Make UpdatesViewModel collect updates only while subscribed (#3761)

Assisted-by: Claude:claude-opus-5
This commit is contained in:
AntsyLich
2026-08-15 01:39:04 +06:00
committed by GitHub
parent 55675e7f80
commit b1a9a05926
2 changed files with 158 additions and 153 deletions
@@ -6,9 +6,9 @@ import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
@@ -56,7 +56,7 @@ data object UpdatesTab : Tab {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val viewModel = viewModel<UpdatesViewModel>() val viewModel = viewModel<UpdatesViewModel>()
val settingsViewModel = viewModel<UpdatesSettingsViewModel>() val settingsViewModel = viewModel<UpdatesSettingsViewModel>()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsStateWithLifecycle()
UpdateScreen( UpdateScreen(
state = state, state = state,
@@ -5,6 +5,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.util.fastFilter import androidx.compose.ui.util.fastFilter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import eu.kanade.core.preference.asState import eu.kanade.core.preference.asState
import eu.kanade.core.util.addOrRemove import eu.kanade.core.util.addOrRemove
@@ -17,25 +18,28 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.util.lang.toLocalDate import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.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.catch
import kotlinx.coroutines.flow.collectLatest
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
import kotlinx.coroutines.flow.launchIn 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.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
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.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.viewmodel.StateViewModel
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.lang.launchNonCancellable
@@ -53,6 +57,7 @@ import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
class UpdatesViewModel( class UpdatesViewModel(
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
@@ -66,7 +71,7 @@ class UpdatesViewModel(
private val libraryPreferences: LibraryPreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val updatesPreferences: UpdatesPreferences = Injekt.get(), private val updatesPreferences: UpdatesPreferences = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(), val snackbarHostState: SnackbarHostState = SnackbarHostState(),
) : StateViewModel<UpdatesViewModel.State>(State()) { ) : ViewModel() {
private val _events: Channel<Event> = Channel(Int.MAX_VALUE) private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _events.receiveAsFlow() val events: Flow<Event> = _events.receiveAsFlow()
@@ -75,74 +80,105 @@ class UpdatesViewModel(
// First and last selected index in list // First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1) private val selectedPositions: Array<Int> = arrayOf(-1, -1)
private val selectedChapterIds: HashSet<Long> = HashSet() private val selectedChapterIds = MutableStateFlow(emptySet<Long>())
private val dialog = MutableStateFlow<Dialog?>(null)
private val downloadStates = MutableStateFlow(emptyMap<Long, DownloadProgress>())
init { init {
viewModelScope.launchIO {
// Set date limit for recent chapters
val limit = Clock.System.now().minus(3, DateTimeUnit.MONTH, TimeZone.currentSystemDefault())
combine(
// needed for SQL filters (unread, started, bookmarked, etc)
getUpdatesItemPreferenceFlow()
.distinctUntilChanged()
.flatMapLatest {
getUpdates.subscribe(
limit,
unread = it.filterUnread.toBooleanOrNull(),
started = it.filterStarted.toBooleanOrNull(),
bookmarked = it.filterBookmarked.toBooleanOrNull(),
hideExcludedScanlators = it.filterExcludedScanlators,
includedCategories = it.filterIncludedCategories,
excludedCategories = it.filterExcludedCategories,
).distinctUntilChanged()
},
downloadCache.changes,
downloadManager.queueState,
// needed for Kotlin filters (downloaded)
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
old.filterDownloaded == new.filterDownloaded
},
) { updates, _, _, itemPreferences ->
updates
.toUpdateItems()
.applyFilters(itemPreferences)
}
.collectLatest { updateItems ->
mutableState.update {
it.copy(
isLoading = false,
items = updateItems,
)
}
}
}
viewModelScope.launchIO { viewModelScope.launchIO {
merge(downloadManager.statusFlow(), downloadManager.progressFlow()) merge(downloadManager.statusFlow(), downloadManager.progressFlow())
.catch { logcat(LogPriority.ERROR, it) } .catch { logcat(LogPriority.ERROR, it) }
.collect(this@UpdatesViewModel::updateDownloadState) .collect(this@UpdatesViewModel::updateDownloadState)
} }
getUpdatesItemPreferenceFlow()
.map { prefs ->
listOf(
prefs.filterUnread,
prefs.filterDownloaded,
prefs.filterStarted,
prefs.filterBookmarked,
)
.any { it != TriState.DISABLED }
}
.distinctUntilChanged()
.onEach {
mutableState.update { state ->
state.copy(hasActiveFilters = it)
}
}
.launchIn(viewModelScope)
} }
private fun updateDownloadState(download: Download) {
val chapterId = download.chapter.id
downloadStates.update {
// Terminal states are derived by the queried item itself, so drop the override instead
// of letting it outlive reality, e.g. showing a since deleted chapter as downloaded.
if (download.status == Download.State.NOT_DOWNLOADED || download.status == Download.State.DOWNLOADED) {
it - chapterId
} else {
it + (chapterId to DownloadProgress(download.status, download.progress))
}
}
}
private val hasActiveFilters = getUpdatesItemPreferenceFlow()
.map { prefs ->
listOf(
prefs.filterUnread,
prefs.filterDownloaded,
prefs.filterStarted,
prefs.filterBookmarked,
)
.any { it != TriState.DISABLED }
}
.distinctUntilChanged()
private val updateItems = combine(
// needed for SQL filters (unread, started, bookmarked, etc)
getUpdatesItemPreferenceFlow()
.distinctUntilChanged()
.flatMapLatest {
getUpdates.subscribe(
Clock.System.now().minus(3, DateTimeUnit.MONTH, TimeZone.currentSystemDefault()),
unread = it.filterUnread.toBooleanOrNull(),
started = it.filterStarted.toBooleanOrNull(),
bookmarked = it.filterBookmarked.toBooleanOrNull(),
hideExcludedScanlators = it.filterExcludedScanlators,
includedCategories = it.filterIncludedCategories,
excludedCategories = it.filterExcludedCategories,
).distinctUntilChanged()
},
downloadCache.changes,
downloadManager.queueState,
// needed for Kotlin filters (downloaded)
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
old.filterDownloaded == new.filterDownloaded
},
) { updates, _, _, itemPreferences ->
updates
.toUpdateItems()
.applyFilters(itemPreferences)
}
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
val state: StateFlow<State> = combine(
updateItems,
selectedChapterIds,
downloadStates,
dialog,
hasActiveFilters,
) { items, selectedIds, downloads, dialog, hasActiveFilters ->
State(
isLoading = items == null,
hasActiveFilters = hasActiveFilters,
items = items.orEmpty().map { item ->
val download = downloads[item.update.chapterId]
item.copy(
selected = item.update.chapterId in selectedIds,
downloadStateProvider = if (download != null) {
{ download.status }
} else {
item.downloadStateProvider
},
downloadProgressProvider = if (download != null) {
{ download.progress }
} else {
item.downloadProgressProvider
},
)
},
dialog = dialog,
)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
private fun List<UpdatesItem>.applyFilters( private fun List<UpdatesItem>.applyFilters(
preferences: ItemPreferences, preferences: ItemPreferences,
): List<UpdatesItem> { ): List<UpdatesItem> {
@@ -179,7 +215,6 @@ class UpdatesViewModel(
update = update, update = update,
downloadStateProvider = { downloadState }, downloadStateProvider = { downloadState },
downloadProgressProvider = { activeDownload?.progress ?: 0 }, downloadProgressProvider = { activeDownload?.progress ?: 0 },
selected = update.chapterId in selectedChapterIds,
) )
} }
} }
@@ -192,27 +227,6 @@ class UpdatesViewModel(
return started return started
} }
/**
* Update status of chapters.
*
* @param download download object containing progress.
*/
private fun updateDownloadState(download: Download) {
mutableState.update { state ->
val newItems = state.items.toMutableList().also { list ->
val modifiedIndex = list.indexOfFirst { it.update.chapterId == download.chapter.id }
if (modifiedIndex < 0) return@also
val item = list[modifiedIndex]
list[modifiedIndex] = item.copy(
downloadStateProvider = { download.status },
downloadProgressProvider = { download.progress },
)
}
state.copy(items = newItems)
}
}
fun downloadChapters(items: List<UpdatesItem>, action: ChapterDownloadAction) { fun downloadChapters(items: List<UpdatesItem>, action: ChapterDownloadAction) {
if (items.isEmpty()) return if (items.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
@@ -327,91 +341,80 @@ class UpdatesViewModel(
selected: Boolean, selected: Boolean,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
mutableState.update { state -> val items = state.value.items
val newItems = state.items.toMutableList().apply { val selectedIndex = items.indexOfFirst { it.update.chapterId == item.update.chapterId }
val selectedIndex = indexOfFirst { it.update.chapterId == item.update.chapterId } if (selectedIndex < 0) return
if (selectedIndex < 0) return@apply
val selectedItem = get(selectedIndex) // Read selection from its own flow, not the derived items, which lag behind it.
if (selectedItem.selected == selected) return@apply val currentSelection = selectedChapterIds.value
if ((item.update.chapterId in currentSelection) == selected) return
val firstSelection = none { it.selected } // Off the visible items, not the id set, which can retain ids filtered out of the list
set(selectedIndex, selectedItem.copy(selected = selected)) val firstSelection = items.none { it.selected }
selectedChapterIds.addOrRemove(item.update.chapterId, selected) val newSelection = currentSelection.toHashSet()
newSelection.addOrRemove(item.update.chapterId, selected)
if (selected && fromLongPress) { if (selected && fromLongPress) {
if (firstSelection) { if (firstSelection) {
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
} else { } else {
// Try to select the items in-between when possible // Try to select the items in-between when possible
val range: IntRange val range: IntRange
if (selectedIndex < selectedPositions[0]) { if (selectedIndex < selectedPositions[0]) {
range = selectedIndex + 1..<selectedPositions[0] range = selectedIndex + 1..<selectedPositions[0]
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) { } else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..<selectedIndex range = (selectedPositions[1] + 1)..<selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
} else { } else {
// Just select itself // Just select itself
range = IntRange.EMPTY range = IntRange.EMPTY
} }
range.forEach { range.forEach { newSelection.add(items[it].update.chapterId) }
val inbetweenItem = get(it) }
if (!inbetweenItem.selected) { } else if (!fromLongPress) {
selectedChapterIds.add(inbetweenItem.update.chapterId) if (!selected) {
set(it, inbetweenItem.copy(selected = true)) if (selectedIndex == selectedPositions[0]) {
} selectedPositions[0] = items.indexOfFirst { it.update.chapterId in newSelection }
} } else if (selectedIndex == selectedPositions[1]) {
} selectedPositions[1] = items.indexOfLast { it.update.chapterId in newSelection }
} else if (!fromLongPress) { }
if (!selected) { } else {
if (selectedIndex == selectedPositions[0]) { if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected } selectedPositions[0] = selectedIndex
} else if (selectedIndex == selectedPositions[1]) { } else if (selectedIndex > selectedPositions[1]) {
selectedPositions[1] = indexOfLast { it.selected } selectedPositions[1] = selectedIndex
}
} else {
if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
selectedPositions[1] = selectedIndex
}
}
} }
} }
state.copy(items = newItems)
} }
selectedChapterIds.update { newSelection }
} }
fun toggleAllSelection(selected: Boolean) { fun toggleAllSelection(selected: Boolean) {
mutableState.update { state -> val ids = if (selected) state.value.items.map { it.update.chapterId }.toSet() else emptySet()
val newItems = state.items.map { selectedChapterIds.update { ids }
selectedChapterIds.addOrRemove(it.update.chapterId, selected)
it.copy(selected = selected)
}
state.copy(items = newItems)
}
selectedPositions[0] = -1 selectedPositions[0] = -1
selectedPositions[1] = -1 selectedPositions[1] = -1
} }
fun invertSelection() { fun invertSelection() {
mutableState.update { state -> val current = selectedChapterIds.value
val newItems = state.items.map { val ids = state.value.items
selectedChapterIds.addOrRemove(it.update.chapterId, !it.selected) .map { it.update.chapterId }
it.copy(selected = !it.selected) .filterNot { it in current }
} .toSet()
state.copy(items = newItems) selectedChapterIds.update { ids }
}
selectedPositions[0] = -1 selectedPositions[0] = -1
selectedPositions[1] = -1 selectedPositions[1] = -1
} }
fun setDialog(dialog: Dialog?) { fun setDialog(dialog: Dialog?) {
mutableState.update { it.copy(dialog = dialog) } this.dialog.update { dialog }
} }
fun resetNewUpdatesCount() { fun resetNewUpdatesCount() {
@@ -442,7 +445,7 @@ class UpdatesViewModel(
} }
fun showFilterDialog() { fun showFilterDialog() {
mutableState.update { it.copy(dialog = Dialog.FilterSheet) } dialog.update { Dialog.FilterSheet }
} }
@Immutable @Immutable
@@ -456,6 +459,8 @@ class UpdatesViewModel(
val filterExcludedCategories: List<Long>, val filterExcludedCategories: List<Long>,
) )
private data class DownloadProgress(val status: Download.State, val progress: Int)
@Immutable @Immutable
data class State( data class State(
val isLoading: Boolean = true, val isLoading: Boolean = true,