From 7bb2728b57a1f8f595500da6889a6463c985b3e3 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Wed, 20 May 2026 13:20:51 +0530 Subject: [PATCH 1/9] favourites: add a check icon to indicate app is installed resolves: #1153 --- .../compose/composable/FavouriteListItem.kt | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt index 7e357fa12..d7475849a 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt @@ -6,12 +6,16 @@ package com.aurora.store.compose.composable import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -29,7 +33,10 @@ import com.aurora.gplayapi.data.models.App import com.aurora.store.R import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.theme.colorGreen +import com.aurora.store.compose.theme.colorRed import com.aurora.store.data.room.favourite.Favourite +import com.aurora.store.util.PackageUtil @Composable fun FavouriteListItem( @@ -39,6 +46,9 @@ fun FavouriteListItem( onClear: () -> Unit = {} ) { val context = LocalContext.current + val isInstalled = remember(favourite.packageName) { + PackageUtil.isInstalled(context, favourite.packageName) + } RemovableListItem(onRemove = onClear) { triggerRemove -> AuroraListItem( modifier = modifier, @@ -65,11 +75,26 @@ fun FavouriteListItem( ) }, trailing = { - IconButton(onClick = triggerRemove) { - Icon( - painter = painterResource(R.drawable.ic_favorite_checked), - contentDescription = stringResource(R.string.action_favourite) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_xsmall) ) + ) { + if (isInstalled) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = stringResource(R.string.title_installed), + tint = colorGreen + ) + } + IconButton(onClick = triggerRemove) { + Icon( + painter = painterResource(R.drawable.ic_favorite_checked), + contentDescription = stringResource(R.string.action_favourite), + tint = colorRed + ) + } } } ) From ab888fab75734df8635733b93984b913ab6cdea0 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Wed, 20 May 2026 14:51:53 +0530 Subject: [PATCH 2/9] rootinstaller: fix apps being installed on main profile from work profile Resolves: #1382 --- .../java/com/aurora/store/data/installer/RootInstaller.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt index 1d4d9484a..041cca7d0 100644 --- a/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt @@ -20,6 +20,7 @@ package com.aurora.store.data.installer import android.content.Context +import android.os.Process import android.util.Log import com.aurora.extensions.TAG import com.aurora.store.AuroraApp @@ -90,8 +91,9 @@ class RootInstaller @Inject constructor( totalSize += file.length().toInt() } + val userId = Process.myUid() / 100_000 val result: Shell.Result = - Shell.cmd("pm install-create -i $PLAY_PACKAGE_NAME --user 0 -r -S $totalSize") + Shell.cmd("pm install-create -i $PLAY_PACKAGE_NAME --user $userId -r -S $totalSize") .exec() val response = result.out From 1607529bbd666e2d134b78f90f2df5e6aa150802 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 23 May 2026 02:03:28 +0530 Subject: [PATCH 3/9] fix: google account login --- .../compose/ui/accounts/GoogleLoginScreen.kt | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt index fb44474c6..7dbdb7131 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt @@ -27,14 +27,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.gplayapi.helpers.AuthHelper import com.aurora.store.AuroraApp import com.aurora.store.R import com.aurora.store.compose.navigation.Destination import com.aurora.store.data.event.AuthEvent -import com.aurora.store.data.model.AccountType -import com.aurora.store.data.providers.AccountProvider +import com.aurora.store.data.model.AuthState import com.aurora.store.util.AC2DMUtil +import com.aurora.store.util.Preferences import com.aurora.store.viewmodel.auth.AuthViewModel private const val EMBEDDED_SETUP_URL = "https://accounts.google.com/EmbeddedSetup" @@ -48,6 +49,7 @@ fun GoogleLoginScreen( viewModel: AuthViewModel = hiltViewModel() ) { val context = LocalContext.current + val authState by viewModel.authState.collectAsStateWithLifecycle() var progress by remember { mutableFloatStateOf(0f) } var isIndeterminate by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) } @@ -56,22 +58,32 @@ fun GoogleLoginScreen( AuroraApp.events.authEvent.collect { event -> if (event is AuthEvent.GoogleLogin) { if (event.success) { - AccountProvider.login( - context, + viewModel.buildGoogleAuthData( event.email, event.token, - AuthHelper.Token.AAS, - AccountType.GOOGLE + AuthHelper.Token.AAS ) } else { Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG) .show() + onNavigateTo(Destination.Splash) } - onNavigateTo(Destination.Splash) } } } + LaunchedEffect(authState) { + when (authState) { + AuthState.SignedIn, AuthState.Valid -> onNavigateTo( + Destination.Main( + Preferences.getInteger(context, Preferences.PREFERENCE_DEFAULT_SELECTED_TAB) + ) + ) + is AuthState.Failed -> onNavigateTo(Destination.Splash) + else -> Unit + } + } + Column(modifier = Modifier.fillMaxSize()) { if (isLoading) { if (isIndeterminate) { From ada4e8de2a8b2b20a8ea1601e0cd6665df35ba8a Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 23 May 2026 02:29:17 +0530 Subject: [PATCH 4/9] fix: google account login - 2 --- .../compose/ui/accounts/GoogleLoginScreen.kt | 28 +++++++++++-------- .../java/com/aurora/store/util/AC2DMTask.kt | 8 ++---- .../store/viewmodel/auth/AuthViewModel.kt | 19 +++++++------ 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt index 7dbdb7131..ff20573bf 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt @@ -40,8 +40,16 @@ import com.aurora.store.viewmodel.auth.AuthViewModel private const val EMBEDDED_SETUP_URL = "https://accounts.google.com/EmbeddedSetup" private const val AUTH_TOKEN = "oauth_token" -private const val JS_PROFILE_EMAIL = - "(function() { return document.getElementById('profileIdentifier').innerHTML; })();" + +// Google's EmbeddedSetup post-login page renders the account as e.g. +//
...
; +// the legacy id="profileIdentifier" selector no longer matches. +private const val JS_PROFILE_EMAIL = """ + (function() { + var el = document.querySelector('[data-profile-identifier][data-email]'); + return el ? el.getAttribute('data-email') : null; + })(); +""" @Composable fun GoogleLoginScreen( @@ -120,16 +128,12 @@ fun GoogleLoginScreen( webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String) { - val cookies = CookieManager.getInstance().getCookie(url) - if (cookies != null) { - val cookieMap = AC2DMUtil.parseCookieString(cookies) - if (cookieMap.isNotEmpty() && cookieMap[AUTH_TOKEN] != null) { - val oauthToken = cookieMap[AUTH_TOKEN] - view.evaluateJavascript(JS_PROFILE_EMAIL) { result -> - val email = result.replace("\"", "") - viewModel.buildAuthData(view.context, email, oauthToken) - } - } + val cookies = CookieManager.getInstance().getCookie(url) ?: return + val cookieMap = AC2DMUtil.parseCookieString(cookies) + val oauthToken = cookieMap[AUTH_TOKEN] ?: return + view.evaluateJavascript(JS_PROFILE_EMAIL) { result -> + val email = result.trim('"') + viewModel.buildAuthData(view.context, email, oauthToken) } } } diff --git a/app/src/main/java/com/aurora/store/util/AC2DMTask.kt b/app/src/main/java/com/aurora/store/util/AC2DMTask.kt index d5415a728..a5099ca16 100644 --- a/app/src/main/java/com/aurora/store/util/AC2DMTask.kt +++ b/app/src/main/java/com/aurora/store/util/AC2DMTask.kt @@ -27,11 +27,7 @@ import okhttp3.RequestBody.Companion.toRequestBody class AC2DMTask @Inject constructor(private val httpClient: HttpClient) { @Throws(Exception::class) - fun getAC2DMResponse(email: String?, oAuthToken: String?): Map { - if (email == null || oAuthToken == null) { - return mapOf() - } - + fun getAC2DMResponse(email: String, oAuthToken: String): Map { val params: MutableMap = hashMapOf() params["lang"] = Locale.getDefault().toString().replace("_", "-") params["google_play_services_version"] = PLAY_SERVICES_VERSION_CODE @@ -60,7 +56,7 @@ class AC2DMTask @Inject constructor(private val httpClient: HttpClient) { return if (response.isSuccessful) { AC2DMUtil.parseResponse(String(response.responseBytes)) } else { - mapOf() + emptyMap() } } 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 428ca10b9..b2e36496e 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( @@ -92,16 +92,19 @@ class AuthViewModel @Inject constructor( } } - fun buildAuthData(context: Context, email: String, oauthToken: String?) { + fun buildAuthData(context: Context, email: String, oauthToken: String) { viewModelScope.launch(Dispatchers.IO) { try { val response = aC2DMTask.getAC2DMResponse(email, oauthToken) if (response.isNotEmpty()) { val aasToken = response["Token"] if (aasToken != null) { - Preferences.putString(context, Constants.ACCOUNT_EMAIL_PLAIN, email) + val accountEmail = response["Email"]?.takeIf { it.isNotBlank() } ?: email + Preferences.putString(context, Constants.ACCOUNT_EMAIL_PLAIN, accountEmail) Preferences.putString(context, Constants.ACCOUNT_AAS_PLAIN, aasToken) - AuroraApp.events.send(AuthEvent.GoogleLogin(true, email, aasToken)) + AuroraApp.events.send( + AuthEvent.GoogleLogin(true, accountEmail, aasToken) + ) } else { Preferences.putString(context, Constants.ACCOUNT_EMAIL_PLAIN, "") Preferences.putString(context, Constants.ACCOUNT_AAS_PLAIN, "") @@ -191,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 { From 272870ddd2eab44fe37518ac4faec17ca6c9c675 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Wed, 20 May 2026 16:38:57 +0530 Subject: [PATCH 5/9] auth: refresh anonymous session on activity resume Long-backgrounded sessions (especially on aggressive vendors like Huawei) could leave the Play token invalid and OkHttp's connection pool full of dead sockets, producing a perpetual loading spinner with no error. - ComposeActivity#onStart probes the saved token via isSavedAuthDataValid; on failure evicts the OkHttp pool and refreshes anonymous auth. - AuthProvider gains authReady StateFlow, awaitReady(), and a mutex-guarded refreshAnonymousAuth() so concurrent callers don't double-fetch. - Play-backed ViewModels (AppDetails, DevProfile, MoreApps, Reviews, Search, ExpandedStreamBrowse, Category) await refresh completion before issuing helper calls, closing the race between resume-time refresh and screen LaunchedEffect fetches. - AppDetailsScreen rendered Error as Loading when app was null; reorder so Error wins and wire a Retry button through Placeholder. - Stream/Review/Search/DevProfile/CategoryBrowse error placeholders gain Retry actions wired to PagingItems.retry() or the VM's fetch. - CategoryViewModel was logging-only on failure; now emits ViewState.Error with a matching Retry path in CategoriesPage. --- .../java/com/aurora/store/ComposeActivity.kt | 24 +++++++++++ .../store/compose/ui/apps/CategoriesPage.kt | 15 +++++++ .../ui/commons/CategoryBrowseScreen.kt | 4 +- .../ui/commons/ExpandedStreamBrowseScreen.kt | 4 +- .../compose/ui/commons/StreamBrowseScreen.kt | 4 +- .../compose/ui/details/AppDetailsScreen.kt | 17 ++++---- .../store/compose/ui/details/ReviewScreen.kt | 4 +- .../store/compose/ui/dev/DevProfileScreen.kt | 4 +- .../store/compose/ui/search/SearchScreen.kt | 4 +- .../store/data/providers/AuthProvider.kt | 40 +++++++++++++++++++ .../browse/ExpandedStreamBrowseViewModel.kt | 3 ++ .../viewmodel/category/CategoryViewModel.kt | 6 +++ .../viewmodel/details/AppDetailsViewModel.kt | 1 + .../viewmodel/details/DevProfileViewModel.kt | 4 ++ .../store/viewmodel/details/MoreViewModel.kt | 3 ++ .../viewmodel/details/ReviewViewModel.kt | 3 ++ .../store/viewmodel/search/SearchViewModel.kt | 2 + 17 files changed, 129 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt index da6576e03..b965f21e9 100644 --- a/app/src/main/java/com/aurora/store/ComposeActivity.kt +++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.core.content.IntentCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import com.aurora.extensions.getPackageName import com.aurora.store.compose.composition.LocalNetworkStatus import com.aurora.store.compose.composition.LocalUI @@ -23,18 +24,27 @@ import com.aurora.store.compose.navigation.NavDisplay import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.theme.AuroraTheme import com.aurora.store.data.model.NetworkStatus +import com.aurora.store.data.providers.AccountProvider +import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.NetworkProvider import com.aurora.store.data.receiver.MigrationReceiver import com.aurora.store.util.PackageUtil import com.aurora.store.util.Preferences import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient @AndroidEntryPoint class ComposeActivity : ComponentActivity() { @Inject lateinit var networkProvider: NetworkProvider + @Inject lateinit var authProvider: AuthProvider + + @Inject lateinit var okHttpClient: OkHttpClient + override fun onCreate(savedInstanceState: Bundle?) { MigrationReceiver.runMigrationsIfRequired(this) enableEdgeToEdge() @@ -91,4 +101,18 @@ class ComposeActivity : ComponentActivity() { !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding else -> Screen.Splash } + + override fun onStart() { + super.onStart() + if (!AccountProvider.isLoggedIn(this)) return + lifecycleScope.launch(Dispatchers.IO) { + // Ask Play directly if the saved token still works. If it does, + // sockets are fine too and there's nothing to do. If it doesn't, + // evict the pool (the same backgrounded state that invalidates + // tokens also leaves dead sockets) and refresh anonymous auth. + if (authProvider.isSavedAuthDataValid()) return@launch + okHttpClient.connectionPool.evictAll() + if (authProvider.isAnonymous) authProvider.refreshAnonymousAuth() + } + } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt index c07ae64a5..7fb00d14f 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt @@ -12,11 +12,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier +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 com.aurora.gplayapi.data.models.Category import com.aurora.store.CategoryStash +import com.aurora.store.R import com.aurora.store.compose.composable.CategoryItem +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ShimmerCategoryRow import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.model.ViewState @@ -35,6 +39,17 @@ internal fun CategoriesContent( viewModel.getCategoryList(categoryType) } + if (state is ViewState.Error) { + Placeholder( + modifier = Modifier.fillMaxSize(), + painter = painterResource(R.drawable.ic_disclaimer), + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { viewModel.getCategoryList(categoryType) } + ) + return + } + @Suppress("UNCHECKED_CAST") val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash val list = categories?.get(categoryType) 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 93a1c7aa9..c3ca7b275 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 @@ -43,7 +43,9 @@ fun CategoryBrowseScreen( Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { viewModel.fetchNextPage() } ) } else { val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt index 4d9736c0d..14e39fb07 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt @@ -57,7 +57,9 @@ fun ExpandedStreamBrowseScreen( Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { apps.retry() } ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt index 577990f2a..9ca0418ff 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt @@ -76,7 +76,9 @@ private fun ScreenContent( Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { apps.retry() } ) } 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 b0bd2c83b..dba6279ae 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 @@ -148,14 +148,15 @@ fun AppDetailsScreen( label = "AppDetailsState" ) { currentState -> when { - currentState is AppState.Loading || app == null -> - ScreenContentLoading() - currentState is AppState.Error -> ScreenContentError( - message = currentState.message + message = currentState.message, + onRetry = { viewModel.fetchAppDetails(packageName) } ) + currentState is AppState.Loading || app == null -> + ScreenContentLoading() + else -> { val loadedApp = app!! ScreenContentApp( @@ -199,8 +200,8 @@ fun AppDetailsScreen( } private fun stateKey(state: AppState, app: App?): String = when { - state is AppState.Loading || app == null -> "loading" state is AppState.Error -> "error" + state is AppState.Loading || app == null -> "loading" else -> "content" } @@ -220,14 +221,16 @@ private fun ScreenContentLoading() { * Composable to display errors related to fetching app details */ @Composable -private fun ScreenContentError(message: String? = null) { +private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? = null) { Scaffold( topBar = { TopAppBar() } ) { paddingValues -> Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_apps_outage), - message = message ?: stringResource(R.string.toast_app_unavailable) + message = message ?: stringResource(R.string.toast_app_unavailable), + actionLabel = onRetry?.let { stringResource(R.string.action_retry) }, + onAction = onRetry ) } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt index f7bf883b7..f48110c67 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt @@ -117,7 +117,9 @@ private fun ScreenContent( Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { reviews.retry() } ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt index ef5e0fb5b..929499b02 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt @@ -99,7 +99,9 @@ private fun ScreenContent( Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { apps.retry() } ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt index 9f8302ef5..5924c23f2 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt @@ -253,7 +253,9 @@ private fun ScreenContent( Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { results.retry() } ) } diff --git a/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt b/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt index e82a5a882..70cef5d73 100644 --- a/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt +++ b/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt @@ -36,6 +36,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -67,11 +73,45 @@ class AuthProvider @Inject constructor( val isAnonymous: Boolean get() = AccountProvider.getAccountType(context) == AccountType.ANONYMOUS + private val _authReady = MutableStateFlow(true) + + /** + * Emits false while an auth refresh is in flight so callers can defer + * network work until fresh credentials are persisted. + */ + val authReady: StateFlow = _authReady.asStateFlow() + + private val refreshMutex = Mutex() + /** * Checks whether saved AuthData is valid or not */ fun isSavedAuthDataValid(): Boolean = AuthHelper.isValid(authData!!) + /** + * Suspends until no auth refresh is in flight. ViewModels should call this + * before issuing Play requests so a foreground refresh doesn't race the fetch. + */ + suspend fun awaitReady() { + _authReady.first { it } + } + + /** + * Rebuilds anonymous [AuthData] from a dispenser and persists it. Serialized + * so concurrent callers don't double-fetch. On failure, the previously saved + * auth is left in place so requests can still attempt with stale credentials. + */ + suspend fun refreshAnonymousAuth(): Result = refreshMutex.withLock { + try { + _authReady.value = false + val result = buildAnonymousAuthData() + result.onSuccess { saveAuthData(it) } + result + } finally { + _authReady.value = true + } + } + /** * Builds [AuthData] for login using personal Google account * @param email E-mail ID 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 9c503fc0b..42718a7bc 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 @@ -16,6 +16,7 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.helpers.ExpandedBrowseHelper import com.aurora.store.data.PageResult import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager +import com.aurora.store.data.providers.AuthProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.onEach @HiltViewModel(assistedFactory = ExpandedStreamBrowseViewModel.Factory::class) class ExpandedStreamBrowseViewModel @AssistedInject constructor( @Assisted val browseUrl: String, + private val authProvider: AuthProvider, private val streamHelper: ExpandedBrowseHelper ) : ViewModel() { @@ -53,6 +55,7 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor( manualPager { page -> val items = try { + authProvider.awaitReady() when (page) { 1 -> { val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl) diff --git a/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt index 9d23d0b49..305f1ab2d 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt @@ -29,6 +29,7 @@ import com.aurora.gplayapi.helpers.CategoryHelper import com.aurora.gplayapi.helpers.contracts.CategoryContract import com.aurora.store.CategoryStash import com.aurora.store.data.model.ViewState +import com.aurora.store.data.providers.AuthProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -36,6 +37,7 @@ import kotlinx.coroutines.launch @HiltViewModel class CategoryViewModel @Inject constructor( + private val authProvider: AuthProvider, private val categoryHelper: CategoryHelper ) : ViewModel() { @@ -57,11 +59,15 @@ class CategoryViewModel @Inject constructor( return@launch } + liveData.postValue(ViewState.Loading) + try { + authProvider.awaitReady() stash[type] = contract().getAllCategories(type) liveData.postValue(ViewState.Success(stash)) } catch (exception: Exception) { Log.e(TAG, "Failed fetching list of categories", exception) + liveData.postValue(ViewState.Error(exception.message)) } } } 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 e01e362ca..75e9d29bb 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 @@ -168,6 +168,7 @@ 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) ) 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 bf561e26b..47e69114a 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 @@ -31,6 +31,7 @@ import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.StreamHelper import com.aurora.gplayapi.helpers.contracts.StreamContract import com.aurora.store.data.model.ViewState +import com.aurora.store.data.providers.AuthProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -39,6 +40,7 @@ import kotlinx.coroutines.supervisorScope @HiltViewModel class DevProfileViewModel @Inject constructor( + private val authProvider: AuthProvider, private val appDetailsHelper: AppDetailsHelper, private val streamHelper: StreamHelper ) : ViewModel() { @@ -54,6 +56,7 @@ class DevProfileViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { supervisorScope { try { + authProvider.awaitReady() devStream = appDetailsHelper.getDeveloperStream(devId) streamBundle = devStream.streamBundle liveData.postValue(ViewState.Success(devStream)) @@ -69,6 +72,7 @@ class DevProfileViewModel @Inject constructor( supervisorScope { try { if (streamCluster.hasNext()) { + authProvider.awaitReady() val newCluster = streamHelper.getNextStreamCluster( streamCluster.clusterNextPageUrl ) diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt index 1fc6ef1c5..bfe555bc0 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.helpers.AppDetailsHelper +import com.aurora.store.data.providers.AuthProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -24,6 +25,7 @@ import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = MoreViewModel.Factory::class) class MoreViewModel @AssistedInject constructor( @Assisted private val dependencies: List, + private val authProvider: AuthProvider, private val appDetailsHelper: AppDetailsHelper ) : ViewModel() { @@ -42,6 +44,7 @@ class MoreViewModel @AssistedInject constructor( private fun fetchDependencies() { viewModelScope.launch(Dispatchers.IO) { try { + authProvider.awaitReady() _dependentApps.value = appDetailsHelper.getAppByPackageName(dependencies) } catch (exception: Exception) { Log.e(TAG, "Failed to fetch dependencies", exception) diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt index f91e3e2ce..618cf5d5a 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt @@ -16,6 +16,7 @@ import com.aurora.gplayapi.data.models.Review import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.store.data.PageResult import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager +import com.aurora.store.data.providers.AuthProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.onEach @HiltViewModel(assistedFactory = ReviewViewModel.Factory::class) class ReviewViewModel @AssistedInject constructor( @Assisted private val packageName: String, + private val authProvider: AuthProvider, private val reviewsHelper: ReviewsHelper ) : ViewModel() { @@ -49,6 +51,7 @@ class ReviewViewModel @AssistedInject constructor( manualPager { page -> val items = try { + authProvider.awaitReady() when (page) { 1 -> reviewsHelper.getReviews(packageName, filter).also { reviewsNextPageUrl = it.nextPageUrl 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 ead7a2ef6..505036a3d 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 @@ -83,6 +83,7 @@ class SearchViewModel @Inject constructor( manualPager { page -> val items = try { + authProvider.awaitReady() when (page) { 1 -> contract.searchResults(query) .also { nextBundleUrl = it.streamNextPageUrl } @@ -121,6 +122,7 @@ class SearchViewModel @Inject constructor( fun fetchSuggestions(query: String) { viewModelScope.launch(Dispatchers.IO) { + authProvider.awaitReady() _suggestions.value = contract.searchSuggestions(query) .filter { it.title.isNotBlank() } .take(3) From eada663bfb0a5aa9ee7661a72c63858f9ea8d026 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Wed, 20 May 2026 16:46:41 +0530 Subject: [PATCH 6/9] compose: use ic_refresh icon on retryable error placeholders --- .../store/compose/ui/apps/CategoriesPage.kt | 2 +- .../ui/commons/CategoryBrowseScreen.kt | 2 +- .../ui/commons/ExpandedStreamBrowseScreen.kt | 2 +- .../compose/ui/commons/StreamBrowseScreen.kt | 2 +- .../compose/ui/details/AppDetailsScreen.kt | 2 +- .../store/compose/ui/details/ReviewScreen.kt | 2 +- .../store/compose/ui/dev/DevProfileScreen.kt | 2 +- .../store/compose/ui/search/SearchScreen.kt | 2 +- app/src/main/res/drawable/ic_refresh.xml | 24 +++++++++++++++++++ 9 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/ic_refresh.xml diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt index 7fb00d14f..24c21abf3 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt @@ -42,7 +42,7 @@ internal fun CategoriesContent( if (state is ViewState.Error) { Placeholder( modifier = Modifier.fillMaxSize(), - painter = painterResource(R.drawable.ic_disclaimer), + painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), onAction = { viewModel.getCategoryList(categoryType) } 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 c3ca7b275..9aba01514 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 @@ -42,7 +42,7 @@ fun CategoryBrowseScreen( if (uiState is ViewState.Error) { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), + painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), onAction = { viewModel.fetchNextPage() } diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt index 14e39fb07..02bc16fd9 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt @@ -56,7 +56,7 @@ fun ExpandedStreamBrowseScreen( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), + painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), onAction = { apps.retry() } diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt index 9ca0418ff..f04514b45 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt @@ -75,7 +75,7 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), + painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), onAction = { apps.retry() } 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 dba6279ae..a358ed412 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 @@ -227,7 +227,7 @@ private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? = ) { paddingValues -> Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_apps_outage), + painter = painterResource(R.drawable.ic_refresh), message = message ?: stringResource(R.string.toast_app_unavailable), actionLabel = onRetry?.let { stringResource(R.string.action_retry) }, onAction = onRetry diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt index f48110c67..06585dce1 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt @@ -116,7 +116,7 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), + painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), onAction = { reviews.retry() } diff --git a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt index 929499b02..ef68ca797 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt @@ -98,7 +98,7 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), + painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), onAction = { apps.retry() } diff --git a/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt index 5924c23f2..101389359 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt @@ -252,7 +252,7 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), + painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), onAction = { results.retry() } diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..782e5ffac --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,24 @@ + + + + From bae9233b27492f8cad1975308a6cbc20d1d6f290 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 23 May 2026 01:21:32 +0530 Subject: [PATCH 7/9] 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" From 0bcb087be29ea7ae8aa58f0f2ad4db2ef14c3175 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Mon, 25 May 2026 03:03:18 +0530 Subject: [PATCH 8/9] use a SessionExpired event instead of a retry loop --- .../java/com/aurora/store/ComposeActivity.kt | 26 +------ .../store/compose/navigation/Destination.kt | 2 +- .../store/compose/navigation/NavDisplay.kt | 24 ++++++- .../aurora/store/compose/navigation/Screen.kt | 2 +- .../compose/ui/accounts/AccountsScreen.kt | 2 +- .../compose/ui/accounts/GoogleLoginScreen.kt | 4 +- .../store/compose/ui/spoof/SpoofScreen.kt | 2 +- .../com/aurora/store/data/event/BusEvent.kt | 1 + .../store/data/providers/AuthProvider.kt | 40 ----------- .../browse/ExpandedStreamBrowseViewModel.kt | 10 ++- .../viewmodel/category/CategoryViewModel.kt | 9 ++- .../viewmodel/details/AppDetailsViewModel.kt | 69 ++++++++----------- .../viewmodel/details/DevProfileViewModel.kt | 10 +-- .../store/viewmodel/details/MoreViewModel.kt | 9 ++- .../viewmodel/details/ReviewViewModel.kt | 10 ++- .../viewmodel/homestream/StreamViewModel.kt | 13 +--- .../store/viewmodel/search/SearchViewModel.kt | 9 ++- 17 files changed, 98 insertions(+), 144 deletions(-) diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt index b965f21e9..ffa594b24 100644 --- a/app/src/main/java/com/aurora/store/ComposeActivity.kt +++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.core.content.IntentCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import com.aurora.extensions.getPackageName import com.aurora.store.compose.composition.LocalNetworkStatus import com.aurora.store.compose.composition.LocalUI @@ -24,27 +23,18 @@ import com.aurora.store.compose.navigation.NavDisplay import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.theme.AuroraTheme import com.aurora.store.data.model.NetworkStatus -import com.aurora.store.data.providers.AccountProvider -import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.NetworkProvider import com.aurora.store.data.receiver.MigrationReceiver import com.aurora.store.util.PackageUtil import com.aurora.store.util.Preferences import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import okhttp3.OkHttpClient @AndroidEntryPoint class ComposeActivity : ComponentActivity() { @Inject lateinit var networkProvider: NetworkProvider - @Inject lateinit var authProvider: AuthProvider - - @Inject lateinit var okHttpClient: OkHttpClient - override fun onCreate(savedInstanceState: Bundle?) { MigrationReceiver.runMigrationsIfRequired(this) enableEdgeToEdge() @@ -99,20 +89,6 @@ class ComposeActivity : ComponentActivity() { private fun defaultStart(): Screen = when { !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding - else -> Screen.Splash - } - - override fun onStart() { - super.onStart() - if (!AccountProvider.isLoggedIn(this)) return - lifecycleScope.launch(Dispatchers.IO) { - // Ask Play directly if the saved token still works. If it does, - // sockets are fine too and there's nothing to do. If it doesn't, - // evict the pool (the same backgrounded state that invalidates - // tokens also leaves dead sockets) and refresh anonymous auth. - if (authProvider.isSavedAuthDataValid()) return@launch - okHttpClient.connectionPool.evictAll() - if (authProvider.isAnonymous) authProvider.refreshAnonymousAuth() - } + else -> Screen.Splash() } } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt index da381c775..8f8bc2b4f 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt @@ -15,7 +15,7 @@ import com.aurora.store.data.room.update.Update * Screens emit one of these via a single `onNavigateTo: (Destination) -> Unit` callback. */ sealed class Destination { - data object Splash : Destination() + data class Splash(val packageName: String? = null) : Destination() data class Main(val initialTab: Int) : Destination() data class AppDetails(val packageName: String) : Destination() diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index 0846dc0cf..47ee6896f 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -58,6 +58,7 @@ import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen import com.aurora.store.compose.ui.search.SearchScreen import com.aurora.store.compose.ui.splash.SplashScreen import com.aurora.store.compose.ui.spoof.SpoofScreen +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.model.AccountType import com.aurora.store.data.providers.AccountProvider @@ -101,12 +102,24 @@ fun NavDisplay(startDestination: NavKey) { } } + // Send the user back to Splash whenever a ViewModel reports the saved Play + // token was rejected. SplashScreen re-validates via a live Play call and + // rebuilds auth on failure, then routes to AppDetails if a packageName was attached. + LaunchedEffect(Unit) { + AuroraApp.events.authEvent.collect { event -> + if (event is AuthEvent.SessionExpired) { + backstack.clear() + backstack.add(Screen.Splash(event.packageName)) + } + } + } + fun navigate(destination: Destination) { when (destination) { - Destination.Splash -> { + is Destination.Splash -> { // Clear the backstack when navigating to Splash to prevent going back to the previous screen when the user is sent back to the splash screen (e.g. after logout). backstack.clear() - backstack.add(Screen.Splash) + backstack.add(Screen.Splash(destination.packageName)) } is Destination.Main -> { @@ -249,7 +262,12 @@ fun NavDisplay(startDestination: NavKey) { } ) { SearchScreen() } - entry { SplashScreen(onNavigateTo = ::navigate) } + entry { screen -> + SplashScreen( + deepLinkPackageName = screen.packageName, + onNavigateTo = ::navigate + ) + } entry { OnboardingScreen() } entry { BlacklistScreen() } entry { DownloadsScreen(onNavigateTo = ::navigate) } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt index f8b0b0870..1ce32189a 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt @@ -96,7 +96,7 @@ sealed class Screen : NavKey, Parcelable { data object SourceFilters : Screen() @Serializable - data object Splash : Screen() + data class Splash(val packageName: String? = null) : Screen() @Serializable data class Main(val initialTab: Int = 0) : Screen() diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt index bbd9a6af6..733510a27 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt @@ -81,7 +81,7 @@ fun AccountsScreen( onConfirm = { shouldShowLogoutDialog = false AccountProvider.logout(context) - onNavigateTo(Destination.Splash) + onNavigateTo(Destination.Splash()) }, onDismiss = { shouldShowLogoutDialog = false } ) diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt index ff20573bf..36764da81 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt @@ -74,7 +74,7 @@ fun GoogleLoginScreen( } else { Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG) .show() - onNavigateTo(Destination.Splash) + onNavigateTo(Destination.Splash()) } } } @@ -87,7 +87,7 @@ fun GoogleLoginScreen( Preferences.getInteger(context, Preferences.PREFERENCE_DEFAULT_SELECTED_TAB) ) ) - is AuthState.Failed -> onNavigateTo(Destination.Splash) + is AuthState.Failed -> onNavigateTo(Destination.Splash()) else -> Unit } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt index f43b42fa2..17c539c19 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt @@ -96,7 +96,7 @@ private fun ScreenContent( when (result) { SnackbarResult.ActionPerformed -> { AccountProvider.logout(context) - onNavigateTo(Destination.Splash) + onNavigateTo(Destination.Splash()) } else -> Unit diff --git a/app/src/main/java/com/aurora/store/data/event/BusEvent.kt b/app/src/main/java/com/aurora/store/data/event/BusEvent.kt index 444e18c37..d7d4269a1 100644 --- a/app/src/main/java/com/aurora/store/data/event/BusEvent.kt +++ b/app/src/main/java/com/aurora/store/data/event/BusEvent.kt @@ -30,6 +30,7 @@ sealed class BusEvent : Event() { sealed class AuthEvent : Event() { data class GoogleLogin(val success: Boolean, val email: String, val token: String) : AuthEvent() + data class SessionExpired(val packageName: String? = null) : AuthEvent() } open class InstallerEvent(open val packageName: String) : Event() { diff --git a/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt b/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt index 70cef5d73..e82a5a882 100644 --- a/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt +++ b/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt @@ -36,12 +36,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -73,45 +67,11 @@ class AuthProvider @Inject constructor( val isAnonymous: Boolean get() = AccountProvider.getAccountType(context) == AccountType.ANONYMOUS - private val _authReady = MutableStateFlow(true) - - /** - * Emits false while an auth refresh is in flight so callers can defer - * network work until fresh credentials are persisted. - */ - val authReady: StateFlow = _authReady.asStateFlow() - - private val refreshMutex = Mutex() - /** * Checks whether saved AuthData is valid or not */ fun isSavedAuthDataValid(): Boolean = AuthHelper.isValid(authData!!) - /** - * Suspends until no auth refresh is in flight. ViewModels should call this - * before issuing Play requests so a foreground refresh doesn't race the fetch. - */ - suspend fun awaitReady() { - _authReady.first { it } - } - - /** - * Rebuilds anonymous [AuthData] from a dispenser and persists it. Serialized - * so concurrent callers don't double-fetch. On failure, the previously saved - * auth is left in place so requests can still attempt with stale credentials. - */ - suspend fun refreshAnonymousAuth(): Result = refreshMutex.withLock { - try { - _authReady.value = false - val result = buildAnonymousAuthData() - result.onSuccess { saveAuthData(it) } - result - } finally { - _authReady.value = true - } - } - /** * Builds [AuthData] for login using personal Google account * @param email E-mail ID 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 6ab06a002..3acfcb5f2 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 @@ -13,10 +13,12 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.exceptions.GooglePlayException import com.aurora.gplayapi.helpers.ExpandedBrowseHelper +import com.aurora.store.AuroraApp import com.aurora.store.data.PageResult +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager -import com.aurora.store.data.providers.AuthProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -30,7 +32,6 @@ import kotlinx.coroutines.flow.onEach @HiltViewModel(assistedFactory = ExpandedStreamBrowseViewModel.Factory::class) class ExpandedStreamBrowseViewModel @AssistedInject constructor( @Assisted val browseUrl: String, - private val authProvider: AuthProvider, private val streamHelper: ExpandedBrowseHelper ) : ViewModel() { @@ -55,7 +56,6 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor( manualPager { page -> val items = try { - authProvider.awaitReady() when (page) { 1 -> { val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl) @@ -86,6 +86,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor( } } } + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Expanded stream returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) + emptyList() } catch (exception: Exception) { Log.e(TAG, "Failed to fetch apps for page $page", exception) emptyList() diff --git a/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt index 305f1ab2d..5dbc811c1 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt @@ -25,11 +25,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.Category +import com.aurora.gplayapi.exceptions.GooglePlayException import com.aurora.gplayapi.helpers.CategoryHelper import com.aurora.gplayapi.helpers.contracts.CategoryContract +import com.aurora.store.AuroraApp import com.aurora.store.CategoryStash +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.model.ViewState -import com.aurora.store.data.providers.AuthProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -37,7 +39,6 @@ import kotlinx.coroutines.launch @HiltViewModel class CategoryViewModel @Inject constructor( - private val authProvider: AuthProvider, private val categoryHelper: CategoryHelper ) : ViewModel() { @@ -62,9 +63,11 @@ class CategoryViewModel @Inject constructor( liveData.postValue(ViewState.Loading) try { - authProvider.awaitReady() stash[type] = contract().getAllCategories(type) liveData.postValue(ViewState.Success(stash)) + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Categories fetch returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) } catch (exception: Exception) { Log.e(TAG, "Failed fetching list of categories", exception) liveData.postValue(ViewState.Error(exception.message)) 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 a6d6de453..b9b7aad0a 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 @@ -27,6 +27,7 @@ import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper import com.aurora.gplayapi.network.IHttpClient import com.aurora.store.AuroraApp import com.aurora.store.BuildConfig +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.model.AppState @@ -162,48 +163,36 @@ 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( - isInstalled = PackageUtil.isInstalled(context, packageName) - ) - val existingDownload = downloadHelper.getDownload(packageName) + try { + _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 - } - 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 + // 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 } + } catch (exception: GooglePlayException.AuthException) { + // The saved Play token has been rejected mid-session. Hand off to + // Splash to re-validate and rebuild auth, and ask it to bring the + // user back to this app's details once auth is good again. + Log.w(TAG, "App details fetch returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired(packageName)) + } 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 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 377ffb8d0..ef92da55f 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 @@ -27,11 +27,13 @@ import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.details.DevStream +import com.aurora.gplayapi.exceptions.GooglePlayException import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.StreamHelper import com.aurora.gplayapi.helpers.contracts.StreamContract +import com.aurora.store.AuroraApp +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.model.ViewState -import com.aurora.store.data.providers.AuthProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -40,7 +42,6 @@ import kotlinx.coroutines.supervisorScope @HiltViewModel class DevProfileViewModel @Inject constructor( - private val authProvider: AuthProvider, private val appDetailsHelper: AppDetailsHelper, private val streamHelper: StreamHelper ) : ViewModel() { @@ -56,10 +57,12 @@ class DevProfileViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { supervisorScope { try { - authProvider.awaitReady() devStream = appDetailsHelper.getDeveloperStream(devId) streamBundle = devStream.streamBundle liveData.postValue(ViewState.Success(devStream)) + } catch (e: GooglePlayException.AuthException) { + Log.w(TAG, "Developer stream fetch returned ${e.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) } catch (e: Exception) { liveData.postValue(ViewState.Error(e.message)) } @@ -72,7 +75,6 @@ class DevProfileViewModel @Inject constructor( supervisorScope { try { if (streamCluster.hasNext()) { - authProvider.awaitReady() val newCluster = streamHelper.getNextStreamCluster( streamCluster.id, streamCluster.clusterNextPageUrl diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt index bfe555bc0..1c8d3591b 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt @@ -11,8 +11,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.exceptions.GooglePlayException import com.aurora.gplayapi.helpers.AppDetailsHelper -import com.aurora.store.data.providers.AuthProvider +import com.aurora.store.AuroraApp +import com.aurora.store.data.event.AuthEvent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -25,7 +27,6 @@ import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = MoreViewModel.Factory::class) class MoreViewModel @AssistedInject constructor( @Assisted private val dependencies: List, - private val authProvider: AuthProvider, private val appDetailsHelper: AppDetailsHelper ) : ViewModel() { @@ -44,8 +45,10 @@ class MoreViewModel @AssistedInject constructor( private fun fetchDependencies() { viewModelScope.launch(Dispatchers.IO) { try { - authProvider.awaitReady() _dependentApps.value = appDetailsHelper.getAppByPackageName(dependencies) + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Dependencies fetch returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) } catch (exception: Exception) { Log.e(TAG, "Failed to fetch dependencies", exception) _dependentApps.value = null diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt index 618cf5d5a..54181181b 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt @@ -13,10 +13,12 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.Review +import com.aurora.gplayapi.exceptions.GooglePlayException import com.aurora.gplayapi.helpers.ReviewsHelper +import com.aurora.store.AuroraApp import com.aurora.store.data.PageResult +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager -import com.aurora.store.data.providers.AuthProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -30,7 +32,6 @@ import kotlinx.coroutines.flow.onEach @HiltViewModel(assistedFactory = ReviewViewModel.Factory::class) class ReviewViewModel @AssistedInject constructor( @Assisted private val packageName: String, - private val authProvider: AuthProvider, private val reviewsHelper: ReviewsHelper ) : ViewModel() { @@ -51,7 +52,6 @@ class ReviewViewModel @AssistedInject constructor( manualPager { page -> val items = try { - authProvider.awaitReady() when (page) { 1 -> reviewsHelper.getReviews(packageName, filter).also { reviewsNextPageUrl = it.nextPageUrl @@ -67,6 +67,10 @@ class ReviewViewModel @AssistedInject constructor( } } } + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Reviews fetch returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired(packageName)) + emptyList() } catch (exception: Exception) { Log.e(TAG, "Failed to fetch reviews for $page: $reviewsNextPageUrl", exception) emptyList() 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 b19442b99..158c9eb29 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 @@ -80,19 +80,8 @@ class StreamViewModel @Inject constructor( streamContract.fetch(type, category) } - // 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 + rekeyedNewClusters, + streamClusters = bundle.streamClusters + newBundle.streamClusters, streamNextPageUrl = newBundle.streamNextPageUrl ) stash[category] = mergedBundle 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 3f60e3ef3..5ecc2dbf0 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,9 +18,12 @@ 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.exceptions.GooglePlayException import com.aurora.gplayapi.helpers.contracts.SearchContract import com.aurora.gplayapi.helpers.web.WebSearchHelper +import com.aurora.store.AuroraApp import com.aurora.store.data.PageResult +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.model.SearchFilter import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager import com.aurora.store.data.providers.AuthProvider @@ -81,7 +84,6 @@ class SearchViewModel @Inject constructor( manualPager { page -> val items = try { - authProvider.awaitReady() when (page) { 1 -> contract.searchResults(query) .also { nextBundleUrl = it.streamNextPageUrl } @@ -107,6 +109,10 @@ class SearchViewModel @Inject constructor( } } } + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Search returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) + emptyList() } catch (exception: Exception) { Log.e(TAG, "Failed to search results for $query", exception) emptyList() @@ -120,7 +126,6 @@ class SearchViewModel @Inject constructor( fun fetchSuggestions(query: String) { viewModelScope.launch(Dispatchers.IO) { - authProvider.awaitReady() _suggestions.value = contract.searchSuggestions(query) .filter { it.title.isNotBlank() } .take(5) From 71f6538d463c1bb48c919615da36b137cb6619ac Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Mon, 25 May 2026 03:30:03 +0530 Subject: [PATCH 9/9] vm: let paging errors surface so retry() re-runs the load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream/ExpandedStream/Review/Search paging VMs were swallowing exceptions inside manualPager and returning emptyList, so the pager never entered LoadState.Error and the error-placeholder retry buttons were dead. Let non-Auth exceptions propagate; keep the AuthException → SessionExpired branch so 401 still hands off to Splash. Also drop supervisorScope wrappers from CategoryStream/DevProfile/TopChart VMs that launch no children — the surrounding try/catch already handles everything. --- .../ui/commons/CategoryBrowseScreen.kt | 4 +- .../browse/ExpandedStreamBrowseViewModel.kt | 3 - .../viewmodel/browse/StreamBrowseViewModel.kt | 12 ++- .../viewmodel/details/DevProfileViewModel.kt | 49 +++++------ .../viewmodel/details/ReviewViewModel.kt | 3 - .../store/viewmodel/search/SearchViewModel.kt | 3 - .../subcategory/CategoryStreamViewModel.kt | 87 +++++++++---------- .../viewmodel/topchart/TopChartViewModel.kt | 23 +++-- 8 files changed, 83 insertions(+), 101 deletions(-) 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 98c164f0e..246545a85 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 @@ -45,7 +45,7 @@ fun CategoryBrowseScreen( painter = painterResource(R.drawable.ic_refresh), message = stringResource(R.string.error), actionLabel = stringResource(R.string.action_retry), - onAction = { viewModel.fetchNextPage() } + onAction = { viewModel.fetch() } ) } else { val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle @@ -60,7 +60,7 @@ fun CategoryBrowseScreen( }, onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, onClusterScrolled = { viewModel.fetchNextCluster(it) }, - onScrolledToEnd = { viewModel.fetchNextPage() } + onScrolledToEnd = { viewModel.fetch() } ) } } 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 3acfcb5f2..8a065d1be 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 @@ -90,9 +90,6 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor( Log.w(TAG, "Expanded stream returned ${exception.code}, redirecting to Splash") AuroraApp.events.send(AuthEvent.SessionExpired()) emptyList() - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch apps for page $page", exception) - emptyList() } PageResult(items) }.flow.distinctUntilChanged() 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 8042fc026..9a223ef42 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 @@ -27,8 +27,11 @@ import androidx.paging.cachedIn import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.StreamCluster +import com.aurora.gplayapi.exceptions.GooglePlayException import com.aurora.gplayapi.helpers.web.WebStreamHelper +import com.aurora.store.AuroraApp import com.aurora.store.data.PageResult +import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -55,10 +58,10 @@ class StreamBrowseViewModel @AssistedInject constructor( val apps = _apps.asStateFlow() init { - fetchApps() + fetch() } - private fun fetchApps() { + fun fetch() { var nextPageUrl: String = streamCluster.clusterNextPageUrl manualPager { page -> @@ -79,8 +82,9 @@ class StreamBrowseViewModel @AssistedInject constructor( } } } - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch apps for $page: $nextPageUrl", exception) + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Stream returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) emptyList() } PageResult(items) 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 ef92da55f..a781759ff 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 @@ -38,7 +38,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope @HiltViewModel class DevProfileViewModel @Inject constructor( @@ -55,39 +54,35 @@ class DevProfileViewModel @Inject constructor( fun getStreamBundle(devId: String) { viewModelScope.launch(Dispatchers.IO) { - supervisorScope { - try { - devStream = appDetailsHelper.getDeveloperStream(devId) - streamBundle = devStream.streamBundle - liveData.postValue(ViewState.Success(devStream)) - } catch (e: GooglePlayException.AuthException) { - Log.w(TAG, "Developer stream fetch returned ${e.code}, redirecting to Splash") - AuroraApp.events.send(AuthEvent.SessionExpired()) - } catch (e: Exception) { - liveData.postValue(ViewState.Error(e.message)) - } + try { + devStream = appDetailsHelper.getDeveloperStream(devId) + streamBundle = devStream.streamBundle + liveData.postValue(ViewState.Success(devStream)) + } catch (e: GooglePlayException.AuthException) { + Log.w(TAG, "Developer stream fetch returned ${e.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) + } catch (e: Exception) { + liveData.postValue(ViewState.Error(e.message)) } } } fun observeCluster(streamCluster: StreamCluster) { viewModelScope.launch(Dispatchers.IO) { - supervisorScope { - try { - if (streamCluster.hasNext()) { - val newCluster = streamHelper.getNextStreamCluster( - streamCluster.id, - streamCluster.clusterNextPageUrl - ) - updateCluster(newCluster) - devStream = devStream.copy(streamBundle = streamBundle) - liveData.postValue(ViewState.Success(devStream)) - } else { - Log.i(TAG, "End of cluster") - } - } catch (e: Exception) { - liveData.postValue(ViewState.Error(e.message)) + try { + if (streamCluster.hasNext()) { + val newCluster = streamHelper.getNextStreamCluster( + streamCluster.id, + streamCluster.clusterNextPageUrl + ) + updateCluster(newCluster) + devStream = devStream.copy(streamBundle = streamBundle) + liveData.postValue(ViewState.Success(devStream)) + } else { + Log.i(TAG, "End of cluster") } + } catch (e: Exception) { + liveData.postValue(ViewState.Error(e.message)) } } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt index 54181181b..aaf1226b2 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/ReviewViewModel.kt @@ -71,9 +71,6 @@ class ReviewViewModel @AssistedInject constructor( Log.w(TAG, "Reviews fetch returned ${exception.code}, redirecting to Splash") AuroraApp.events.send(AuthEvent.SessionExpired(packageName)) emptyList() - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch reviews for $page: $reviewsNextPageUrl", exception) - emptyList() } PageResult(items) }.flow.distinctUntilChanged() 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 5ecc2dbf0..6472c6d9c 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 @@ -113,9 +113,6 @@ class SearchViewModel @Inject constructor( Log.w(TAG, "Search returned ${exception.code}, redirecting to Splash") AuroraApp.events.send(AuthEvent.SessionExpired()) emptyList() - } catch (exception: Exception) { - Log.e(TAG, "Failed to search results for $query", exception) - emptyList() } PageResult(items) }.flow.distinctUntilChanged() 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 8c0abfb58..fe6091a1a 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 @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope @HiltViewModel(assistedFactory = CategoryStreamViewModel.Factory::class) class CategoryStreamViewModel @AssistedInject constructor( @@ -47,66 +46,62 @@ class CategoryStreamViewModel @AssistedInject constructor( private var streamBundle = StreamBundle.EMPTY init { - fetchNextPage() + fetch() } - fun fetchNextPage() { + fun fetch() { viewModelScope.launch(Dispatchers.IO) { - supervisorScope { - 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 - ) - } - - streamBundle = streamBundle.copy( - streamClusters = streamBundle.streamClusters + newBundle.streamClusters, - streamNextPageUrl = newBundle.streamNextPageUrl - ) - - _viewState.value = ViewState.Success(streamBundle) + try { + if (!streamBundle.hasCluster() || streamBundle.hasNext()) { + val newBundle = if (streamBundle.streamClusters.isEmpty()) { + categoryStreamContract.fetch(browseUrl) } else { - Log.i(TAG, "End of Bundle") + categoryStreamContract.nextStreamBundle( + streamBundle.id, + StreamContract.Category.NONE, + streamBundle.streamNextPageUrl + ) } - } catch (e: Exception) { - _viewState.value = ViewState.Error(e.message) + + streamBundle = streamBundle.copy( + streamClusters = streamBundle.streamClusters + newBundle.streamClusters, + streamNextPageUrl = newBundle.streamNextPageUrl + ) + + _viewState.value = ViewState.Success(streamBundle) + } else { + Log.i(TAG, "End of Bundle") } + } catch (e: Exception) { + _viewState.value = ViewState.Error(e.message) } } } fun fetchNextCluster(streamCluster: StreamCluster) { viewModelScope.launch(Dispatchers.IO) { - supervisorScope { - try { - if (streamCluster.hasNext()) { - val newCluster = categoryStreamContract.nextStreamCluster( - streamCluster.id, - streamCluster.clusterNextPageUrl - ) - val mergedCluster = streamCluster.copy( - clusterNextPageUrl = newCluster.clusterNextPageUrl, - clusterAppList = - streamCluster.clusterAppList + newCluster.clusterAppList - ) + try { + if (streamCluster.hasNext()) { + val newCluster = categoryStreamContract.nextStreamCluster( + streamCluster.id, + streamCluster.clusterNextPageUrl + ) + val mergedCluster = streamCluster.copy( + clusterNextPageUrl = newCluster.clusterNextPageUrl, + clusterAppList = + streamCluster.clusterAppList + newCluster.clusterAppList + ) - val newClusters = streamBundle.streamClusters.toMutableMap().apply { - this[streamCluster.id] = mergedCluster - } - streamBundle = streamBundle.copy(streamClusters = newClusters) - _viewState.value = ViewState.Success(streamBundle) - } else { - Log.i(TAG, "End of cluster") + val newClusters = streamBundle.streamClusters.toMutableMap().apply { + this[streamCluster.id] = mergedCluster } - } catch (e: Exception) { - Log.e(TAG, "Failed to fetch next cluster", e) + streamBundle = streamBundle.copy(streamClusters = newClusters) + _viewState.value = ViewState.Success(streamBundle) + } else { + Log.i(TAG, "End of cluster") } + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch next cluster", e) } } } 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 8864fc349..60721e093 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 @@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope @HiltViewModel class TopChartViewModel @Inject constructor( @@ -69,21 +68,19 @@ class TopChartViewModel @Inject constructor( fun nextCluster(type: TopChartsContract.Type, chart: TopChartsContract.Chart) { viewModelScope.launch(Dispatchers.IO) { - supervisorScope { - try { - val target = targetCluster(type, chart) - if (target.hasNext()) { - val newCluster = topChartsContract.getNextStreamCluster( - target.id, - target.clusterNextPageUrl - ) + try { + val target = targetCluster(type, chart) + if (target.hasNext()) { + val newCluster = topChartsContract.getNextStreamCluster( + target.id, + target.clusterNextPageUrl + ) - updateCluster(type, chart, newCluster) + updateCluster(type, chart, newCluster) - _state.value = ViewState.Success(targetCluster(type, chart)) - } - } catch (_: Exception) { + _state.value = ViewState.Success(targetCluster(type, chart)) } + } catch (_: Exception) { } } }