diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt index da6576e03..ffa594b24 100644 --- a/app/src/main/java/com/aurora/store/ComposeActivity.kt +++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt @@ -89,6 +89,6 @@ class ComposeActivity : ComponentActivity() { private fun defaultStart(): Screen = when { !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding - else -> Screen.Splash + else -> Screen.Splash() } } 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 + ) + } } } ) 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/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 fb44474c6..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 @@ -27,20 +27,29 @@ 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" 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( @@ -48,6 +57,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 +66,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) { @@ -108,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/compose/ui/apps/CategoriesPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt index c07ae64a5..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 @@ -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_refresh), + 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/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 93a1c7aa9..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 @@ -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) } @@ -42,8 +42,10 @@ fun CategoryBrowseScreen( if (uiState is ViewState.Error) { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + painter = painterResource(R.drawable.ic_refresh), + message = stringResource(R.string.error), + actionLabel = stringResource(R.string.action_retry), + onAction = { viewModel.fetch() } ) } else { val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle @@ -58,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/compose/ui/commons/ExpandedStreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt index 4d9736c0d..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,8 +56,10 @@ fun ExpandedStreamBrowseScreen( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + 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 577990f2a..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,8 +75,10 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + 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 b0bd2c83b..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) } @@ -148,14 +147,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 +199,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 +220,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) + painter = painterResource(R.drawable.ic_refresh), + message = message ?: stringResource(R.string.toast_app_unavailable), + actionLabel = onRetry?.let { stringResource(R.string.action_retry) }, + onAction = onRetry ) } } @@ -691,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/compose/ui/details/ReviewScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt index f7bf883b7..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,8 +116,10 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + 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 ef5e0fb5b..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,8 +98,10 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + 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 9f8302ef5..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,8 +252,10 @@ private fun ScreenContent( is LoadState.Error -> { Placeholder( modifier = Modifier.padding(paddingValues), - painter = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) + 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/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/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 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/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..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 @@ -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, "") 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..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 @@ -13,8 +13,11 @@ 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 dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -58,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 @@ -69,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 { @@ -77,8 +86,9 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor( } } } - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch apps for page $page", exception) + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Expanded 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/browse/StreamBrowseViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt index 347c1dbb6..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 -> @@ -68,7 +71,10 @@ class StreamBrowseViewModel @AssistedInject constructor( else -> { if (nextPageUrl.isNotBlank()) { - streamHelper.nextStreamCluster(nextPageUrl).also { + streamHelper.nextStreamCluster( + nextPageUrl.hashCode(), + nextPageUrl + ).also { nextPageUrl = it.clusterNextPageUrl }.clusterAppList } else { @@ -76,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/category/CategoryViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/category/CategoryViewModel.kt index 9d23d0b49..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,9 +25,12 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -57,11 +60,17 @@ class CategoryViewModel @Inject constructor( return@launch } + liveData.postValue(ViewState.Loading) + try { 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 e01e362ca..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 @@ -20,12 +20,14 @@ 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 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 @@ -46,12 +48,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 +81,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() @@ -187,6 +183,12 @@ class AppDetailsViewModel @Inject constructor( _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 @@ -345,21 +347,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 } } } @@ -369,7 +371,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, @@ -378,7 +383,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 bf561e26b..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 @@ -27,15 +27,17 @@ 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 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( @@ -44,43 +46,43 @@ 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 fun getStreamBundle(devId: String) { viewModelScope.launch(Dispatchers.IO) { - supervisorScope { - try { - devStream = appDetailsHelper.getDeveloperStream(devId) - streamBundle = devStream.streamBundle - liveData.postValue(ViewState.Success(devStream)) - } 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.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/MoreViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/MoreViewModel.kt index 1fc6ef1c5..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,7 +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.AuroraApp +import com.aurora.store.data.event.AuthEvent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -43,6 +46,9 @@ class MoreViewModel @AssistedInject constructor( viewModelScope.launch(Dispatchers.IO) { try { _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 f91e3e2ce..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 @@ -13,8 +13,11 @@ 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 dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -64,8 +67,9 @@ class ReviewViewModel @AssistedInject constructor( } } } - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch reviews for $page: $reviewsNextPageUrl", exception) + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Reviews fetch returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired(packageName)) emptyList() } PageResult(items) 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..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 @@ -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,7 +80,6 @@ class StreamViewModel @Inject constructor( streamContract.fetch(type, category) } - // Update old bundle val mergedBundle = bundle.copy( streamClusters = bundle.streamClusters + newBundle.streamClusters, streamNextPageUrl = newBundle.streamNextPageUrl @@ -103,6 +103,7 @@ class StreamViewModel @Inject constructor( try { if (streamCluster.hasNext()) { val newCluster = streamContract.nextStreamCluster( + streamCluster.id, streamCluster.clusterNextPageUrl ) @@ -158,6 +159,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 ead7a2ef6..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 @@ -18,10 +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.helpers.SearchHelper +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 @@ -41,12 +43,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() @@ -108,8 +109,9 @@ class SearchViewModel @Inject constructor( } } } - } catch (exception: Exception) { - Log.e(TAG, "Failed to search results for $query", exception) + } catch (exception: GooglePlayException.AuthException) { + Log.w(TAG, "Search returned ${exception.code}, redirecting to Splash") + AuroraApp.events.send(AuthEvent.SessionExpired()) emptyList() } PageResult(items) @@ -123,7 +125,7 @@ class SearchViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { _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..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 @@ -21,12 +21,10 @@ 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 @HiltViewModel(assistedFactory = CategoryStreamViewModel.Factory::class) class CategoryStreamViewModel @AssistedInject constructor( @@ -39,83 +37,71 @@ 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() + fetch() } - fun fetchNextPage() { + fun fetch() { 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( - StreamContract.Category.NONE, - streamBundle.streamNextPageUrl - ) - } - - streamBundle = streamBundle.copy( - streamClusters = streamBundle.streamClusters + newBundle.streamClusters, - streamNextPageUrl = newBundle.streamNextPageUrl - ) - - _viewState.tryEmit(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.tryEmit(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.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.tryEmit(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 256b9fc75..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 @@ -29,12 +29,10 @@ 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 @HiltViewModel class TopChartViewModel @Inject constructor( @@ -43,14 +41,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,38 +50,37 @@ 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) } } } 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.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.tryEmit(ViewState.Success(targetCluster(type, chart))) - } - } catch (_: Exception) { + _state.value = ViewState.Success(targetCluster(type, chart)) } + } catch (_: Exception) { } } } @@ -114,7 +105,7 @@ class TopChartViewModel @Inject constructor( ): StreamCluster { val cluster = stash .getOrPut(type) { mutableMapOf() } - .getOrPut(chart) { StreamCluster() } + .getOrPut(chart) { StreamCluster.EMPTY } return cluster } } 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 @@ + + + + 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"