Update viewmodel to reflect gplayapi changes

This commit is contained in:
Rahul Patel
2026-05-23 01:21:32 +05:30
parent eada663bfb
commit bae9233b27
16 changed files with 131 additions and 184 deletions

View File

@@ -1,57 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.remember
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
/**
* Collects a [Flow] as [State] starting from [initial], always triggering recomposition on
* every emission regardless of structural equality, by using [neverEqualPolicy].
*
* Useful for non-state flows (e.g. [kotlinx.coroutines.flow.SharedFlow]) and for flows whose
* values have broken `equals` implementations that would otherwise be conflated upstream.
*/
@Composable
internal fun <T> Flow<T>.collectForced(initial: T): State<T> {
val lifecycleOwner = LocalLifecycleOwner.current
val state: MutableState<T> = remember(this) { mutableStateOf(initial, neverEqualPolicy()) }
LaunchedEffect(this, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect { state.value = it }
}
}
return state
}
/**
* Observes a [LiveData] as [State], always triggering recomposition on every emission
* regardless of structural equality, by using [neverEqualPolicy].
*/
@Suppress("UNCHECKED_CAST")
@Composable
internal fun <T> LiveData<T>.observeForced(): State<T?> {
val lifecycleOwner = LocalLifecycleOwner.current
val state: MutableState<T?> = remember { mutableStateOf(value, neverEqualPolicy()) }
DisposableEffect(this, lifecycleOwner) {
val observer = Observer<T> { state.value = it }
observe(lifecycleOwner, observer)
onDispose { removeObserver(observer) }
}
return state
}

View File

