Make ExtensionDetailsViewModel collect sources only while subscribed (#3726)
Assisted-by: Claude:claude-opus-5
This commit is contained in:
@@ -61,12 +61,11 @@ import tachiyomi.presentation.core.components.ScrollbarLazyColumn
|
|||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ExtensionDetailsScreen(
|
fun ExtensionDetailsScreen(
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
state: ExtensionDetailsViewModel.State,
|
state: ExtensionDetailsViewModel.State.Success,
|
||||||
onClickSourcePreferences: (sourceId: Long) -> Unit,
|
onClickSourcePreferences: (sourceId: Long) -> Unit,
|
||||||
onClickEnableAll: () -> Unit,
|
onClickEnableAll: () -> Unit,
|
||||||
onClickDisableAll: () -> Unit,
|
onClickDisableAll: () -> Unit,
|
||||||
@@ -78,12 +77,12 @@ fun ExtensionDetailsScreen(
|
|||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val url = remember(state.extension) {
|
val url = remember(state.extension) {
|
||||||
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
|
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
|
||||||
regex.find(state.extension?.store?.indexUrl.orEmpty())
|
regex.find(state.extension.store?.indexUrl.orEmpty())
|
||||||
?.let {
|
?.let {
|
||||||
val (user, repo) = it.destructured
|
val (user, repo) = it.destructured
|
||||||
"https://github.com/$user/$repo"
|
"https://github.com/$user/$repo"
|
||||||
}
|
}
|
||||||
?: state.extension?.store?.indexUrl
|
?: state.extension.store?.indexUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -128,14 +127,6 @@ fun ExtensionDetailsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
if (state.extension == null) {
|
|
||||||
EmptyScreen(
|
|
||||||
MR.strings.empty_screen,
|
|
||||||
modifier = Modifier.padding(paddingValues),
|
|
||||||
)
|
|
||||||
return@Scaffold
|
|
||||||
}
|
|
||||||
|
|
||||||
ExtensionDetails(
|
ExtensionDetails(
|
||||||
contentPadding = paddingValues,
|
contentPadding = paddingValues,
|
||||||
extension = state.extension,
|
extension = state.extension,
|
||||||
|
|||||||
+22
-25
@@ -2,15 +2,16 @@ package eu.kanade.tachiyomi.ui.browse.extension.details
|
|||||||
|
|
||||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.CreationExtras
|
import androidx.lifecycle.viewmodel.CreationExtras
|
||||||
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.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
import eu.kanade.presentation.browse.ExtensionDetailsScreen
|
import eu.kanade.presentation.browse.ExtensionDetailsScreen
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import tachiyomi.i18n.MR
|
||||||
|
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
|
|
||||||
data class ExtensionDetailsScreen(
|
data class ExtensionDetailsScreen(
|
||||||
@@ -25,32 +26,28 @@ data class ExtensionDetailsScreen(
|
|||||||
set(ExtensionDetailsViewModel.PKG_NAME_KEY, pkgName)
|
set(ExtensionDetailsViewModel.PKG_NAME_KEY, pkgName)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (state.isLoading) {
|
|
||||||
LoadingScreen()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
|
||||||
ExtensionDetailsScreen(
|
when (val state = state) {
|
||||||
navigateUp = navigator::pop,
|
ExtensionDetailsViewModel.State.Loading -> LoadingScreen()
|
||||||
state = state,
|
ExtensionDetailsViewModel.State.Uninstalled -> {
|
||||||
onClickSourcePreferences = { navigator.push(SourcePreferencesScreen(it)) },
|
LaunchedEffect(Unit) { navigator.pop() }
|
||||||
onClickEnableAll = { viewModel.toggleSources(true) },
|
EmptyScreen(MR.strings.empty_screen)
|
||||||
onClickDisableAll = { viewModel.toggleSources(false) },
|
}
|
||||||
onClickClearCookies = viewModel::clearCookies,
|
is ExtensionDetailsViewModel.State.Success -> {
|
||||||
onClickUninstall = viewModel::uninstallExtension,
|
ExtensionDetailsScreen(
|
||||||
onClickSource = viewModel::toggleSource,
|
navigateUp = navigator::pop,
|
||||||
onClickIncognito = viewModel::toggleIncognito,
|
state = state,
|
||||||
)
|
onClickSourcePreferences = { navigator.push(SourcePreferencesScreen(it)) },
|
||||||
|
onClickEnableAll = { viewModel.toggleSources(true) },
|
||||||
LaunchedEffect(Unit) {
|
onClickDisableAll = { viewModel.toggleSources(false) },
|
||||||
viewModel.events.collectLatest { event ->
|
onClickClearCookies = viewModel::clearCookies,
|
||||||
if (event is ExtensionDetailsEvent.Uninstalled) {
|
onClickUninstall = viewModel::uninstallExtension,
|
||||||
navigator.pop()
|
onClickSource = viewModel::toggleSource,
|
||||||
}
|
onClickIncognito = viewModel::toggleIncognito,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-76
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.browse.extension.details
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.CreationExtras
|
import androidx.lifecycle.viewmodel.CreationExtras
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
@@ -17,32 +18,34 @@ import eu.kanade.tachiyomi.extension.model.Extension
|
|||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
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.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
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.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.viewmodel.StateViewModel
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
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.Duration.Companion.seconds
|
||||||
|
|
||||||
class ExtensionDetailsViewModel(
|
class ExtensionDetailsViewModel(
|
||||||
pkgName: String,
|
pkgName: String,
|
||||||
context: Context,
|
private val context: Context,
|
||||||
private val network: NetworkHelper = Injekt.get(),
|
private val network: NetworkHelper = Injekt.get(),
|
||||||
private val extensionManager: ExtensionManager = Injekt.get(),
|
private val extensionManager: ExtensionManager = Injekt.get(),
|
||||||
private val getExtensionSources: GetExtensionSources = Injekt.get(),
|
private val getExtensionSources: GetExtensionSources = Injekt.get(),
|
||||||
private val toggleSource: ToggleSource = Injekt.get(),
|
private val toggleSource: ToggleSource = Injekt.get(),
|
||||||
private val toggleIncognito: ToggleIncognito = Injekt.get(),
|
private val toggleIncognito: ToggleIncognito = Injekt.get(),
|
||||||
private val preferences: SourcePreferences = Injekt.get(),
|
private val preferences: SourcePreferences = Injekt.get(),
|
||||||
) : StateViewModel<ExtensionDetailsViewModel.State>(State()) {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val PKG_NAME_KEY = CreationExtras.Key<String>()
|
val PKG_NAME_KEY = CreationExtras.Key<String>()
|
||||||
@@ -57,62 +60,44 @@ class ExtensionDetailsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _events: Channel<ExtensionDetailsEvent> = Channel()
|
val state: StateFlow<State> = extensionManager.installedExtensionsFlow
|
||||||
val events: Flow<ExtensionDetailsEvent> = _events.receiveAsFlow()
|
.map { it.firstOrNull { extension -> extension.pkgName == pkgName } }
|
||||||
|
.distinctUntilChanged()
|
||||||
init {
|
.flatMapLatest { extension ->
|
||||||
viewModelScope.launch {
|
if (extension == null) return@flatMapLatest flowOf(State.Uninstalled)
|
||||||
launch {
|
combine(
|
||||||
extensionManager.installedExtensionsFlow
|
subscribeToSources(extension),
|
||||||
.map { it.firstOrNull { extension -> extension.pkgName == pkgName } }
|
preferences.incognitoExtensions.changes().map { pkgName in it }.distinctUntilChanged(),
|
||||||
.collectLatest { extension ->
|
) { sources, isIncognito ->
|
||||||
if (extension == null) {
|
State.Success(extension = extension, isIncognito = isIncognito, sources = sources)
|
||||||
_events.send(ExtensionDetailsEvent.Uninstalled)
|
|
||||||
return@collectLatest
|
|
||||||
}
|
|
||||||
mutableState.update { state ->
|
|
||||||
state.copy(extension = extension)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
launch {
|
|
||||||
state.collectLatest { state ->
|
|
||||||
if (state.extension == null) return@collectLatest
|
|
||||||
getExtensionSources.subscribe(state.extension)
|
|
||||||
.map {
|
|
||||||
it.sortedWith(
|
|
||||||
compareBy(
|
|
||||||
{ !it.enabled },
|
|
||||||
{ item ->
|
|
||||||
item.source.name.takeIf { item.labelAsName }
|
|
||||||
?: LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.catch { throwable ->
|
|
||||||
logcat(LogPriority.ERROR, throwable)
|
|
||||||
mutableState.update { it.copy(_sources = listOf()) }
|
|
||||||
}
|
|
||||||
.collectLatest { sources ->
|
|
||||||
mutableState.update { it.copy(_sources = sources) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
launch {
|
|
||||||
preferences.incognitoExtensions
|
|
||||||
.changes()
|
|
||||||
.map { pkgName in it }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collectLatest { isIncognito ->
|
|
||||||
mutableState.update { it.copy(isIncognito = isIncognito) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
|
||||||
|
|
||||||
|
private val successState: State.Success?
|
||||||
|
get() = state.value as? State.Success
|
||||||
|
|
||||||
|
private fun subscribeToSources(extension: Extension.Installed): Flow<List<ExtensionSourceItem>> {
|
||||||
|
return getExtensionSources.subscribe(extension)
|
||||||
|
.map {
|
||||||
|
it.sortedWith(
|
||||||
|
compareBy(
|
||||||
|
{ !it.enabled },
|
||||||
|
{ item ->
|
||||||
|
item.source.name.takeIf { item.labelAsName }
|
||||||
|
?: LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.catch { throwable ->
|
||||||
|
logcat(LogPriority.ERROR, throwable)
|
||||||
|
emit(listOf())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCookies() {
|
fun clearCookies() {
|
||||||
val extension = state.value.extension ?: return
|
val extension = successState?.extension ?: return
|
||||||
|
|
||||||
val urls = extension.sources
|
val urls = extension.sources
|
||||||
.filterIsInstance<HttpSource>()
|
.filterIsInstance<HttpSource>()
|
||||||
@@ -133,7 +118,7 @@ class ExtensionDetailsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun uninstallExtension() {
|
fun uninstallExtension() {
|
||||||
val extension = state.value.extension ?: return
|
val extension = successState?.extension ?: return
|
||||||
extensionManager.uninstallExtension(extension)
|
extensionManager.uninstallExtension(extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,32 +127,28 @@ class ExtensionDetailsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSources(enable: Boolean) {
|
fun toggleSources(enable: Boolean) {
|
||||||
state.value.extension?.sources
|
successState?.extension?.sources
|
||||||
?.map { it.id }
|
?.map { it.id }
|
||||||
?.let { toggleSource.await(it, enable) }
|
?.let { toggleSource.await(it, enable) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleIncognito(enable: Boolean) {
|
fun toggleIncognito(enable: Boolean) {
|
||||||
state.value.extension?.pkgName?.let { packageName ->
|
successState?.extension?.pkgName?.let { packageName ->
|
||||||
toggleIncognito.await(packageName, enable)
|
toggleIncognito.await(packageName, enable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
sealed interface State {
|
||||||
data class State(
|
|
||||||
val extension: Extension.Installed? = null,
|
|
||||||
val isIncognito: Boolean = false,
|
|
||||||
private val _sources: List<ExtensionSourceItem>? = null,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val sources: List<ExtensionSourceItem>
|
data object Loading : State
|
||||||
get() = _sources ?: listOf()
|
|
||||||
|
|
||||||
val isLoading: Boolean
|
data object Uninstalled : State
|
||||||
get() = extension == null || _sources == null
|
|
||||||
|
@Immutable
|
||||||
|
data class Success(
|
||||||
|
val extension: Extension.Installed,
|
||||||
|
val isIncognito: Boolean,
|
||||||
|
val sources: List<ExtensionSourceItem>,
|
||||||
|
) : State
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface ExtensionDetailsEvent {
|
|
||||||
data object Uninstalled : ExtensionDetailsEvent
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user