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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.contracts.StreamContract import com.aurora.gplayapi.helpers.contracts.StreamContract
import com.aurora.store.HomeStash import com.aurora.store.HomeStash
import com.aurora.store.compose.composable.StreamCarousel 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.data.model.ViewState
import com.aurora.store.viewmodel.homestream.StreamViewModel import com.aurora.store.viewmodel.homestream.StreamViewModel
@@ -29,7 +29,7 @@ internal fun ForYouContent(
onScrolledToEnd: () -> Unit onScrolledToEnd: () -> Unit
) { ) {
val category = category(pageType) val category = category(pageType)
val state by viewModel.liveData.observeForced() val state by viewModel.liveData.observeAsState()
LaunchedEffect(category) { LaunchedEffect(category) {
viewModel.getStreamBundle(category, StreamContract.Type.HOME) 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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper 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.App
import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.contracts.TopChartsContract 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.Placeholder
import com.aurora.store.compose.composable.ShimmerAppRow import com.aurora.store.compose.composable.ShimmerAppRow
import com.aurora.store.compose.composable.app.LargeAppListItem 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.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
@@ -72,7 +72,7 @@ internal fun TopChartsContent(
if (pageType == 1) TopChartsContract.Type.GAME else TopChartsContract.Type.APPLICATION if (pageType == 1) TopChartsContract.Type.GAME else TopChartsContract.Type.APPLICATION
var selectedIndex by rememberSaveable { mutableIntStateOf(0) } var selectedIndex by rememberSaveable { mutableIntStateOf(0) }
val selectedChart = charts[selectedIndex] val selectedChart = charts[selectedIndex]
val state by viewModel.state.collectForced(ViewState.Loading) val state by viewModel.state.collectAsStateWithLifecycle()
val cluster = state.getDataAs<StreamCluster?>() val cluster = state.getDataAs<StreamCluster?>()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -223,7 +223,7 @@ private fun TopChartsBodyLoadingPreview() {
private fun TopChartsBodyLoadedPreview() { private fun TopChartsBodyLoadedPreview() {
val app = AppPreviewProvider().values.first() val app = AppPreviewProvider().values.first()
val apps = List(5) { i -> app.copy(id = i + 1, packageName = "com.preview.app$i") } 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( TopChartsBody(
selectedIndex = 0, selectedIndex = 0,
chartTitles = previewChartTitles, chartTitles = previewChartTitles,
@@ -236,7 +236,7 @@ private fun TopChartsBodyLoadedPreview() {
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun TopChartsBodyEmptyPreview() { private fun TopChartsBodyEmptyPreview() {
val cluster = StreamCluster(clusterAppList = emptyList()) val cluster = StreamCluster(id = 1, clusterAppList = emptyList())
TopChartsBody( TopChartsBody(
selectedIndex = 1, selectedIndex = 1,
chartTitles = previewChartTitles, chartTitles = previewChartTitles,

View File

@@ -13,12 +13,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.Placeholder
import com.aurora.store.compose.composable.StreamCarousel import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composable.TopAppBar 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.Destination
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
import com.aurora.store.viewmodel.subcategory.CategoryStreamViewModel 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( Scaffold(
topBar = { TopAppBar(title = title) } 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.ShimmerCarouselSection
import com.aurora.store.compose.composable.StreamCarousel import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composable.TopAppBar 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.Destination
import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.AppPreviewProvider
@@ -126,7 +125,7 @@ fun AppDetailsScreen(
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle() val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
val installError by viewModel.installError.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) } LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
@@ -694,6 +693,7 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class)
app = app, app = app,
isAnonymous = false, isAnonymous = false,
suggestionsBundle = StreamBundle( suggestionsBundle = StreamBundle(
id = 1,
streamClusters = mapOf( streamClusters = mapOf(
1 to StreamCluster( 1 to StreamCluster(
id = 1, id = 1,

View File

@@ -237,7 +237,7 @@ class DownloadWorker @AssistedInject constructor(
} }
else -> { else -> {
val userMessage = exception.userReason() val userMessage = exception.message?.takeIf { it.isNotBlank() }
?: context.getString(R.string.download_failed) ?: context.getString(R.string.download_failed)
notifyStatus( notifyStatus(
status = DownloadStatus.FAILED, 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 * Purchases the app to get the download URL of the required files
* @param packageName The packageName of the app * @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 com.aurora.store.util.Preferences
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext 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.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.ConnectException
import java.net.UnknownHostException
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor( class AuthViewModel @Inject constructor(
@@ -194,8 +194,8 @@ class AuthViewModel @Inject constructor(
context, context,
Preferences.PREFERENCE_AUTH_VIA_MICROG, Preferences.PREFERENCE_AUTH_VIA_MICROG,
accountType == AccountType.GOOGLE && accountType == AccountType.GOOGLE &&
tokenType == AuthHelper.Token.AUTH && tokenType == AuthHelper.Token.AUTH &&
PackageUtil.hasSupportedMicroGVariant(context) PackageUtil.hasSupportedMicroGVariant(context)
) )
_authState.value = AuthState.SignedIn _authState.value = AuthState.SignedIn
} else { } else {

View File

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

View File

@@ -68,7 +68,10 @@ class StreamBrowseViewModel @AssistedInject constructor(
else -> { else -> {
if (nextPageUrl.isNotBlank()) { if (nextPageUrl.isNotBlank()) {
streamHelper.nextStreamCluster(nextPageUrl).also { streamHelper.nextStreamCluster(
nextPageUrl.hashCode(),
nextPageUrl
).also {
nextPageUrl = it.clusterNextPageUrl nextPageUrl = it.clusterNextPageUrl
}.clusterAppList }.clusterAppList
} else { } 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.StreamCluster
import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
import com.aurora.gplayapi.data.models.details.TestingProgramStatus 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.AppDetailsHelper
import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
@@ -46,12 +47,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@@ -82,13 +80,10 @@ class AppDetailsViewModel @Inject constructor(
private val _state = MutableStateFlow<AppState>(AppState.Loading) private val _state = MutableStateFlow<AppState>(AppState.Loading)
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _suggestionsBundle = MutableSharedFlow<StreamBundle?>( private val _suggestionsBundle = MutableStateFlow<StreamBundle?>(null)
replay = 1, val suggestionsBundle: StateFlow<StreamBundle?> = _suggestionsBundle.asStateFlow()
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val suggestionsBundle: SharedFlow<StreamBundle?> = _suggestionsBundle.asSharedFlow()
private var suggestionsState: StreamBundle = StreamBundle() private var suggestionsState: StreamBundle = StreamBundle.EMPTY
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList()) private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
val featuredReviews = _featuredReviews.asStateFlow() val featuredReviews = _featuredReviews.asStateFlow()
@@ -167,31 +162,48 @@ class AppDetailsViewModel @Inject constructor(
fun fetchAppDetails(packageName: String) { fun fetchAppDetails(packageName: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { var retried = false
authProvider.awaitReady() while (true) {
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy( try {
isInstalled = PackageUtil.isInstalled(context, packageName) authProvider.awaitReady()
) _app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
val existingDownload = downloadHelper.getDownload(packageName) 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 // A COMPLETED record for an app that is no longer installed means the app was
// installed then removed while Aurora held a stale record. // installed then removed while Aurora held a stale record.
// Remove it so the live download observer doesn't lock the UI in Installing state // Remove it so the live download observer doesn't lock the UI in Installing state
// indefinitely. // indefinitely.
if (existingDownload?.status == DownloadStatus.COMPLETED && !isInstalled) { if (existingDownload?.status == DownloadStatus.COMPLETED && !isInstalled) {
downloadHelper.removeDownload(packageName) downloadHelper.removeDownload(packageName)
_state.value = defaultAppState _state.value = defaultAppState
} else { } else {
// Seed state from any in-flight download for this package so reopening // Seed state from any in-flight download for this package so reopening
// the screen doesn't briefly flash the default install action while the // the screen doesn't briefly flash the default install action while the
// download flow catches up. // download flow catches up.
_state.value = _state.value =
existingDownload?.let { stateFromDownload(it) } ?: defaultAppState 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 -> }.invokeOnCompletion { throwable ->
// Only proceed if there was no error while fetching the app details // 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 // Bail out if we got no suggestions to offer
if (streamUrl.isNullOrBlank()) { if (streamUrl.isNullOrBlank()) {
_suggestionsBundle.tryEmit(StreamBundle.EMPTY) _suggestionsBundle.value = StreamBundle.EMPTY
return return
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val pageBundle = appDetailsHelper.getDetailsStream(streamUrl) val pageBundle = appDetailsHelper.getDetailsStream(streamUrl.hashCode(), streamUrl)
val pageClusters = pageBundle.streamClusters.filterValues { val pageClusters = pageBundle.streamClusters.filterValues {
it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty() it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty()
} }
suggestionsState = pageBundle.copy(streamClusters = pageClusters) suggestionsState = pageBundle.copy(streamClusters = pageClusters)
_suggestionsBundle.tryEmit(suggestionsState) _suggestionsBundle.value = suggestionsState
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch suggestions stream", 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) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val nextPage = appDetailsHelper.getNextStreamCluster(cluster.clusterNextPageUrl) val nextPage = appDetailsHelper.getNextStreamCluster(
cluster.id,
cluster.clusterNextPageUrl
)
val existing = suggestionsState.streamClusters[cluster.id] ?: return@launch val existing = suggestionsState.streamClusters[cluster.id] ?: return@launch
val mergedCluster = existing.copy( val mergedCluster = existing.copy(
clusterAppList = existing.clusterAppList + nextPage.clusterAppList, clusterAppList = existing.clusterAppList + nextPage.clusterAppList,
@@ -379,7 +394,7 @@ class AppDetailsViewModel @Inject constructor(
suggestionsState = suggestionsState.copy( suggestionsState = suggestionsState.copy(
streamClusters = suggestionsState.streamClusters + (cluster.id to mergedCluster) streamClusters = suggestionsState.streamClusters + (cluster.id to mergedCluster)
) )
_suggestionsBundle.tryEmit(suggestionsState) _suggestionsBundle.value = suggestionsState
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch next cluster page", exception) Log.e(TAG, "Failed to fetch next cluster page", exception)
} }

View File

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

View File

@@ -72,6 +72,7 @@ class StreamViewModel @Inject constructor(
// Fetch new stream bundle // Fetch new stream bundle
val newBundle = if (bundle.hasCluster()) { val newBundle = if (bundle.hasCluster()) {
streamContract.nextStreamBundle( streamContract.nextStreamBundle(
bundle.id,
category, category,
bundle.streamNextPageUrl bundle.streamNextPageUrl
) )
@@ -79,9 +80,19 @@ class StreamViewModel @Inject constructor(
streamContract.fetch(type, category) 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( val mergedBundle = bundle.copy(
streamClusters = bundle.streamClusters + newBundle.streamClusters, streamClusters = bundle.streamClusters + rekeyedNewClusters,
streamNextPageUrl = newBundle.streamNextPageUrl streamNextPageUrl = newBundle.streamNextPageUrl
) )
stash[category] = mergedBundle stash[category] = mergedBundle
@@ -103,6 +114,7 @@ class StreamViewModel @Inject constructor(
try { try {
if (streamCluster.hasNext()) { if (streamCluster.hasNext()) {
val newCluster = streamContract.nextStreamCluster( val newCluster = streamContract.nextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl streamCluster.clusterNextPageUrl
) )
@@ -158,6 +170,6 @@ class StreamViewModel @Inject constructor(
private fun targetBundle(category: StreamContract.Category): StreamBundle = private fun targetBundle(category: StreamContract.Category): StreamBundle =
stash.getOrPut(category) { 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.SearchSuggestEntry
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster 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.contracts.SearchContract
import com.aurora.gplayapi.helpers.web.WebSearchHelper import com.aurora.gplayapi.helpers.web.WebSearchHelper
import com.aurora.store.data.PageResult import com.aurora.store.data.PageResult
@@ -41,12 +40,11 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
val authProvider: AuthProvider, val authProvider: AuthProvider,
private val searchHelper: SearchHelper,
private val webSearchHelper: WebSearchHelper private val webSearchHelper: WebSearchHelper
) : ViewModel() { ) : ViewModel() {
private val contract: SearchContract private val contract: SearchContract
get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper get() = webSearchHelper
private val _suggestions = MutableStateFlow<List<SearchSuggestEntry>>(emptyList()) private val _suggestions = MutableStateFlow<List<SearchSuggestEntry>>(emptyList())
val suggestions = _suggestions.asStateFlow() val suggestions = _suggestions.asStateFlow()
@@ -125,7 +123,7 @@ class SearchViewModel @Inject constructor(
authProvider.awaitReady() authProvider.awaitReady()
_suggestions.value = contract.searchSuggestions(query) _suggestions.value = contract.searchSuggestions(query)
.filter { it.title.isNotBlank() } .filter { it.title.isNotBlank() }
.take(3) .take(5)
} }
} }
} }

View File

@@ -21,10 +21,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
@@ -39,19 +38,13 @@ class CategoryStreamViewModel @AssistedInject constructor(
fun create(browseUrl: String): CategoryStreamViewModel fun create(browseUrl: String): CategoryStreamViewModel
} }
// SharedFlow (instead of StateFlow) because StreamBundle/StreamCluster override equals private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
// to compare only id, which is preserved by copy(). StateFlow would conflate every update val viewState: StateFlow<ViewState> = _viewState.asStateFlow()
// and break paginated scroll loading.
private val _viewState = MutableSharedFlow<ViewState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val viewState: SharedFlow<ViewState> = _viewState.asSharedFlow()
private val categoryStreamContract: CategoryStreamContract private val categoryStreamContract: CategoryStreamContract
get() = webCategoryStreamHelper get() = webCategoryStreamHelper
private var streamBundle = StreamBundle() private var streamBundle = StreamBundle.EMPTY
init { init {
fetchNextPage() fetchNextPage()
@@ -60,16 +53,13 @@ class CategoryStreamViewModel @AssistedInject constructor(
fun fetchNextPage() { fun fetchNextPage() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
supervisorScope { supervisorScope {
if (streamBundle.streamClusters.isNotEmpty()) {
_viewState.tryEmit(ViewState.Success(streamBundle))
}
try { try {
if (!streamBundle.hasCluster() || streamBundle.hasNext()) { if (!streamBundle.hasCluster() || streamBundle.hasNext()) {
val newBundle = if (streamBundle.streamClusters.isEmpty()) { val newBundle = if (streamBundle.streamClusters.isEmpty()) {
categoryStreamContract.fetch(browseUrl) categoryStreamContract.fetch(browseUrl)
} else { } else {
categoryStreamContract.nextStreamBundle( categoryStreamContract.nextStreamBundle(
streamBundle.id,
StreamContract.Category.NONE, StreamContract.Category.NONE,
streamBundle.streamNextPageUrl streamBundle.streamNextPageUrl
) )
@@ -80,12 +70,12 @@ class CategoryStreamViewModel @AssistedInject constructor(
streamNextPageUrl = newBundle.streamNextPageUrl streamNextPageUrl = newBundle.streamNextPageUrl
) )
_viewState.tryEmit(ViewState.Success(streamBundle)) _viewState.value = ViewState.Success(streamBundle)
} else { } else {
Log.i(TAG, "End of Bundle") Log.i(TAG, "End of Bundle")
} }
} catch (e: Exception) { } catch (e: Exception) {
_viewState.tryEmit(ViewState.Error(e.message)) _viewState.value = ViewState.Error(e.message)
} }
} }
} }
@@ -97,6 +87,7 @@ class CategoryStreamViewModel @AssistedInject constructor(
try { try {
if (streamCluster.hasNext()) { if (streamCluster.hasNext()) {
val newCluster = categoryStreamContract.nextStreamCluster( val newCluster = categoryStreamContract.nextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl streamCluster.clusterNextPageUrl
) )
val mergedCluster = streamCluster.copy( val mergedCluster = streamCluster.copy(
@@ -109,7 +100,7 @@ class CategoryStreamViewModel @AssistedInject constructor(
this[streamCluster.id] = mergedCluster this[streamCluster.id] = mergedCluster
} }
streamBundle = streamBundle.copy(streamClusters = newClusters) streamBundle = streamBundle.copy(streamClusters = newClusters)
_viewState.tryEmit(ViewState.Success(streamBundle)) _viewState.value = ViewState.Success(streamBundle)
} else { } else {
Log.i(TAG, "End of cluster") 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
@@ -43,14 +42,8 @@ class TopChartViewModel @Inject constructor(
private var stash: TopChartStash = mutableMapOf() private var stash: TopChartStash = mutableMapOf()
// SharedFlow (instead of StateFlow) because StreamCluster overrides equals to compare private val _state = MutableStateFlow<ViewState>(ViewState.Loading)
// only id, which is preserved by copy(). StateFlow would conflate paginated updates and val state: StateFlow<ViewState> = _state.asStateFlow()
// 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 topChartsContract: TopChartsContract private val topChartsContract: TopChartsContract
get() = webTopChartsHelper get() = webTopChartsHelper
@@ -58,18 +51,18 @@ class TopChartViewModel @Inject constructor(
fun getStreamCluster(type: TopChartsContract.Type, chart: TopChartsContract.Chart) { fun getStreamCluster(type: TopChartsContract.Type, chart: TopChartsContract.Chart) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
if (targetCluster(type, chart).clusterAppList.isNotEmpty()) { if (targetCluster(type, chart).clusterAppList.isNotEmpty()) {
_state.tryEmit(ViewState.Success(targetCluster(type, chart))) _state.value = ViewState.Success(targetCluster(type, chart))
return@launch return@launch
} }
_state.tryEmit(ViewState.Loading) _state.value = ViewState.Loading
try { try {
val cluster = topChartsContract.getCluster(type.value, chart.value) val cluster = topChartsContract.getCluster(type.value, chart.value)
updateCluster(type, chart, cluster) updateCluster(type, chart, cluster)
_state.tryEmit(ViewState.Success(targetCluster(type, chart))) _state.value = ViewState.Success(targetCluster(type, chart))
} catch (e: Exception) { } 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) val target = targetCluster(type, chart)
if (target.hasNext()) { if (target.hasNext()) {
val newCluster = topChartsContract.getNextStreamCluster( val newCluster = topChartsContract.getNextStreamCluster(
target.id,
target.clusterNextPageUrl target.clusterNextPageUrl
) )
updateCluster(type, chart, newCluster) updateCluster(type, chart, newCluster)
_state.tryEmit(ViewState.Success(targetCluster(type, chart))) _state.value = ViewState.Success(targetCluster(type, chart))
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -114,7 +108,7 @@ class TopChartViewModel @Inject constructor(
): StreamCluster { ): StreamCluster {
val cluster = stash val cluster = stash
.getOrPut(type) { mutableMapOf() } .getOrPut(type) { mutableMapOf() }
.getOrPut(chart) { StreamCluster() } .getOrPut(chart) { StreamCluster.EMPTY }
return cluster return cluster
} }
} }

View File

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