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,
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()
@@ -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 ->
@@ -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<String>): Boolean {
val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>, stores: List<ExtensionStore>): 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()
}
@@ -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 {
@@ -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<List<SearchResultItem>?>(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<List<SearchResultItem>?>(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<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
@NonRestartableComposable
private fun getIndex() = settingScreens
@@ -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<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.
*
@@ -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<Manga>): Map<Long, Int> {
return cache.getDownloadCounts(manga)
}
suspend fun cancelQueuedDownloads(downloads: List<Download>) {
downloader.dequeue(downloads.map { it.chapter })
}
@@ -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
@@ -19,11 +19,14 @@ data class Download(
val chapter: Chapter,
) {
@Transient
private val pagesFlow = MutableStateFlow<List<Page>?>(null)
private val _pagesFlow = MutableStateFlow<List<Page>?>(null)
@Transient
val pagesFlow = _pagesFlow.asStateFlow()
var pages: List<Page>?
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)
@@ -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<Manga>, 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
@@ -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)
}
@@ -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(),
@@ -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<Int>::sum)
.distinctUntilChanged()
.debounce(50.milliseconds)
@@ -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<Event> = 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<State> = 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<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> {
return map { HistoryUiModel.Item(it) }
@@ -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) }
@@ -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<String?>(null)
private val selection = MutableStateFlow(emptySet</* Manga */ Long>())
@@ -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<State> = 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<Long, List<Track>>,
loggedInTrackerIds: Set<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 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,
@@ -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)
@@ -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)
}
}
@@ -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<Chapter>? = 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
@@ -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<Event> = 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<State> = 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<UpdatesItem>.applyFilters(
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.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(),
)
}
@@ -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<State> = combine(
upcoming,
@@ -98,8 +101,8 @@ class UpcomingViewModel(
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5.seconds),
viewModelScope + subscriberGate,
subscriberGate.whileSubscribed(5.seconds),
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
}
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()
}
@@ -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 <T : Any> Query<T>.subscribeToList(
context: CoroutineContext = EmptyCoroutineContext,
): Flow<List<T>> = asFlow().mapToList(context)
): Flow<List<T>> = asFlow().pausedWhileUnsubscribed().mapToList(context)
fun <T : Any> Query<T>.subscribeToOne(
context: CoroutineContext = EmptyCoroutineContext,
): Flow<T> = asFlow().mapToOne(context)
): Flow<T> = asFlow().pausedWhileUnsubscribed().mapToOne(context)
fun <T : Any> Query<T>.subscribeToOneOrNull(
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.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<String> {
return database.chapterQueries
.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
@@ -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
@@ -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 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<Chapter>
suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>>
@@ -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)
}
}
@@ -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)
}
}
@@ -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<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 }
}
}
}