From bae9233b27492f8cad1975308a6cbc20d1d6f290 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 23 May 2026 01:21:32 +0530 Subject: [PATCH] Update viewmodel to reflect gplayapi changes --- .../compose/composition/FlowExtensions.kt | 57 ------------ .../store/compose/ui/apps/ForYouPage.kt | 4 +- .../store/compose/ui/apps/TopChartsPage.kt | 8 +- .../ui/commons/CategoryBrowseScreen.kt | 4 +- .../compose/ui/details/AppDetailsScreen.kt | 4 +- .../aurora/store/data/work/DownloadWorker.kt | 18 +--- .../store/viewmodel/auth/AuthViewModel.kt | 10 +- .../browse/ExpandedStreamBrowseViewModel.kt | 10 +- .../viewmodel/browse/StreamBrowseViewModel.kt | 5 +- .../viewmodel/details/AppDetailsViewModel.kt | 93 +++++++++++-------- .../viewmodel/details/DevProfileViewModel.kt | 5 +- .../viewmodel/homestream/StreamViewModel.kt | 18 +++- .../store/viewmodel/search/SearchViewModel.kt | 6 +- .../subcategory/CategoryStreamViewModel.kt | 31 +++---- .../viewmodel/topchart/TopChartViewModel.kt | 30 +++--- gradle/libs.versions.toml | 12 +-- 16 files changed, 131 insertions(+), 184 deletions(-) delete mode 100644 app/src/main/java/com/aurora/store/compose/composition/FlowExtensions.kt diff --git a/app/src/main/java/com/aurora/store/compose/composition/FlowExtensions.kt b/app/src/main/java/com/aurora/store/compose/composition/FlowExtensions.kt deleted file mode 100644 index c69aff06a..000000000 --- a/app/src/main/java/com/aurora/store/compose/composition/FlowExtensions.kt +++ /dev/null @@ -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 Flow.collectForced(initial: T): State { - val lifecycleOwner = LocalLifecycleOwner.current - val state: MutableState = 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 LiveData.observeForced(): State { - val lifecycleOwner = LocalLifecycleOwner.current - val state: MutableState = remember { mutableStateOf(value, neverEqualPolicy()) } - DisposableEffect(this, lifecycleOwner) { - val observer = Observer { state.value = it } - observe(lifecycleOwner, observer) - onDispose { removeObserver(observer) } - } - return state -} diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt index 24daf6dc1..2b9dbb695 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt @@ -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) diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/TopChartsPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/TopChartsPage.kt index 95d23d4a1..847f254ea 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/apps/TopChartsPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/TopChartsPage.kt @@ -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() 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, diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/CategoryBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/CategoryBrowseScreen.kt index 9aba01514..98c164f0e 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/CategoryBrowseScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/CategoryBrowseScreen.kt @@ -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) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index a358ed412..aa1d148cf 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -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, diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index 1a22899d9..cc8319ccf 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -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 diff --git a/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt index b2e36496e..e926b02f6 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt @@ -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( @@ -194,8 +194,8 @@ class AuthViewModel @Inject constructor( context, Preferences.PREFERENCE_AUTH_VIA_MICROG, accountType == AccountType.GOOGLE && - tokenType == AuthHelper.Token.AUTH && - PackageUtil.hasSupportedMicroGVariant(context) + tokenType == AuthHelper.Token.AUTH && + PackageUtil.hasSupportedMicroGVariant(context) ) _authState.value = AuthState.SignedIn } else { diff --git a/app/src/main/java/com/aurora/store/viewmodel/browse/ExpandedStreamBrowseViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/browse/ExpandedStreamBrowseViewModel.kt index 42718a7bc..6ab06a002 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/browse/ExpandedStreamBrowseViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/browse/ExpandedStreamBrowseViewModel.kt @@ -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 { diff --git a/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt index 347c1dbb6..8042fc026 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt @@ -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 { diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index 75e9d29bb..a6d6de453 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -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.Loading) val state = _state.asStateFlow() - private val _suggestionsBundle = MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val suggestionsBundle: SharedFlow = _suggestionsBundle.asSharedFlow() + private val _suggestionsBundle = MutableStateFlow(null) + val suggestionsBundle: StateFlow = _suggestionsBundle.asStateFlow() - private var suggestionsState: StreamBundle = StreamBundle() + private var suggestionsState: StreamBundle = StreamBundle.EMPTY private val _featuredReviews = MutableStateFlow>(emptyList()) val featuredReviews = _featuredReviews.asStateFlow() @@ -167,31 +162,48 @@ class AppDetailsViewModel @Inject constructor( fun fetchAppDetails(packageName: String) { viewModelScope.launch(Dispatchers.IO) { - try { - authProvider.awaitReady() - _app.value = appDetailsHelper.getAppByPackageName(packageName).copy( - isInstalled = PackageUtil.isInstalled(context, packageName) - ) - val existingDownload = downloadHelper.getDownload(packageName) + var retried = false + while (true) { + try { + authProvider.awaitReady() + _app.value = appDetailsHelper.getAppByPackageName(packageName).copy( + isInstalled = PackageUtil.isInstalled(context, packageName) + ) + val existingDownload = downloadHelper.getDownload(packageName) - // A COMPLETED record for an app that is no longer installed means the app was - // installed then removed while Aurora held a stale record. - // Remove it so the live download observer doesn't lock the UI in Installing state - // indefinitely. - if (existingDownload?.status == DownloadStatus.COMPLETED && !isInstalled) { - downloadHelper.removeDownload(packageName) - _state.value = defaultAppState - } else { - // Seed state from any in-flight download for this package so reopening - // the screen doesn't briefly flash the default install action while the - // download flow catches up. - _state.value = - existingDownload?.let { stateFromDownload(it) } ?: defaultAppState + // A COMPLETED record for an app that is no longer installed means the app was + // installed then removed while Aurora held a stale record. + // Remove it so the live download observer doesn't lock the UI in Installing state + // indefinitely. + if (existingDownload?.status == DownloadStatus.COMPLETED && !isInstalled) { + downloadHelper.removeDownload(packageName) + _state.value = defaultAppState + } else { + // Seed state from any in-flight download for this package so reopening + // the screen doesn't briefly flash the default install action while the + // download flow catches up. + _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 } - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch app details", exception) - _app.value = null - _state.value = AppState.Error(exception.message) } }.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) } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/DevProfileViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/DevProfileViewModel.kt index 47e69114a..377ffb8d0 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/DevProfileViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/DevProfileViewModel.kt @@ -46,8 +46,8 @@ class DevProfileViewModel @Inject constructor( ) : ViewModel() { val liveData: MutableLiveData = 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) diff --git a/app/src/main/java/com/aurora/store/viewmodel/homestream/StreamViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/homestream/StreamViewModel.kt index 18468a4d0..b19442b99 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/homestream/StreamViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/homestream/StreamViewModel.kt @@ -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()) } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/search/SearchViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/search/SearchViewModel.kt index 505036a3d..3f60e3ef3 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/search/SearchViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/search/SearchViewModel.kt @@ -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>(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) } } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/subcategory/CategoryStreamViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/subcategory/CategoryStreamViewModel.kt index 05cba8221..8c0abfb58 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/subcategory/CategoryStreamViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/subcategory/CategoryStreamViewModel.kt @@ -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( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val viewState: SharedFlow = _viewState.asSharedFlow() + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState: StateFlow = _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") } diff --git a/app/src/main/java/com/aurora/store/viewmodel/topchart/TopChartViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/topchart/TopChartViewModel.kt index 256b9fc75..8864fc349 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/topchart/TopChartViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/topchart/TopChartViewModel.kt @@ -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( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val state: SharedFlow = _state.asSharedFlow() + private val _state = MutableStateFlow(ViewState.Loading) + val state: StateFlow = _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 } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f70913682..010365dd1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"