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
This commit is contained in:
AntsyLich
2026-10-11 04:28:52 +00:00
committed by GitHub
parent b15af71b94
commit 260e31cd2c
33 changed files with 458 additions and 136 deletions
@@ -225,11 +225,10 @@ class SyncChaptersWithSource(
added = toAdd, added = toAdd,
updated = updatedChapters, updated = updatedChapters,
) )
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, fetchWindow)
// Set this manga as updated since chapters were changed // Set this manga as updated since chapters were changed
// Note that last_update actually represents last time the chapter list changed at all // 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() val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
@@ -30,11 +30,10 @@ class GetExtensionsByType(
val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName) val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName)
val installedSignatures = (_loaded + _notLoaded).groupBy({ it.pkgName }, { it.signatures })
val available = _available val available = _available
.filter { extension -> .filter { extension ->
(_loaded + _notLoaded).none { installedSignatures[extension.pkgName].orEmpty().none { extension.store.signingKey in it } &&
it.pkgName == extension.pkgName && extension.store.signingKey in it.signatures
} &&
extension.contentWarning in enabledContentWarnings extension.contentWarning in enabledContentWarnings
} }
.flatMap { ext -> .flatMap { ext ->
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.repository.ExtensionStoreRepository import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
@@ -18,8 +19,8 @@ class TrustExtension(
private val preferences: SourcePreferences, private val preferences: SourcePreferences,
) { ) {
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean { fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>, stores: List<ExtensionStore>): Boolean {
val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet() val trustedFingerprints = stores.mapTo(HashSet()) { it.signingKey }
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}" val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get() return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get()
} }
@@ -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<Instant>?,
): Boolean {
return mangaRepository.update(
fetchInterval.withFetchInterval(manga, dateTime, timeZone, window).apply {
lastUpdate = Clock.System.now()
},
)
} }
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean { suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
@@ -30,6 +30,7 @@ import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.components.UpIcon
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import kotlinx.coroutines.flow.collectLatest
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Close
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -141,7 +143,7 @@ class SettingsSearchScreen : Screen() {
}, },
) { contentPadding -> ) { contentPadding ->
SearchResult( SearchResult(
searchKey = textFieldState.text.toString(), searchKey = { textFieldState.text.toString() },
listState = listState, listState = listState,
contentPadding = contentPadding, contentPadding = contentPadding,
) { result -> ) { result ->
@@ -154,64 +156,20 @@ class SettingsSearchScreen : Screen() {
@Composable @Composable
private fun SearchResult( private fun SearchResult(
searchKey: String, searchKey: () -> String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues(),
onItemClick: (SearchResultItem) -> Unit, onItemClick: (SearchResultItem) -> Unit,
) { ) {
if (searchKey.isEmpty()) return
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val index = getIndex() val index = getIndex()
val result by produceState<List<SearchResultItem>?>(initialValue = null, searchKey) { // The query is read here rather than in composition, so typing doesn't rebuild the index on every key
value = index.asSequence() val result by produceState<List<SearchResultItem>?>(initialValue = null, index) {
.flatMap { settingsData -> snapshotFlow(searchKey).collectLatest { searchKey ->
fun result(title: String, categoryTitle: String?) = SearchResultItem( value = if (searchKey.isEmpty()) null else search(index, searchKey, isLtr)
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()
} }
Crossfade( Crossfade(
@@ -262,6 +220,55 @@ private fun SearchResult(
} }
} }
private fun search(index: List<SettingsData>, searchKey: String, isLtr: Boolean): List<SearchResultItem> {
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 @Composable
@NonRestartableComposable @NonRestartableComposable
private fun getIndex() = settingScreens private fun getIndex() = settingScreens
@@ -211,6 +211,27 @@ class DownloadCache(
return 0 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<Manga>): Map<Long, Int> {
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. * Adds a chapter that has just been download to this cache.
* *
@@ -246,6 +246,15 @@ class DownloadManager(
return cache.getDownloadCount(manga) 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<Manga>): Map<Long, Int> {
return cache.getDownloadCounts(manga)
}
suspend fun cancelQueuedDownloads(downloads: List<Download>) { suspend fun cancelQueuedDownloads(downloads: List<Download>) {
downloader.dequeue(downloads.map { it.chapter }) downloader.dequeue(downloads.map { it.chapter })
} }
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@@ -62,14 +63,23 @@ class DownloadNotifier(
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED) context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
} }
private var lastProgressNotificationTime = 0L
/** /**
* Called when download progress changes. * 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. * @param download download object containing download information.
*/ */
// Pages report their progress from several threads, and they share the builder
@Synchronized @Synchronized
fun onProgressChange(download: Download) { 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) { with(progressNotificationBuilder) {
if (!isDownloading) { if (!isDownloading) {
context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED) context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED)
@@ -156,6 +166,7 @@ class DownloadNotifier(
/** /**
* Resets the state once downloads are completed. * Resets the state once downloads are completed.
*/ */
@Synchronized
fun onComplete() { fun onComplete() {
dismissProgress() dismissProgress()
@@ -229,3 +240,5 @@ class DownloadNotifier(
isDownloading = false isDownloading = false
} }
} }
private const val PROGRESS_NOTIFICATION_INTERVAL_MS = 500L
@@ -19,11 +19,14 @@ data class Download(
val chapter: Chapter, val chapter: Chapter,
) { ) {
@Transient @Transient
private val pagesFlow = MutableStateFlow<List<Page>?>(null) private val _pagesFlow = MutableStateFlow<List<Page>?>(null)
@Transient
val pagesFlow = _pagesFlow.asStateFlow()
var pages: List<Page>? var pages: List<Page>?
get() = pagesFlow.value get() = _pagesFlow.value
set(pages) { set(pages) {
pagesFlow.value = pages _pagesFlow.value = pages
} }
val totalProgress: Int val totalProgress: Int
@@ -44,7 +47,7 @@ data class Download(
} }
@Transient @Transient
val progressFlow = pagesFlow val progressFlow = _pagesFlow
.flatMapLatest { pages -> .flatMapLatest { pages ->
if (pages == null) { if (pages == null) {
flowOf(0) flowOf(0)
@@ -7,6 +7,7 @@ import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import coil3.asDrawable import coil3.asDrawable
@@ -85,14 +86,24 @@ class LibraryUpdateNotifier(
} }
} }
private var lastProgressNotificationTime = 0L
/** /**
* Shows the notification containing the currently updating manga and the progress. * 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 manga the manga that are being updated.
* @param current the current progress. * @param current the current progress.
* @param total the total progress. * @param total the total progress.
*/ */
@Synchronized
fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) { fun showProgressNotification(manga: List<Manga>, current: Int, total: Int) {
val now = SystemClock.elapsedRealtime()
if (current < total && now - lastProgressNotificationTime < NOTIF_PROGRESS_INTERVAL_MS) return
lastProgressNotificationTime = now
progressNotificationBuilder progressNotificationBuilder
.setContentTitle( .setContentTitle(
context.stringResource( 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_TITLE_MAX_LEN = 45
private const val NOTIF_ICON_SIZE = 192 private const val NOTIF_ICON_SIZE = 192
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
private const val NOTIF_PROGRESS_INTERVAL_MS = 500L
@@ -367,7 +367,7 @@ internal object ExtensionLoader {
if (signatures.isEmpty()) { if (signatures.isEmpty()) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion) 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" } logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion) return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion)
} }
@@ -37,6 +37,7 @@ 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.i18n.MR import tachiyomi.i18n.MR
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @Inject
@@ -69,7 +70,8 @@ class ExtensionsViewModel(
@Suppress("LocalVariableName") @Suppress("LocalVariableName")
private val items = combine( private val items = combine(
searchQuery 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 ?: "") }, .map { searchQueryPredicate(it ?: "") },
currentDownloads, currentDownloads,
getExtensions.subscribe(), getExtensions.subscribe(),
@@ -14,7 +14,6 @@ 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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
@@ -22,6 +21,8 @@ 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.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn 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
@@ -213,11 +214,8 @@ class DownloadQueueViewModel(
*/ */
private fun launchProgressJob(download: Download) { private fun launchProgressJob(download: Download) {
val job = viewModelScope.launch { val job = viewModelScope.launch {
while (download.pages == null) { val pages = download.pagesFlow.filterNotNull().first()
delay(50.milliseconds) val progressFlows = pages.map(Page::progressFlow)
}
val progressFlows = download.pages!!.map(Page::progressFlow)
combine(progressFlows, Array<Int>::sum) combine(progressFlows, Array<Int>::sum)
.distinctUntilChanged() .distinctUntilChanged()
.debounce(50.milliseconds) .debounce(50.milliseconds)
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
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
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.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -32,10 +31,12 @@ 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 kotlinx.coroutines.plus
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope 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.SubscriberGate
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
@@ -71,6 +72,8 @@ class HistoryViewModel(
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
) : ViewModel() { ) : ViewModel() {
private val subscriberGate = SubscriberGate()
val snackbarHostState: SnackbarHostState = SnackbarHostState() val snackbarHostState: SnackbarHostState = SnackbarHostState()
private val _events: Channel<Event> = Channel(Channel.UNLIMITED) private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
@@ -91,7 +94,7 @@ class HistoryViewModel(
.map { it.toHistoryUiModels() } .map { it.toHistoryUiModels() }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList())
val state: StateFlow<State> = combine( val state: StateFlow<State> = combine(
searchQuery, searchQuery,
@@ -100,7 +103,7 @@ class HistoryViewModel(
) { searchQuery, history, dialog -> ) { searchQuery, history, dialog ->
State(searchQuery = searchQuery, list = history, dialog = 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<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> { private fun List<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> {
return map { HistoryUiModel.Item(it) } return map { HistoryUiModel.Item(it) }
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.animation.graphics.vector.AnimatedImageVector
@@ -270,6 +271,8 @@ data object LibraryTab : Tab {
(context as? MainActivity)?.ready = true (context as? MainActivity)?.ready = true
} }
} }
// Lets startup be measured up to the library, not just its loading screen
ReportDrawnWhen { !state.isLoading }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { queryEvent.receiveAsFlow().collect(viewModel::search) } launch { queryEvent.receiveAsFlow().collect(viewModel::search) }
@@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.Flow
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
import kotlinx.coroutines.flow.WhileSubscribed
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
@@ -44,6 +43,7 @@ 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 kotlinx.coroutines.plus
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mihon.core.common.utils.mutate import mihon.core.common.utils.mutate
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
@@ -51,7 +51,9 @@ 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.SubscriberGate
import tachiyomi.core.common.util.lang.compareToWithCollator 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.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
@@ -98,6 +100,8 @@ class LibraryViewModel(
private val trackerManager: TrackerManager, private val trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
private val subscriberGate = SubscriberGate()
private val searchQuery = MutableStateFlow<String?>(null) private val searchQuery = MutableStateFlow<String?>(null)
private val selection = MutableStateFlow(emptySet</* Manga */ Long>()) private val selection = MutableStateFlow(emptySet</* Manga */ Long>())
@@ -140,7 +144,7 @@ class LibraryViewModel(
.flatMapLatest { needsTracks -> if (needsTracks) getTracksPerManga.subscribe() else flowOf(emptyMap()) } .flatMapLatest { needsTracks -> if (needsTracks) getTracksPerManga.subscribe() else flowOf(emptyMap()) }
// Shared separately so search, selection and dialog changes still reach [state] before the // 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( private val library = combine(
searchQuery.debounce(0.25.seconds), searchQuery.debounce(0.25.seconds),
getCategories.subscribe(), getCategories.subscribe(),
@@ -179,7 +183,7 @@ class LibraryViewModel(
) )
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null)
val state: StateFlow<State> = combine( val state: StateFlow<State> = combine(
library, library,
@@ -200,7 +204,7 @@ class LibraryViewModel(
groupedFavorites = library?.groupedFavorites.orEmpty(), groupedFavorites = library?.groupedFavorites.orEmpty(),
) )
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
private data class DisplayPreferences( private data class DisplayPreferences(
val showCategoryTabs: Boolean, val showCategoryTabs: Boolean,
@@ -302,10 +306,9 @@ class LibraryViewModel(
trackMap: Map<Long, List<Track>>, trackMap: Map<Long, List<Track>>,
loggedInTrackerIds: Set<Long>, loggedInTrackerIds: Set<Long>,
): Map<Category, List</* LibraryItem */ Long>> { ): Map<Category, List</* LibraryItem */ Long>> {
// The collator already ignores case, and lowercasing on every comparison is slow on large libraries
val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 -> val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 ->
val title1 = manga1.libraryManga.manga.title.lowercase() manga1.libraryManga.manga.title.compareToWithCollator(manga2.libraryManga.manga.title)
val title2 = manga2.libraryManga.manga.title.lowercase()
title1.compareToWithCollator(title2)
} }
val defaultTrackerScoreSortValue = -1.0 val defaultTrackerScoreSortValue = -1.0
@@ -415,14 +418,15 @@ class LibraryViewModel(
return combine( return combine(
getLibraryManga.subscribe(), getLibraryManga.subscribe(),
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
downloadCache.changes, downloadCache.changes.pausedWhileUnsubscribed(),
) { libraryManga, preferences, _ -> ) { libraryManga, preferences, _ ->
val sources = libraryManga val sources = libraryManga
.mapTo(mutableSetOf()) { it.manga.source } .mapTo(mutableSetOf()) { it.manga.source }
.associateWith { sourceManager.getOrStub(it) } .associateWith { sourceManager.getOrStub(it) }
val downloadCounts = downloadManager.getDownloadCounts(libraryManga.map { it.manga })
libraryManga.map { manga -> libraryManga.map { manga ->
val source = sources.getValue(manga.manga.source) val source = sources.getValue(manga.manga.source)
val downloadCount = downloadManager.getDownloadCount(manga.manga) val downloadCount = downloadCounts.getValue(manga.id)
LibraryItem( LibraryItem(
libraryManga = manga, libraryManga = manga,
downloadCount = downloadCount, downloadCount = downloadCount,
@@ -51,7 +51,6 @@ 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
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.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -65,9 +64,11 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
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.plus
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope 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.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.SubscriberGate
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
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
@@ -100,6 +103,7 @@ import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import kotlin.math.floor import kotlin.math.floor
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@AssistedInject @AssistedInject
@@ -136,6 +140,8 @@ class MangaViewModel(
private val coverCache: CoverCache, private val coverCache: CoverCache,
) : ViewModel() { ) : ViewModel() {
private val subscriberGate = SubscriberGate()
@AssistedFactory @AssistedFactory
@ManualViewModelAssistedFactoryKey @ManualViewModelAssistedFactoryKey
@ContributesIntoMap(AppScope::class) @ContributesIntoMap(AppScope::class)
@@ -206,8 +212,8 @@ class MangaViewModel(
emitAll(getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true)) emitAll(getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true))
} }
.distinctUntilChanged(), .distinctUntilChanged(),
downloadCache.changes, downloadCache.changes.pausedWhileUnsubscribed(),
downloadManager.queueState, downloadManager.queueState.pausedWhileUnsubscribed(),
) { (manga, chapters), _, _ -> ) { (manga, chapters), _, _ ->
MangaAndChapters( MangaAndChapters(
manga = manga, manga = manga,
@@ -242,7 +248,7 @@ class MangaViewModel(
mangaAndChapters, mangaAndChapters,
scanlators, scanlators,
trackers, trackers,
combine(selectedChapterIds, queuedDownloadStates, ::Pair), combine(selectedChapterIds, queuedDownloadStates.pausedWhileUnsubscribed(), ::Pair),
combine(dialog, isRefreshingData, ::Pair), combine(dialog, isRefreshingData, ::Pair),
) { ) {
mangaAndChapters, mangaAndChapters,
@@ -281,12 +287,16 @@ class MangaViewModel(
it.isAnySelected it.isAnySelected
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading) .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State.Loading)
init { init {
// Progress ticks often, and each one copies the map of download states // Progress ticks often, and each one copies the map of download states
viewModelScope.launch(Dispatchers.Default) { 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 } .filter { it.manga.id == mangaId }
.catch { logcat(LogPriority.ERROR, it) } .catch { logcat(LogPriority.ERROR, it) }
.collect(::updateDownloadState) .collect(::updateDownloadState)
@@ -59,8 +59,10 @@ 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
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.catch import kotlinx.coroutines.flow.catch
@@ -777,6 +779,8 @@ data class TrackerSearchScreen(
private val tracker = trackerManager.get(trackerId)!! private val tracker = trackerManager.get(trackerId)!!
private var searchJob: Job? = null
val supportsPrivateTracking = tracker.supportsPrivateTracking val supportsPrivateTracking = tracker.supportsPrivateTracking
init { init {
@@ -787,7 +791,8 @@ data class TrackerSearchScreen(
} }
fun trackingSearch(query: String) { fun trackingSearch(query: String) {
viewModelScope.launch { searchJob?.cancel()
searchJob = viewModelScope.launch {
// To show loading state // To show loading state
state.update { it.copy(queryResult = null, selected = null) } state.update { it.copy(queryResult = null, selected = null) }
@@ -796,6 +801,7 @@ data class TrackerSearchScreen(
val results = tracker.search(query) val results = tracker.search(query)
Result.success(results) Result.success(results)
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e
Result.failure(e) Result.failure(e)
} }
} }
@@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged 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.image.LocalCoverManager
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -197,6 +199,11 @@ class ReaderViewModel(
// Serializes the progress writes, which outlive the reader, so they are applied in order // Serializes the progress writes, which outlive the reader, so they are applied in order
private val progressWrites = Mutex() 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 chapterToDownload: Download? = null
private var unfilteredChapterList: List<Chapter>? = null private var unfilteredChapterList: List<Chapter>? = null
@@ -540,10 +547,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. The database calls are main-safe. // Save last page read and mark as read if needed
appScope.launch(Dispatchers.Main.immediate) {
updateChapterProgress(selectedChapter, page) updateChapterProgress(selectedChapter, page)
}
if (selectedChapter != getCurrentChapter()) { if (selectedChapter != getCurrentChapter()) {
logcat { "Setting ${selectedChapter.chapter.url} as active" } 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) * Saves the chapter progress (last read page and whether it's read)
* if incognito mode isn't on. * 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 val pageIndex = page.index
mutableState.update { mutableState.update {
@@ -649,22 +657,49 @@ class ReaderViewModel(
readerChapter.requestedPage = pageIndex readerChapter.requestedPage = pageIndex
chapterPageIndex = pageIndex chapterPageIndex = pageIndex
if (!incognitoMode && page.status !is Page.State.Error) { if (incognitoMode || page.status is Page.State.Error) return
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
val isLastPage = readerChapter.pages?.lastIndex == pageIndex
// Taken before the first suspension, and the mutex is fair, so the writes land in the order the pages val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
// were selected even when one of them suspends longer // A page change in another chapter would replace that chapter's pending save
progressWrites.withLock { if (pendingProgressChapter != readerChapter) saveChapterProgress()
if (isLastPage) { readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
updateChapterProgressOnComplete(readerChapter) 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 (onComplete) updateChapterProgressOnComplete(readerChapter)
val chapter = readerChapter.chapter
updateChapter.await( updateChapter.await(
ChapterUpdate(readerChapter.chapter.id) { ChapterUpdate(chapter.id) {
this.read = read read = chapter.read
lastPageRead = pageIndex lastPageRead = chapter.lastPageRead
}, },
) )
} }
@@ -710,6 +745,7 @@ class ReaderViewModel(
* Saves the chapter last read history if incognito mode isn't on. * Saves the chapter last read history if incognito mode isn't on.
*/ */
private suspend fun updateHistory() { private suspend fun updateHistory() {
saveChapterProgress()
getCurrentChapter()?.let { readerChapter -> getCurrentChapter()?.let { readerChapter ->
if (incognitoMode) return@let if (incognitoMode) return@let
@@ -1109,3 +1145,5 @@ class ReaderViewModel(
data class CopyImage(val uri: Uri) : Event data class CopyImage(val uri: Uri) : Event
} }
} }
private val PROGRESS_SAVE_DELAY = 2.seconds
@@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.Flow
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
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.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -40,15 +39,19 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.sample
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.plus
import kotlinx.datetime.DateTimeUnit 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 mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.preference.TriState 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.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
@@ -61,6 +64,7 @@ import tachiyomi.domain.updates.interactor.GetUpdates
import tachiyomi.domain.updates.model.UpdatesWithRelations import tachiyomi.domain.updates.model.UpdatesWithRelations
import tachiyomi.domain.updates.service.UpdatesPreferences import tachiyomi.domain.updates.service.UpdatesPreferences
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @Inject
@@ -81,6 +85,8 @@ class UpdatesViewModel(
private val updatesPreferences: UpdatesPreferences, private val updatesPreferences: UpdatesPreferences,
) : ViewModel() { ) : ViewModel() {
private val subscriberGate = SubscriberGate()
val snackbarHostState: SnackbarHostState = SnackbarHostState() val snackbarHostState: SnackbarHostState = SnackbarHostState()
private val _events: Channel<Event> = Channel(Int.MAX_VALUE) private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
@@ -105,7 +111,8 @@ class UpdatesViewModel(
init { init {
viewModelScope.launch { 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) } .catch { logcat(LogPriority.ERROR, it) }
.collect(this@UpdatesViewModel::updateDownloadState) .collect(this@UpdatesViewModel::updateDownloadState)
} }
@@ -157,8 +164,8 @@ class UpdatesViewModel(
excludedCategories = it.filterExcludedCategories, excludedCategories = it.filterExcludedCategories,
).distinctUntilChanged() ).distinctUntilChanged()
}, },
downloadCache.changes, downloadCache.changes.pausedWhileUnsubscribed(),
downloadManager.queueState, downloadManager.queueState.pausedWhileUnsubscribed(),
// needed for Kotlin filters (downloaded) // needed for Kotlin filters (downloaded)
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new -> getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
old.filterDownloaded == new.filterDownloaded old.filterDownloaded == new.filterDownloaded
@@ -169,12 +176,12 @@ class UpdatesViewModel(
.applyFilters(itemPreferences) .applyFilters(itemPreferences)
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null)
val state: StateFlow<State> = combine( val state: StateFlow<State> = combine(
updateItems, updateItems,
selectedChapterIds, selectedChapterIds,
queuedDownloadStates, queuedDownloadStates.pausedWhileUnsubscribed(),
dialog, dialog,
hasActiveFilters, hasActiveFilters,
) { items, selectedIds, downloads, dialog, hasActiveFilters -> ) { items, selectedIds, downloads, dialog, hasActiveFilters ->
@@ -201,7 +208,7 @@ class UpdatesViewModel(
) )
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State())
private fun List<UpdatesItem>.applyFilters( private fun List<UpdatesItem>.applyFilters(
preferences: ItemPreferences, preferences: ItemPreferences,
@@ -38,7 +38,7 @@ import mihon.feature.migration.list.models.MigratingManga
import mihon.feature.migration.list.models.MigratingManga.SearchResult import mihon.feature.migration.list.models.MigratingManga.SearchResult
import mihon.feature.migration.list.search.SmartSourceSearchEngine import mihon.feature.migration.list.search.SmartSourceSearchEngine
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.GetChapterSummaryByMangaId
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
@@ -52,7 +52,7 @@ class MigrationListViewModel(
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
private val getManga: GetManga, private val getManga: GetManga,
private val networkToLocalManga: NetworkToLocalManga, private val networkToLocalManga: NetworkToLocalManga,
private val getChaptersByMangaId: GetChaptersByMangaId, private val getChapterSummaryByMangaId: GetChapterSummaryByMangaId,
private val migrateManga: MigrateMangaUseCase, private val migrateManga: MigrateMangaUseCase,
private val updateMangaFromRemote: UpdateMangaFromRemote, private val updateMangaFromRemote: UpdateMangaFromRemote,
) : ViewModel() { ) : 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( ChapterInfo(
latestChapter = chapters.maxOfOrNull { it.chapterNumber }, latestChapter = summary.latestChapterNumber,
chapterCount = chapters.size, chapterCount = summary.chapterCount.toInt(),
) )
} }
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow
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
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -24,6 +23,7 @@ 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.plus
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
@@ -31,6 +31,7 @@ import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.yearMonth import kotlinx.datetime.yearMonth
import mihon.domain.upcoming.interactor.GetUpcomingManga import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.util.lang.SubscriberGate
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -47,6 +48,8 @@ class UpcomingViewModel(
val upcomingPreferences: UpcomingPreferences, val upcomingPreferences: UpcomingPreferences,
) : ViewModel() { ) : ViewModel() {
private val subscriberGate = SubscriberGate()
val excludedCategories = upcomingPreferences.filterExcludedCategories val excludedCategories = upcomingPreferences.filterExcludedCategories
val includedCategories = upcomingPreferences.filterIncludedCategories val includedCategories = upcomingPreferences.filterIncludedCategories
@@ -80,7 +83,7 @@ class UpcomingViewModel(
.distinctUntilChanged() .distinctUntilChanged()
.map { it.toUpcomingUIModels() } .map { it.toUpcomingUIModels() }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList())
val state: StateFlow<State> = combine( val state: StateFlow<State> = combine(
upcoming, upcoming,
@@ -98,8 +101,8 @@ class UpcomingViewModel(
) )
} }
.stateIn( .stateIn(
viewModelScope, viewModelScope + subscriberGate,
SharingStarted.WhileSubscribed(5.seconds), subscriberGate.whileSubscribed(5.seconds),
State(selectedYearMonth = selectedYearMonth.value), State(selectedYearMonth = selectedYearMonth.value),
) )
@@ -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<SubscriberGate>
private val open = MutableStateFlow(true)
fun whileSubscribed(stopTimeout: Duration): SharingStarted = object : SharingStarted {
override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> = 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 <T> Flow<T>.pausedWhileUnsubscribed(): Flow<T> = flow {
val gate = currentCoroutineContext()[SubscriberGate]
if (gate == null) {
emitAll(this@pausedWhileUnsubscribed)
} else {
this@pausedWhileUnsubscribed.conflate().collect {
gate.awaitOpen()
emit(it)
}
}
}
@@ -318,8 +318,16 @@ object ImageUtil {
null 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 val whiteColor = Color.WHITE
if (image == null) return whiteColor.toDrawable()
if (image.width < 50 || image.height < 50) { if (image.width < 50 || image.height < 50) {
return whiteColor.toDrawable() return whiteColor.toDrawable()
} }
@@ -6,17 +6,18 @@ import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOne import app.cash.sqldelight.coroutines.mapToOne
import app.cash.sqldelight.coroutines.mapToOneOrNull import app.cash.sqldelight.coroutines.mapToOneOrNull
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
fun <T : Any> Query<T>.subscribeToList( fun <T : Any> Query<T>.subscribeToList(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
): Flow<List<T>> = asFlow().mapToList(context) ): Flow<List<T>> = asFlow().pausedWhileUnsubscribed().mapToList(context)
fun <T : Any> Query<T>.subscribeToOne( fun <T : Any> Query<T>.subscribeToOne(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
): Flow<T> = asFlow().mapToOne(context) ): Flow<T> = asFlow().pausedWhileUnsubscribed().mapToOne(context)
fun <T : Any> Query<T>.subscribeToOneOrNull( fun <T : Any> Query<T>.subscribeToOneOrNull(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
): Flow<T?> = asFlow().mapToOneOrNull(context) ): Flow<T?> = asFlow().pausedWhileUnsubscribed().mapToOneOrNull(context)
@@ -14,6 +14,7 @@ import tachiyomi.data.Database
import tachiyomi.data.subscribeToList import tachiyomi.data.subscribeToList
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
import tachiyomi.domain.chapter.model.ChapterSummary
import tachiyomi.domain.chapter.model.ChapterUpdate import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.chapter.repository.ChapterRepository import tachiyomi.domain.chapter.repository.ChapterRepository
import kotlin.time.Instant import kotlin.time.Instant
@@ -112,6 +113,14 @@ class ChapterRepositoryImpl(
.awaitAsList() .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<String> { override suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> {
return database.chapterQueries return database.chapterQueries
.getScanlatorsByMangaId(mangaId) { it.orEmpty() } .getScanlatorsByMangaId(mangaId) { it.orEmpty() }
@@ -48,6 +48,11 @@ AND (
OR ES.scanlator IS NULL OR ES.scanlator IS NULL
); );
getChapterSummaryByMangaId:
SELECT count(*) AS total, max(remote_chapter_number) AS latestChapterNumber
FROM chapter
WHERE manga_id = :mangaId;
getScanlatorsByMangaId: getScanlatorsByMangaId:
SELECT DISTINCT remote_scanlator SELECT DISTINCT remote_scanlator
FROM chapter FROM chapter
@@ -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)
}
}
@@ -0,0 +1,6 @@
package tachiyomi.domain.chapter.model
data class ChapterSummary(
val chapterCount: Long,
val latestChapterNumber: Double?,
)
@@ -3,6 +3,7 @@ package tachiyomi.domain.chapter.repository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
import tachiyomi.domain.chapter.model.ChapterSummary
import tachiyomi.domain.chapter.model.ChapterUpdate import tachiyomi.domain.chapter.model.ChapterUpdate
interface ChapterRepository { interface ChapterRepository {
@@ -19,6 +20,8 @@ interface ChapterRepository {
suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter> suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>> fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>>
@@ -1,9 +1,20 @@
package tachiyomi.presentation.widget package tachiyomi.presentation.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
class UpdatesGridCoverScreenGlanceReceiver : GlanceAppWidgetReceiver() { class UpdatesGridCoverScreenGlanceReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget override val glanceAppWidget: GlanceAppWidget
get() = UpdatesGridCoverScreenGlanceWidget() get() = UpdatesGridCoverScreenGlanceWidget()
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetManager.onWidgetEnabled(glanceAppWidget)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetManager.onWidgetDisabled(glanceAppWidget)
}
} }
@@ -1,5 +1,6 @@
package tachiyomi.presentation.widget package tachiyomi.presentation.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -7,4 +8,14 @@ class UpdatesGridGlanceReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget override val glanceAppWidget: GlanceAppWidget
get() = UpdatesGridGlanceWidget() get() = UpdatesGridGlanceWidget()
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetManager.onWidgetEnabled(glanceAppWidget)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetManager.onWidgetDisabled(glanceAppWidget)
}
} }
@@ -1,16 +1,25 @@
package tachiyomi.presentation.widget package tachiyomi.presentation.widget
import android.content.Context import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateAll
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.updates.interactor.GetUpdates import tachiyomi.domain.updates.interactor.GetUpdates
@@ -21,21 +30,40 @@ class WidgetManager(
private val securityPreferences: SecurityPreferences, private val securityPreferences: SecurityPreferences,
) { ) {
@OptIn(ExperimentalCoroutinesApi::class)
context(context: Context) context(context: Context)
fun init(scope: CoroutineScope) { fun init(scope: CoroutineScope) {
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( combine(
getUpdates.subscribe(read = false, after = BaseUpdatesGridGlanceWidget.DateLimit), getUpdates.subscribe(
read = false,
after = BaseUpdatesGridGlanceWidget.DateLimit,
),
securityPreferences.useAuthenticator.changes(), securityPreferences.useAuthenticator.changes(),
transform = { a, b -> a to b }, transform = { a, b -> a to b },
) )
} else {
emptyFlow()
}
}
.distinctUntilChanged { old, new -> .distinctUntilChanged { old, new ->
old.second == new.second && old.second == new.second &&
old.first.map { it.chapterId }.toSet() == new.first.map { it.chapterId }.toSet() old.first.map { it.chapterId }.toSet() == new.first.map { it.chapterId }.toSet()
} }
.onEach { .onEach {
try { try {
UpdatesGridGlanceWidget().updateAll(context) widgets.forEach { it.updateAll(context) }
UpdatesGridCoverScreenGlanceWidget().updateAll(context)
} catch (e: Exception) { } catch (e: Exception) {
this.logcat(LogPriority.ERROR, e) { "Failed to update widget" } this.logcat(LogPriority.ERROR, e) { "Failed to update widget" }
} }
@@ -43,4 +71,19 @@ class WidgetManager(
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.launchIn(scope) .launchIn(scope)
} }
companion object {
private val widgets: List<GlanceAppWidget>
get() = listOf(UpdatesGridGlanceWidget(), UpdatesGridCoverScreenGlanceWidget())
private val placedWidgets = MutableStateFlow(emptySet<Class<out GlanceAppWidget>>())
internal fun onWidgetEnabled(widget: GlanceAppWidget) {
placedWidgets.update { it + widget.javaClass }
}
internal fun onWidgetDisabled(widget: GlanceAppWidget) {
placedWidgets.update { it - widget.javaClass }
}
}
} }