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.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.Navigator import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
@@ -59,7 +59,7 @@ data object BrowseTab : Tab {
// Hoisted for extensions tab's search bar // Hoisted for extensions tab's search bar
val extensionsViewModel = viewModel<ExtensionsViewModel>() val extensionsViewModel = viewModel<ExtensionsViewModel>()
val extensionsState by extensionsViewModel.state.collectAsState() val extensionsSearchQuery by extensionsViewModel.searchQuery.collectAsStateWithLifecycle()
val tabs = listOf( val tabs = listOf(
sourcesTab(), sourcesTab(),
@@ -73,7 +73,7 @@ data object BrowseTab : Tab {
titleRes = MR.strings.browse, titleRes = MR.strings.browse,
tabs = tabs, tabs = tabs,
state = state, state = state,
searchQuery = extensionsState.searchQuery, searchQuery = extensionsSearchQuery,
onChangeSearchQuery = extensionsViewModel::search, onChangeSearchQuery = extensionsViewModel::search,
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -5,12 +5,12 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.ExtensionScreen import eu.kanade.presentation.browse.ExtensionScreen
@@ -31,12 +31,12 @@ fun extensionsTab(
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current val context = LocalContext.current
val state by extensionsViewModel.state.collectAsState() val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle()
var privateExtensionToUninstall by remember { mutableStateOf<Extension?>(null) } var privateExtensionToUninstall by remember { mutableStateOf<Extension?>(null) }
return TabContent( return TabContent(
titleRes = MR.strings.label_extensions, titleRes = MR.strings.label_extensions,
badgeNumber = state.updates.takeIf { it > 0 }, badgeNumber = updatesCount.takeIf { it > 0 },
searchEnabled = true, searchEnabled = true,
actions = listOf( actions = listOf(
AppBar.OverflowAction( AppBar.OverflowAction(
@@ -49,6 +49,8 @@ fun extensionsTab(
), ),
), ),
content = { contentPadding, _ -> content = { contentPadding, _ ->
val state by extensionsViewModel.state.collectAsStateWithLifecycle()
BackHandler(enabled = state.searchQuery != null) { BackHandler(enabled = state.searchQuery != null) {
extensionsViewModel.search(null) extensionsViewModel.search(null)
} }
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.browse.extension
import android.app.Application import android.app.Application
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.base.BasePreferences 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.extension.model.InstallStep
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow 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.collect
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.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mihon.core.viewmodel.StateViewModel
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@@ -35,75 +38,86 @@ import uy.kohesive.injekt.api.get
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class ExtensionsViewModel( class ExtensionsViewModel(
preferences: SourcePreferences = Injekt.get(), private val preferences: SourcePreferences = Injekt.get(),
basePreferences: BasePreferences = Injekt.get(), basePreferences: BasePreferences = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensions: GetExtensionsByType = Injekt.get(), private val getExtensions: GetExtensionsByType = Injekt.get(),
) : StateViewModel<ExtensionsViewModel.State>(State()) { ) : ViewModel() {
private val currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf()) private val currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
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)
}
@Suppress("LocalVariableName")
private val items = combine(
searchQuery
.debounce(0.25.seconds)
.map { searchQueryPredicate(it ?: "") },
currentDownloads,
getExtensions.subscribe(),
) { predicate, downloads, (_updates, _installed, _available, _untrusted) ->
buildMap {
val updates = _updates.filter(predicate).map(extensionMapper(downloads))
if (updates.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates)
}
val installed = _installed.filter(predicate).map(extensionMapper(downloads))
val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads))
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted)
}
val languagesWithExtensions = _available
.filter(predicate)
.groupBy { it.lang }
.toSortedMap(LocaleHelper.comparator)
.map { (lang, exts) ->
ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to
exts.map(extensionMapper(downloads))
}
if (languagesWithExtensions.isNotEmpty()) {
putAll(languagesWithExtensions)
}
}
}
.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 { init {
val context = Injekt.get<Application>()
val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel.Item) = { map ->
{
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
}
}
viewModelScope.launchIO {
combine(
state.map { it.searchQuery }
.distinctUntilChanged()
.debounce(0.25.seconds)
.map { searchQueryPredicate(it ?: "") },
currentDownloads,
getExtensions.subscribe(),
) { predicate, downloads, (_updates, _installed, _available, _untrusted) ->
buildMap {
val updates = _updates.filter(predicate).map(extensionMapper(downloads))
if (updates.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates)
}
val installed = _installed.filter(predicate).map(extensionMapper(downloads))
val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads))
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted)
}
val languagesWithExtensions = _available
.filter(predicate)
.groupBy { it.lang }
.toSortedMap(LocaleHelper.comparator)
.map { (lang, exts) ->
ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to
exts.map(extensionMapper(downloads))
}
if (languagesWithExtensions.isNotEmpty()) {
putAll(languagesWithExtensions)
}
}
}
.collectLatest { items ->
mutableState.update { state ->
state.copy(
isLoading = false,
items = items,
)
}
}
}
viewModelScope.launchIO { findAvailableExtensions() } 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 { fun searchQueryPredicate(query: String): (Extension) -> Boolean {
@@ -137,9 +151,7 @@ class ExtensionsViewModel(
} }
fun search(query: String?) { fun search(query: String?) {
mutableState.update { searchQuery.update { query }
it.copy(searchQuery = query)
}
} }
fun updateAllExtensions() { fun updateAllExtensions() {
@@ -190,14 +202,14 @@ class ExtensionsViewModel(
fun findAvailableExtensions() { fun findAvailableExtensions() {
viewModelScope.launchIO { viewModelScope.launchIO {
mutableState.update { it.copy(isRefreshing = true) } isRefreshing.update { true }
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
// Fake slower refresh so it doesn't seem like it's not doing anything // Fake slower refresh so it doesn't seem like it's not doing anything
delay(1.seconds) delay(1.seconds)
mutableState.update { it.copy(isRefreshing = false) } isRefreshing.update { false }
} }
} }