@@ -9,13 +9,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.contracts.StreamContract
import com.aurora.store.HomeStash
import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composition.observeForced
import com.aurora.store.data.model.ViewState
import com.aurora.store.viewmodel.homestream.StreamViewModel
@@ -29,7 +29,7 @@ internal fun ForYouContent(
onScrolledToEnd: () -> Unit
) {
val category = category(pageType)
val state by viewModel.liveData.observeForced()
val state by viewModel.liveData.observeAsState()
LaunchedEffect(category) {
viewModel.getStreamBundle(category, StreamContract.Type.HOME)

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.contracts.TopChartsContract
@@ -41,7 +42,6 @@ import com.aurora.store.R
import com.aurora.store.compose.composable.Placeholder
import com.aurora.store.compose.composable.ShimmerAppRow
import com.aurora.store.compose.composable.app.LargeAppListItem
import com.aurora.store.compose.composition.collectForced
import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.ViewState
@@ -72,7 +72,7 @@ internal fun TopChartsContent(
if (pageType == 1) TopChartsContract.Type.GAME else TopChartsContract.Type.APPLICATION
var selectedIndex by rememberSaveable { mutableIntStateOf(0) }
val selectedChart = charts[selectedIndex]
val state by viewModel.state.collectForced(ViewState.Loading)
val state by viewModel.state.collectAsStateWithLifecycle()
val cluster = state.getDataAs<StreamCluster?>()
val listState = rememberLazyListState()
@@ -223,7 +223,7 @@ private fun TopChartsBodyLoadingPreview() {
private fun TopChartsBodyLoadedPreview() {
val app = AppPreviewProvider().values.first()
val apps = List(5) { i -> app.copy(id = i + 1, packageName = "com.preview.app$i") }
val cluster = StreamCluster(clusterAppList = apps)
val cluster = StreamCluster(id = 1, clusterAppList = apps)
TopChartsBody(
selectedIndex = 0,
chartTitles = previewChartTitles,
@@ -236,7 +236,7 @@ private fun TopChartsBodyLoadedPreview() {
@Preview(showBackground = true)
@Composable
private fun TopChartsBodyEmptyPreview() {
val cluster = StreamCluster(clusterAppList = emptyList())
val cluster = StreamCluster(id = 1, clusterAppList = emptyList())
TopChartsBody(
selectedIndex = 1,
chartTitles = previewChartTitles,

View File

@@ -13,12 +13,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.store.R
import com.aurora.store.compose.composable.Placeholder
import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.composition.collectForced
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.model.ViewState
import com.aurora.store.viewmodel.subcategory.CategoryStreamViewModel
@@ -34,7 +34,7 @@ fun CategoryBrowseScreen(
}
)
) {
val uiState by viewModel.viewState.collectForced(ViewState.Loading)
val uiState by viewModel.viewState.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopAppBar(title = title) }

View File

@@ -72,7 +72,6 @@ import com.aurora.store.compose.composable.SectionHeader
import com.aurora.store.compose.composable.ShimmerCarouselSection
import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.composition.collectForced
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.preview.AppPreviewProvider
@@ -126,7 +125,7 @@ fun AppDetailsScreen(
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
val installError by viewModel.installError.collectAsStateWithLifecycle()
val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null)
val suggestionsBundle by viewModel.suggestionsBundle.collectAsStateWithLifecycle()
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
@@ -694,6 +693,7 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class)
app = app,
isAnonymous = false,
suggestionsBundle = StreamBundle(
id = 1,
streamClusters = mapOf(
1 to StreamCluster(
id = 1,

View File

@@ -237,7 +237,7 @@ class DownloadWorker @AssistedInject constructor(
}
else -> {
val userMessage = exception.userReason()
val userMessage = exception.message?.takeIf { it.isNotBlank() }
?: context.getString(R.string.download_failed)
notifyStatus(
status = DownloadStatus.FAILED,
@@ -261,22 +261,6 @@ class DownloadWorker @AssistedInject constructor(
}
}
/**
* Extracts a user-readable message from an exception. Falls back to reading the
* `reason` property via reflection for GPlayApi's `InternalException` variants,
* which expose their detail through a data-class field rather than [Throwable.message].
*
* TODO: Remove reflection and use a proper exception hierarchy once GPlayApi supports it.
*/
private fun Throwable.userReason(): String? {
message?.takeIf { it.isNotBlank() }?.let { return it }
return runCatching {
javaClass.getDeclaredField("reason")
.apply { isAccessible = true }
.get(this) as? String
}.getOrNull()?.takeIf { it.isNotBlank() }
}
/**
* Purchases the app to get the download URL of the required files
* @param packageName The packageName of the app

View File

@@ -39,13 +39,13 @@ import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.net.ConnectException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.net.ConnectException
import java.net.UnknownHostException
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(

View File

@@ -61,7 +61,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl)
if (browseResponse.hasBrowseTab()) {
listUrl = browseResponse.browseTab.listUrl
val cluster = streamHelper.getExpandedBrowseClusters(listUrl)
val cluster = streamHelper.getExpandedBrowseClusters(
id = listUrl.hashCode(),
listUrl
)
_title.value = cluster.clusterTitle
nextPageUrl = cluster.clusterNextPageUrl
cluster.clusterAppList
@@ -72,7 +75,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
else -> {
if (nextPageUrl.isNotBlank()) {
val cluster = streamHelper.getExpandedBrowseClusters(nextPageUrl)
val cluster = streamHelper.getExpandedBrowseClusters(
nextPageUrl.hashCode(),
nextPageUrl
)
nextPageUrl = cluster.clusterNextPageUrl
cluster.clusterAppList
} else {

View File

@@ -68,7 +68,10 @@ class StreamBrowseViewModel @AssistedInject constructor(
else -> {
if (nextPageUrl.isNotBlank()) {
streamHelper.nextStreamCluster(nextPageUrl).also {
streamHelper.nextStreamCluster(
nextPageUrl.hashCode(),
nextPageUrl
).also {
nextPageUrl = it.clusterNextPageUrl
}.clusterAppList
} else {

View File

@@ -20,6 +20,7 @@ import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
import com.aurora.gplayapi.data.models.details.TestingProgramStatus
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
@@ -46,12 +47,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
@@ -82,13 +80,10 @@ class AppDetailsViewModel @Inject constructor(
private val _state = MutableStateFlow<AppState>(AppState.Loading)
val state = _state.asStateFlow()
private val _suggestionsBundle = MutableSharedFlow<StreamBundle?>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val suggestionsBundle: SharedFlow<StreamBundle?> = _suggestionsBundle.asSharedFlow()
private val _suggestionsBundle = MutableStateFlow<StreamBundle?>(null)
val suggestionsBundle: StateFlow<StreamBundle?> = _suggestionsBundle.asStateFlow()
private var suggestionsState: StreamBundle = StreamBundle()
private var suggestionsState: StreamBundle = StreamBundle.EMPTY
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
val featuredReviews = _featuredReviews.asStateFlow()
@@ -167,6 +162,8 @@ class AppDetailsViewModel @Inject constructor(
fun fetchAppDetails(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
var retried = false
while (true) {
try {
authProvider.awaitReady()
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
@@ -188,10 +185,25 @@ class AppDetailsViewModel @Inject constructor(
_state.value =
existingDownload?.let { stateFromDownload(it) } ?: defaultAppState
}
break
} catch (exception: Exception) {
// gplayapi throws AppNotFound(code=401) when the saved token is
// rejected mid-session; refresh anonymous auth once and retry.
if (!retried &&
authProvider.isAnonymous &&
exception is GooglePlayException.NotFound &&
exception.code == 401
) {
Log.w(TAG, "App details fetch returned 401, refreshing auth", exception)
retried = true
authProvider.refreshAnonymousAuth()
continue
}
Log.e(TAG, "Failed to fetch app details", exception)
_app.value = null
_state.value = AppState.Error(exception.message)
break
}
}
}.invokeOnCompletion { throwable ->
// Only proceed if there was no error while fetching the app details
@@ -346,21 +358,21 @@ class AppDetailsViewModel @Inject constructor(
// Bail out if we got no suggestions to offer
if (streamUrl.isNullOrBlank()) {
_suggestionsBundle.tryEmit(StreamBundle.EMPTY)
_suggestionsBundle.value = StreamBundle.EMPTY
return
}
viewModelScope.launch(Dispatchers.IO) {
try {
val pageBundle = appDetailsHelper.getDetailsStream(streamUrl)
val pageBundle = appDetailsHelper.getDetailsStream(streamUrl.hashCode(), streamUrl)
val pageClusters = pageBundle.streamClusters.filterValues {
it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty()
}
suggestionsState = pageBundle.copy(streamClusters = pageClusters)
_suggestionsBundle.tryEmit(suggestionsState)
_suggestionsBundle.value = suggestionsState
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch suggestions stream", exception)
_suggestionsBundle.tryEmit(StreamBundle.EMPTY)
_suggestionsBundle.value = StreamBundle.EMPTY
}
}
}
@@ -370,7 +382,10 @@ class AppDetailsViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) {
try {
val nextPage = appDetailsHelper.getNextStreamCluster(cluster.clusterNextPageUrl)
val nextPage = appDetailsHelper.getNextStreamCluster(
cluster.id,
cluster.clusterNextPageUrl
)
val existing = suggestionsState.streamClusters[cluster.id] ?: return@launch
val mergedCluster = existing.copy(
clusterAppList = existing.clusterAppList + nextPage.clusterAppList,
@@ -379,7 +394,7 @@ class AppDetailsViewModel @Inject constructor(
suggestionsState = suggestionsState.copy(
streamClusters = suggestionsState.streamClusters + (cluster.id to mergedCluster)
)
_suggestionsBundle.tryEmit(suggestionsState)
_suggestionsBundle.value = suggestionsState
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch next cluster page", exception)
}

View File

@@ -46,8 +46,8 @@ class DevProfileViewModel @Inject constructor(
) : ViewModel() {
val liveData: MutableLiveData<ViewState> = MutableLiveData()
var devStream: DevStream = DevStream()
var streamBundle: StreamBundle = StreamBundle()
var devStream: DevStream = DevStream.EMPTY
var streamBundle: StreamBundle = StreamBundle.EMPTY
lateinit var type: StreamContract.Type
lateinit var category: StreamContract.Category
@@ -74,6 +74,7 @@ class DevProfileViewModel @Inject constructor(
if (streamCluster.hasNext()) {
authProvider.awaitReady()
val newCluster = streamHelper.getNextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl
)
updateCluster(newCluster)

View File

@@ -72,6 +72,7 @@ class StreamViewModel @Inject constructor(
// Fetch new stream bundle
val newBundle = if (bundle.hasCluster()) {
streamContract.nextStreamBundle(
bundle.id,
category,
bundle.streamNextPageUrl
)
@@ -79,9 +80,19 @@ class StreamViewModel @Inject constructor(
streamContract.fetch(type, category)
}
// Update old bundle
// gplayapi 3.6.1 stamps every cluster in a bundle with the bundle id,
// so naive Map.plus drops the existing page's clusters. Re-key the new
// clusters with synthetic ids past the current max so pagination appends.
val baseKey = (bundle.streamClusters.keys.maxOrNull() ?: 0) + 1
val rekeyedNewClusters = newBundle.streamClusters.values
.mapIndexed { index, cluster ->
val newId = baseKey + index
newId to cluster.copy(id = newId)
}
.toMap()
val mergedBundle = bundle.copy(
streamClusters = bundle.streamClusters + newBundle.streamClusters,
streamClusters = bundle.streamClusters + rekeyedNewClusters,
streamNextPageUrl = newBundle.streamNextPageUrl
)
stash[category] = mergedBundle
@@ -103,6 +114,7 @@ class StreamViewModel @Inject constructor(
try {
if (streamCluster.hasNext()) {
val newCluster = streamContract.nextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl
)
@@ -158,6 +170,6 @@ class StreamViewModel @Inject constructor(
private fun targetBundle(category: StreamContract.Category): StreamBundle =
stash.getOrPut(category) {
StreamBundle()
StreamBundle(id = category.value.hashCode())
}
}

View File

@@ -18,7 +18,6 @@ import com.aurora.extensions.requiresGMS
import com.aurora.gplayapi.SearchSuggestEntry
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.SearchHelper
import com.aurora.gplayapi.helpers.contracts.SearchContract
import com.aurora.gplayapi.helpers.web.WebSearchHelper
import com.aurora.store.data.PageResult
@@ -41,12 +40,11 @@ import kotlinx.coroutines.launch
@HiltViewModel
class SearchViewModel @Inject constructor(
val authProvider: AuthProvider,
private val searchHelper: SearchHelper,
private val webSearchHelper: WebSearchHelper
) : ViewModel() {
private val contract: SearchContract
get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper
get() = webSearchHelper
private val _suggestions = MutableStateFlow<List<SearchSuggestEntry>>(emptyList())
val suggestions = _suggestions.asStateFlow()
@@ -125,7 +123,7 @@ class SearchViewModel @Inject constructor(
authProvider.awaitReady()
_suggestions.value = contract.searchSuggestions(query)
.filter { it.title.isNotBlank() }
.take(3)
.take(5)
}
}
}

View File

@@ -21,10 +21,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@@ -39,19 +38,13 @@ class CategoryStreamViewModel @AssistedInject constructor(
fun create(browseUrl: String): CategoryStreamViewModel
}
// SharedFlow (instead of StateFlow) because StreamBundle/StreamCluster override equals
// to compare only id, which is preserved by copy(). StateFlow would conflate every update
// and break paginated scroll loading.
private val _viewState = MutableSharedFlow<ViewState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val viewState: SharedFlow<ViewState> = _viewState.asSharedFlow()
private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
val viewState: StateFlow<ViewState> = _viewState.asStateFlow()
private val categoryStreamContract: CategoryStreamContract
get() = webCategoryStreamHelper
private var streamBundle = StreamBundle()
private var streamBundle = StreamBundle.EMPTY
init {
fetchNextPage()
@@ -60,16 +53,13 @@ class CategoryStreamViewModel @AssistedInject constructor(
fun fetchNextPage() {
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
if (streamBundle.streamClusters.isNotEmpty()) {
_viewState.tryEmit(ViewState.Success(streamBundle))
}
try {
if (!streamBundle.hasCluster() || streamBundle.hasNext()) {
val newBundle = if (streamBundle.streamClusters.isEmpty()) {
categoryStreamContract.fetch(browseUrl)
} else {
categoryStreamContract.nextStreamBundle(
streamBundle.id,
StreamContract.Category.NONE,
streamBundle.streamNextPageUrl
)
@@ -80,12 +70,12 @@ class CategoryStreamViewModel @AssistedInject constructor(
streamNextPageUrl = newBundle.streamNextPageUrl
)
_viewState.tryEmit(ViewState.Success(streamBundle))
_viewState.value = ViewState.Success(streamBundle)
} else {
Log.i(TAG, "End of Bundle")
}
} catch (e: Exception) {
_viewState.tryEmit(ViewState.Error(e.message))
_viewState.value = ViewState.Error(e.message)
}
}
}
@@ -97,6 +87,7 @@ class CategoryStreamViewModel @AssistedInject constructor(
try {
if (streamCluster.hasNext()) {
val newCluster = categoryStreamContract.nextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl
)
val mergedCluster = streamCluster.copy(
@@ -109,7 +100,7 @@ class CategoryStreamViewModel @AssistedInject constructor(
this[streamCluster.id] = mergedCluster
}
streamBundle = streamBundle.copy(streamClusters = newClusters)
_viewState.tryEmit(ViewState.Success(streamBundle))
_viewState.value = ViewState.Success(streamBundle)
} else {
Log.i(TAG, "End of cluster")
}

View File

@@ -29,10 +29,9 @@ import com.aurora.store.data.model.ViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@@ -43,14 +42,8 @@ class TopChartViewModel @Inject constructor(
private var stash: TopChartStash = mutableMapOf()
// SharedFlow (instead of StateFlow) because StreamCluster overrides equals to compare
// only id, which is preserved by copy(). StateFlow would conflate paginated updates and
// break scroll loading. See CategoryStreamViewModel for the same fix.
private val _state = MutableSharedFlow<ViewState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val state: SharedFlow<ViewState> = _state.asSharedFlow()
private val _state = MutableStateFlow<ViewState>(ViewState.Loading)
val state: StateFlow<ViewState> = _state.asStateFlow()
private val topChartsContract: TopChartsContract
get() = webTopChartsHelper
@@ -58,18 +51,18 @@ class TopChartViewModel @Inject constructor(
fun getStreamCluster(type: TopChartsContract.Type, chart: TopChartsContract.Chart) {
viewModelScope.launch(Dispatchers.IO) {
if (targetCluster(type, chart).clusterAppList.isNotEmpty()) {
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
_state.value = ViewState.Success(targetCluster(type, chart))
return@launch
}
_state.tryEmit(ViewState.Loading)
_state.value = ViewState.Loading
try {
val cluster = topChartsContract.getCluster(type.value, chart.value)
updateCluster(type, chart, cluster)
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
_state.value = ViewState.Success(targetCluster(type, chart))
} catch (e: Exception) {
_state.tryEmit(ViewState.Error(e.message))
_state.value = ViewState.Error(e.message)
}
}
}
@@ -81,12 +74,13 @@ class TopChartViewModel @Inject constructor(
val target = targetCluster(type, chart)
if (target.hasNext()) {
val newCluster = topChartsContract.getNextStreamCluster(
target.id,
target.clusterNextPageUrl
)
updateCluster(type, chart, newCluster)
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
_state.value = ViewState.Success(targetCluster(type, chart))
}
} catch (_: Exception) {
}
@@ -114,7 +108,7 @@ class TopChartViewModel @Inject constructor(
): StreamCluster {
val cluster = stash
.getOrPut(type) { mutableMapOf() }
.getOrPut(chart) { StreamCluster() }
.getOrPut(chart) { StreamCluster.EMPTY }
return cluster
}
}

View File

@@ -6,18 +6,18 @@
[versions]
activity = "1.13.0"
adaptive = "1.3.0-beta01"
adaptive = "1.3.0-beta02"
agCoreservice = "13.3.1.300"
androidGradlePlugin = "9.2.1"
androidx-hilt = "1.3.0"
androidx-junit = "1.3.0"
browser = "1.10.0"
coil = "3.4.0"
composeBom = "2026.05.00"
composeMaterial = "1.5.0-alpha19"
composeBom = "2026.05.01"
composeMaterial = "1.5.0-alpha20"
core = "1.18.0"
espresso = "3.7.0"
gplayapi = "3.5.9"
gplayapi = "3.6.2"
hiddenapibypass = "6.1"
hilt = "2.59.2"
junit = "4.13.2"
@@ -29,12 +29,12 @@ libsu = "6.0.0"
lifecycle = "2.10.0"
material = "1.14.0"
navigation = "2.9.8"
navigation3 = "1.1.1"
navigation3 = "1.1.2"
okhttp = "5.3.2"
paging = "3.5.0"
preference = "1.2.1"
processPhoenix = "3.0.0"
protobufJavalite = "4.34.1"
protobufJavalite = "4.35.0"
rikkaHiddenAPI = "4.4.0"
rikkaTools = "4.4.0"
room = "2.8.4"