Make ExtensionsViewModel collect extensions only while subscribed (#3729)

Assisted-by: Claude:claude-opus-5
This commit is contained in:
AntsyLich
2026-08-08 20:05:11 +06:00
committed by GitHub
parent a494ebd791
commit 77a1a5afbd
3 changed files with 90 additions and 76 deletions
@@ -6,9 +6,9 @@ import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
@@ -59,7 +59,7 @@ data object BrowseTab : Tab {
// Hoisted for extensions tab's search bar
val extensionsViewModel = viewModel<ExtensionsViewModel>()
val extensionsState by extensionsViewModel.state.collectAsState()
val extensionsSearchQuery by extensionsViewModel.searchQuery.collectAsStateWithLifecycle()
val tabs = listOf(
sourcesTab(),
@@ -73,7 +73,7 @@ data object BrowseTab : Tab {
titleRes = MR.strings.browse,
tabs = tabs,
state = state,
searchQuery = extensionsState.searchQuery,
searchQuery = extensionsSearchQuery,
onChangeSearchQuery = extensionsViewModel::search,
)
LaunchedEffect(Unit) {
@@ -5,12 +5,12 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.ExtensionScreen
@@ -31,12 +31,12 @@ fun extensionsTab(
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val state by extensionsViewModel.state.collectAsState()
val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle()
var privateExtensionToUninstall by remember { mutableStateOf<Extension?>(null) }
return TabContent(
titleRes = MR.strings.label_extensions,
badgeNumber = state.updates.takeIf { it > 0 },
badgeNumber = updatesCount.takeIf { it > 0 },
searchEnabled = true,
actions = listOf(
AppBar.OverflowAction(
@@ -49,6 +49,8 @@ fun extensionsTab(
),
),
content = { contentPadding, _ ->
val state by extensionsViewModel.state.collectAsStateWithLifecycle()
BackHandler(enabled = state.searchQuery != null) {
extensionsViewModel.search(null)
}
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.browse.extension
import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.base.BasePreferences
@@ -12,22 +13,24 @@ import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
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.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mihon.core.viewmodel.StateViewModel
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
@@ -35,26 +38,33 @@ import uy.kohesive.injekt.api.get
import kotlin.time.Duration.Companion.seconds
class ExtensionsViewModel(
preferences: SourcePreferences = Injekt.get(),
private val preferences: SourcePreferences = Injekt.get(),
basePreferences: BasePreferences = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensions: GetExtensionsByType = Injekt.get(),
) : StateViewModel<ExtensionsViewModel.State>(State()) {
) : ViewModel() {
private val currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
init {
val context = Injekt.get<Application>()
val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel.Item) = { map ->
{
private val context = Injekt.get<Application>()
// Public so BrowseTab's search bar can observe it without subscribing to the whole state.
val searchQuery: StateFlow<String?>
field = MutableStateFlow(null)
// Public so the tab badge can observe it without subscribing to the whole state.
val updatesCount = preferences.extensionUpdatesCount.changes()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), 0)
private val isRefreshing = MutableStateFlow(false)
private fun extensionMapper(map: Map<String, InstallStep>): (Extension) -> ExtensionUiModel.Item = {
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
}
}
viewModelScope.launchIO {
combine(
state.map { it.searchQuery }
.distinctUntilChanged()
@Suppress("LocalVariableName")
private val items = combine(
searchQuery
.debounce(0.25.seconds)
.map { searchQueryPredicate(it ?: "") },
currentDownloads,
@@ -85,25 +95,29 @@ class ExtensionsViewModel(
}
}
}
.collectLatest { items ->
mutableState.update { state ->
state.copy(
isLoading = false,
items = items,
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
val state: StateFlow<State> = combine(
items,
searchQuery,
isRefreshing,
preferences.extensionUpdatesCount.changes(),
basePreferences.extensionInstaller.changes(),
) { items, searchQuery, isRefreshing, updates, installer ->
State(
isLoading = items == null,
isRefreshing = isRefreshing,
items = items.orEmpty(),
updates = updates,
installer = installer,
searchQuery = searchQuery,
)
}
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
init {
viewModelScope.launchIO { findAvailableExtensions() }
preferences.extensionUpdatesCount.changes()
.onEach { mutableState.update { state -> state.copy(updates = it) } }
.launchIn(viewModelScope)
basePreferences.extensionInstaller.changes()
.onEach { mutableState.update { state -> state.copy(installer = it) } }
.launchIn(viewModelScope)
}
fun searchQueryPredicate(query: String): (Extension) -> Boolean {
@@ -137,9 +151,7 @@ class ExtensionsViewModel(
}
fun search(query: String?) {
mutableState.update {
it.copy(searchQuery = query)
}
searchQuery.update { query }
}
fun updateAllExtensions() {
@@ -190,14 +202,14 @@ class ExtensionsViewModel(
fun findAvailableExtensions() {
viewModelScope.launchIO {
mutableState.update { it.copy(isRefreshing = true) }
isRefreshing.update { true }
extensionManager.findAvailableExtensions()
// Fake slower refresh so it doesn't seem like it's not doing anything
delay(1.seconds)
mutableState.update { it.copy(isRefreshing = false) }
isRefreshing.update { false }
}
}