Merge branch 'dev' into 'master'

dev

Closes #1382 and #1153

See merge request AuroraOSS/AuroraStore!572
This commit is contained in:
Rahul Patel
2026-05-29 01:45:58 +05:30
37 changed files with 367 additions and 302 deletions

View File

@@ -89,6 +89,6 @@ class ComposeActivity : ComponentActivity() {
private fun defaultStart(): Screen = when { private fun defaultStart(): Screen = when {
!Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding
else -> Screen.Splash else -> Screen.Splash()
} }
} }

View File

@@ -6,12 +6,16 @@
package com.aurora.store.compose.composable package com.aurora.store.compose.composable
import android.text.format.DateUtils 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.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale 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.R
import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.theme.colorGreen
import com.aurora.store.compose.theme.colorRed
import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.util.PackageUtil
@Composable @Composable
fun FavouriteListItem( fun FavouriteListItem(
@@ -39,6 +46,9 @@ fun FavouriteListItem(
onClear: () -> Unit = {} onClear: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val isInstalled = remember(favourite.packageName) {
PackageUtil.isInstalled(context, favourite.packageName)
}
RemovableListItem(onRemove = onClear) { triggerRemove -> RemovableListItem(onRemove = onClear) { triggerRemove ->
AuroraListItem( AuroraListItem(
modifier = modifier, modifier = modifier,
@@ -65,11 +75,26 @@ fun FavouriteListItem(
) )
}, },
trailing = { trailing = {
IconButton(onClick = triggerRemove) { Row(
Icon( verticalAlignment = Alignment.CenterVertically,
painter = painterResource(R.drawable.ic_favorite_checked), horizontalArrangement = Arrangement.spacedBy(
contentDescription = stringResource(R.string.action_favourite) 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
)
}
} }
} }
) )

View File

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

View File

@@ -15,7 +15,7 @@ import com.aurora.store.data.room.update.Update
* Screens emit one of these via a single `onNavigateTo: (Destination) -> Unit` callback. * Screens emit one of these via a single `onNavigateTo: (Destination) -> Unit` callback.
*/ */
sealed class Destination { sealed class Destination {
data object Splash : Destination() data class Splash(val packageName: String? = null) : Destination()
data class Main(val initialTab: Int) : Destination() data class Main(val initialTab: Int) : Destination()
data class AppDetails(val packageName: String) : Destination() data class AppDetails(val packageName: String) : Destination()

View File

@@ -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.search.SearchScreen
import com.aurora.store.compose.ui.splash.SplashScreen import com.aurora.store.compose.ui.splash.SplashScreen
import com.aurora.store.compose.ui.spoof.SpoofScreen 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.event.InstallerEvent
import com.aurora.store.data.model.AccountType import com.aurora.store.data.model.AccountType
import com.aurora.store.data.providers.AccountProvider 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) { fun navigate(destination: Destination) {
when (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). // 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.clear()
backstack.add(Screen.Splash) backstack.add(Screen.Splash(destination.packageName))
} }
is Destination.Main -> { is Destination.Main -> {
@@ -249,7 +262,12 @@ fun NavDisplay(startDestination: NavKey) {
} }
) { SearchScreen() } ) { SearchScreen() }
entry<Screen.Splash> { SplashScreen(onNavigateTo = ::navigate) } entry<Screen.Splash> { screen ->
SplashScreen(
deepLinkPackageName = screen.packageName,
onNavigateTo = ::navigate
)
}
entry<Screen.Onboarding> { OnboardingScreen() } entry<Screen.Onboarding> { OnboardingScreen() }
entry<Screen.Blacklist> { BlacklistScreen() } entry<Screen.Blacklist> { BlacklistScreen() }
entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) } entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) }

View File

@@ -96,7 +96,7 @@ sealed class Screen : NavKey, Parcelable {
data object SourceFilters : Screen() data object SourceFilters : Screen()
@Serializable @Serializable
data object Splash : Screen() data class Splash(val packageName: String? = null) : Screen()
@Serializable @Serializable
data class Main(val initialTab: Int = 0) : Screen() data class Main(val initialTab: Int = 0) : Screen()

View File

@@ -81,7 +81,7 @@ fun AccountsScreen(
onConfirm = { onConfirm = {
shouldShowLogoutDialog = false shouldShowLogoutDialog = false
AccountProvider.logout(context) AccountProvider.logout(context)
onNavigateTo(Destination.Splash) onNavigateTo(Destination.Splash())
}, },
onDismiss = { shouldShowLogoutDialog = false } onDismiss = { shouldShowLogoutDialog = false }
) )

View File

@@ -27,20 +27,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.gplayapi.helpers.AuthHelper import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.model.AccountType import com.aurora.store.data.model.AuthState
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.util.AC2DMUtil import com.aurora.store.util.AC2DMUtil
import com.aurora.store.util.Preferences
import com.aurora.store.viewmodel.auth.AuthViewModel import com.aurora.store.viewmodel.auth.AuthViewModel
private const val EMBEDDED_SETUP_URL = "https://accounts.google.com/EmbeddedSetup" private const val EMBEDDED_SETUP_URL = "https://accounts.google.com/EmbeddedSetup"
private const val AUTH_TOKEN = "oauth_token" 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.
// <div data-profile-identifier data-email="user@gmail.com">...</div>;
// 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 @Composable
fun GoogleLoginScreen( fun GoogleLoginScreen(
@@ -48,6 +57,7 @@ fun GoogleLoginScreen(
viewModel: AuthViewModel = hiltViewModel() viewModel: AuthViewModel = hiltViewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val authState by viewModel.authState.collectAsStateWithLifecycle()
var progress by remember { mutableFloatStateOf(0f) } var progress by remember { mutableFloatStateOf(0f) }
var isIndeterminate by remember { mutableStateOf(true) } var isIndeterminate by remember { mutableStateOf(true) }
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
@@ -56,22 +66,32 @@ fun GoogleLoginScreen(
AuroraApp.events.authEvent.collect { event -> AuroraApp.events.authEvent.collect { event ->
if (event is AuthEvent.GoogleLogin) { if (event is AuthEvent.GoogleLogin) {
if (event.success) { if (event.success) {
AccountProvider.login( viewModel.buildGoogleAuthData(
context,
event.email, event.email,
event.token, event.token,
AuthHelper.Token.AAS, AuthHelper.Token.AAS
AccountType.GOOGLE
) )
} else { } else {
Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG) Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG)
.show() .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()) { Column(modifier = Modifier.fillMaxSize()) {
if (isLoading) { if (isLoading) {
if (isIndeterminate) { if (isIndeterminate) {
@@ -108,16 +128,12 @@ fun GoogleLoginScreen(
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) { override fun onPageFinished(view: WebView, url: String) {
val cookies = CookieManager.getInstance().getCookie(url) val cookies = CookieManager.getInstance().getCookie(url) ?: return
if (cookies != null) { val cookieMap = AC2DMUtil.parseCookieString(cookies)
val cookieMap = AC2DMUtil.parseCookieString(cookies) val oauthToken = cookieMap[AUTH_TOKEN] ?: return
if (cookieMap.isNotEmpty() && cookieMap[AUTH_TOKEN] != null) { view.evaluateJavascript(JS_PROFILE_EMAIL) { result ->
val oauthToken = cookieMap[AUTH_TOKEN] val email = result.trim('"')
view.evaluateJavascript(JS_PROFILE_EMAIL) { result -> viewModel.buildAuthData(view.context, email, oauthToken)
val email = result.replace("\"", "")
viewModel.buildAuthData(view.context, email, oauthToken)
}
}
} }
} }
} }

View File

@@ -12,11 +12,15 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier 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.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.Category
import com.aurora.store.CategoryStash import com.aurora.store.CategoryStash
import com.aurora.store.R
import com.aurora.store.compose.composable.CategoryItem 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.composable.ShimmerCategoryRow
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
@@ -35,6 +39,17 @@ internal fun CategoriesContent(
viewModel.getCategoryList(categoryType) 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") @Suppress("UNCHECKED_CAST")
val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash
val list = categories?.get(categoryType) val list = categories?.get(categoryType)

View File

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

View File

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

View File

@@ -13,12 +13,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.Placeholder
import com.aurora.store.compose.composable.StreamCarousel import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.composition.collectForced
import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
import com.aurora.store.viewmodel.subcategory.CategoryStreamViewModel import com.aurora.store.viewmodel.subcategory.CategoryStreamViewModel
@@ -34,7 +34,7 @@ fun CategoryBrowseScreen(
} }
) )
) { ) {
val uiState by viewModel.viewState.collectForced(ViewState.Loading) val uiState by viewModel.viewState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { TopAppBar(title = title) } topBar = { TopAppBar(title = title) }
@@ -42,8 +42,10 @@ fun CategoryBrowseScreen(
if (uiState is ViewState.Error) { if (uiState is ViewState.Error) {
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_refresh),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { viewModel.fetch() }
) )
} else { } else {
val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle
@@ -58,7 +60,7 @@ fun CategoryBrowseScreen(
}, },
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
onClusterScrolled = { viewModel.fetchNextCluster(it) }, onClusterScrolled = { viewModel.fetchNextCluster(it) },
onScrolledToEnd = { viewModel.fetchNextPage() } onScrolledToEnd = { viewModel.fetch() }
) )
} }
} }

View File

@@ -56,8 +56,10 @@ fun ExpandedStreamBrowseScreen(
is LoadState.Error -> { is LoadState.Error -> {
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_refresh),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { apps.retry() }
) )
} }

View File

@@ -75,8 +75,10 @@ private fun ScreenContent(
is LoadState.Error -> { is LoadState.Error -> {
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_refresh),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { apps.retry() }
) )
} }

View File

@@ -72,7 +72,6 @@ import com.aurora.store.compose.composable.SectionHeader
import com.aurora.store.compose.composable.ShimmerCarouselSection import com.aurora.store.compose.composable.ShimmerCarouselSection
import com.aurora.store.compose.composable.StreamCarousel import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.composition.collectForced
import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.AppPreviewProvider
@@ -126,7 +125,7 @@ fun AppDetailsScreen(
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle() val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
val installError by viewModel.installError.collectAsStateWithLifecycle() val installError by viewModel.installError.collectAsStateWithLifecycle()
val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null) val suggestionsBundle by viewModel.suggestionsBundle.collectAsStateWithLifecycle()
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) } LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
@@ -148,14 +147,15 @@ fun AppDetailsScreen(
label = "AppDetailsState" label = "AppDetailsState"
) { currentState -> ) { currentState ->
when { when {
currentState is AppState.Loading || app == null ->
ScreenContentLoading()
currentState is AppState.Error -> currentState is AppState.Error ->
ScreenContentError( ScreenContentError(
message = currentState.message message = currentState.message,
onRetry = { viewModel.fetchAppDetails(packageName) }
) )
currentState is AppState.Loading || app == null ->
ScreenContentLoading()
else -> { else -> {
val loadedApp = app!! val loadedApp = app!!
ScreenContentApp( ScreenContentApp(
@@ -199,8 +199,8 @@ fun AppDetailsScreen(
} }
private fun stateKey(state: AppState, app: App?): String = when { private fun stateKey(state: AppState, app: App?): String = when {
state is AppState.Loading || app == null -> "loading"
state is AppState.Error -> "error" state is AppState.Error -> "error"
state is AppState.Loading || app == null -> "loading"
else -> "content" else -> "content"
} }
@@ -220,14 +220,16 @@ private fun ScreenContentLoading() {
* Composable to display errors related to fetching app details * Composable to display errors related to fetching app details
*/ */
@Composable @Composable
private fun ScreenContentError(message: String? = null) { private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? = null) {
Scaffold( Scaffold(
topBar = { TopAppBar() } topBar = { TopAppBar() }
) { paddingValues -> ) { paddingValues ->
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), 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) 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, app = app,
isAnonymous = false, isAnonymous = false,
suggestionsBundle = StreamBundle( suggestionsBundle = StreamBundle(
id = 1,
streamClusters = mapOf( streamClusters = mapOf(
1 to StreamCluster( 1 to StreamCluster(
id = 1, id = 1,

View File

@@ -116,8 +116,10 @@ private fun ScreenContent(
is LoadState.Error -> { is LoadState.Error -> {
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_refresh),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { reviews.retry() }
) )
} }

View File

@@ -98,8 +98,10 @@ private fun ScreenContent(
is LoadState.Error -> { is LoadState.Error -> {
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_refresh),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { apps.retry() }
) )
} }

View File

@@ -252,8 +252,10 @@ private fun ScreenContent(
is LoadState.Error -> { is LoadState.Error -> {
Placeholder( Placeholder(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer), painter = painterResource(R.drawable.ic_refresh),
message = stringResource(R.string.error) message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = { results.retry() }
) )
} }

View File

@@ -96,7 +96,7 @@ private fun ScreenContent(
when (result) { when (result) {
SnackbarResult.ActionPerformed -> { SnackbarResult.ActionPerformed -> {
AccountProvider.logout(context) AccountProvider.logout(context)
onNavigateTo(Destination.Splash) onNavigateTo(Destination.Splash())
} }
else -> Unit else -> Unit

View File

@@ -30,6 +30,7 @@ sealed class BusEvent : Event() {
sealed class AuthEvent : Event() { sealed class AuthEvent : Event() {
data class GoogleLogin(val success: Boolean, val email: String, val token: String) : AuthEvent() 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() { open class InstallerEvent(open val packageName: String) : Event() {

View File

@@ -20,6 +20,7 @@
package com.aurora.store.data.installer package com.aurora.store.data.installer
import android.content.Context import android.content.Context
import android.os.Process
import android.util.Log import android.util.Log
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
@@ -90,8 +91,9 @@ class RootInstaller @Inject constructor(
totalSize += file.length().toInt() totalSize += file.length().toInt()
} }
val userId = Process.myUid() / 100_000
val result: Shell.Result = 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() .exec()
val response = result.out val response = result.out

View File

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

View File

@@ -27,11 +27,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
class AC2DMTask @Inject constructor(private val httpClient: HttpClient) { class AC2DMTask @Inject constructor(private val httpClient: HttpClient) {
@Throws(Exception::class) @Throws(Exception::class)
fun getAC2DMResponse(email: String?, oAuthToken: String?): Map<String, String> { fun getAC2DMResponse(email: String, oAuthToken: String): Map<String, String> {
if (email == null || oAuthToken == null) {
return mapOf()
}
val params: MutableMap<String, Any> = hashMapOf() val params: MutableMap<String, Any> = hashMapOf()
params["lang"] = Locale.getDefault().toString().replace("_", "-") params["lang"] = Locale.getDefault().toString().replace("_", "-")
params["google_play_services_version"] = PLAY_SERVICES_VERSION_CODE 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) { return if (response.isSuccessful) {
AC2DMUtil.parseResponse(String(response.responseBytes)) AC2DMUtil.parseResponse(String(response.responseBytes))
} else { } else {
mapOf() emptyMap()
} }
} }

View File

@@ -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) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val response = aC2DMTask.getAC2DMResponse(email, oauthToken) val response = aC2DMTask.getAC2DMResponse(email, oauthToken)
if (response.isNotEmpty()) { if (response.isNotEmpty()) {
val aasToken = response["Token"] val aasToken = response["Token"]
if (aasToken != null) { 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) 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 { } else {
Preferences.putString(context, Constants.ACCOUNT_EMAIL_PLAIN, "") Preferences.putString(context, Constants.ACCOUNT_EMAIL_PLAIN, "")
Preferences.putString(context, Constants.ACCOUNT_AAS_PLAIN, "") Preferences.putString(context, Constants.ACCOUNT_AAS_PLAIN, "")

View File

@@ -13,8 +13,11 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.ExpandedBrowseHelper import com.aurora.gplayapi.helpers.ExpandedBrowseHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.PageResult 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.paging.GenericPagingSource.Companion.manualPager
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@@ -58,7 +61,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl) val browseResponse = streamHelper.getBrowseStreamResponse(browseUrl)
if (browseResponse.hasBrowseTab()) { if (browseResponse.hasBrowseTab()) {
listUrl = browseResponse.browseTab.listUrl listUrl = browseResponse.browseTab.listUrl
val cluster = streamHelper.getExpandedBrowseClusters(listUrl) val cluster = streamHelper.getExpandedBrowseClusters(
id = listUrl.hashCode(),
listUrl
)
_title.value = cluster.clusterTitle _title.value = cluster.clusterTitle
nextPageUrl = cluster.clusterNextPageUrl nextPageUrl = cluster.clusterNextPageUrl
cluster.clusterAppList cluster.clusterAppList
@@ -69,7 +75,10 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
else -> { else -> {
if (nextPageUrl.isNotBlank()) { if (nextPageUrl.isNotBlank()) {
val cluster = streamHelper.getExpandedBrowseClusters(nextPageUrl) val cluster = streamHelper.getExpandedBrowseClusters(
nextPageUrl.hashCode(),
nextPageUrl
)
nextPageUrl = cluster.clusterNextPageUrl nextPageUrl = cluster.clusterNextPageUrl
cluster.clusterAppList cluster.clusterAppList
} else { } else {
@@ -77,8 +86,9 @@ class ExpandedStreamBrowseViewModel @AssistedInject constructor(
} }
} }
} }
} catch (exception: Exception) { } catch (exception: GooglePlayException.AuthException) {
Log.e(TAG, "Failed to fetch apps for page $page", exception) Log.w(TAG, "Expanded stream returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
emptyList() emptyList()
} }
PageResult(items) PageResult(items)

View File

@@ -27,8 +27,11 @@ import androidx.paging.cachedIn
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.web.WebStreamHelper import com.aurora.gplayapi.helpers.web.WebStreamHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.PageResult 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.paging.GenericPagingSource.Companion.manualPager
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@@ -55,10 +58,10 @@ class StreamBrowseViewModel @AssistedInject constructor(
val apps = _apps.asStateFlow() val apps = _apps.asStateFlow()
init { init {
fetchApps() fetch()
} }
private fun fetchApps() { fun fetch() {
var nextPageUrl: String = streamCluster.clusterNextPageUrl var nextPageUrl: String = streamCluster.clusterNextPageUrl
manualPager { page -> manualPager { page ->
@@ -68,7 +71,10 @@ class StreamBrowseViewModel @AssistedInject constructor(
else -> { else -> {
if (nextPageUrl.isNotBlank()) { if (nextPageUrl.isNotBlank()) {
streamHelper.nextStreamCluster(nextPageUrl).also { streamHelper.nextStreamCluster(
nextPageUrl.hashCode(),
nextPageUrl
).also {
nextPageUrl = it.clusterNextPageUrl nextPageUrl = it.clusterNextPageUrl
}.clusterAppList }.clusterAppList
} else { } else {
@@ -76,8 +82,9 @@ class StreamBrowseViewModel @AssistedInject constructor(
} }
} }
} }
} catch (exception: Exception) { } catch (exception: GooglePlayException.AuthException) {
Log.e(TAG, "Failed to fetch apps for $page: $nextPageUrl", exception) Log.w(TAG, "Stream returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
emptyList() emptyList()
} }
PageResult(items) PageResult(items)

View File

@@ -25,9 +25,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.Category
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.CategoryHelper import com.aurora.gplayapi.helpers.CategoryHelper
import com.aurora.gplayapi.helpers.contracts.CategoryContract import com.aurora.gplayapi.helpers.contracts.CategoryContract
import com.aurora.store.AuroraApp
import com.aurora.store.CategoryStash import com.aurora.store.CategoryStash
import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.model.ViewState import com.aurora.store.data.model.ViewState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@@ -57,11 +60,17 @@ class CategoryViewModel @Inject constructor(
return@launch return@launch
} }
liveData.postValue(ViewState.Loading)
try { try {
stash[type] = contract().getAllCategories(type) stash[type] = contract().getAllCategories(type)
liveData.postValue(ViewState.Success(stash)) 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) { } catch (exception: Exception) {
Log.e(TAG, "Failed fetching list of categories", exception) Log.e(TAG, "Failed fetching list of categories", exception)
liveData.postValue(ViewState.Error(exception.message))
} }
} }
} }

View File

@@ -20,12 +20,14 @@ import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
import com.aurora.gplayapi.data.models.details.TestingProgramStatus import com.aurora.gplayapi.data.models.details.TestingProgramStatus
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
import com.aurora.gplayapi.network.IHttpClient import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.BuildConfig import com.aurora.store.BuildConfig
import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.model.AppState import com.aurora.store.data.model.AppState
@@ -46,12 +48,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@@ -82,13 +81,10 @@ class AppDetailsViewModel @Inject constructor(
private val _state = MutableStateFlow<AppState>(AppState.Loading) private val _state = MutableStateFlow<AppState>(AppState.Loading)
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _suggestionsBundle = MutableSharedFlow<StreamBundle?>( private val _suggestionsBundle = MutableStateFlow<StreamBundle?>(null)
replay = 1, val suggestionsBundle: StateFlow<StreamBundle?> = _suggestionsBundle.asStateFlow()
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val suggestionsBundle: SharedFlow<StreamBundle?> = _suggestionsBundle.asSharedFlow()
private var suggestionsState: StreamBundle = StreamBundle() private var suggestionsState: StreamBundle = StreamBundle.EMPTY
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList()) private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
val featuredReviews = _featuredReviews.asStateFlow() val featuredReviews = _featuredReviews.asStateFlow()
@@ -187,6 +183,12 @@ class AppDetailsViewModel @Inject constructor(
_state.value = _state.value =
existingDownload?.let { stateFromDownload(it) } ?: defaultAppState 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) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app details", exception) Log.e(TAG, "Failed to fetch app details", exception)
_app.value = null _app.value = null
@@ -345,21 +347,21 @@ class AppDetailsViewModel @Inject constructor(
// Bail out if we got no suggestions to offer // Bail out if we got no suggestions to offer
if (streamUrl.isNullOrBlank()) { if (streamUrl.isNullOrBlank()) {
_suggestionsBundle.tryEmit(StreamBundle.EMPTY) _suggestionsBundle.value = StreamBundle.EMPTY
return return
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val pageBundle = appDetailsHelper.getDetailsStream(streamUrl) val pageBundle = appDetailsHelper.getDetailsStream(streamUrl.hashCode(), streamUrl)
val pageClusters = pageBundle.streamClusters.filterValues { val pageClusters = pageBundle.streamClusters.filterValues {
it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty() it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty()
} }
suggestionsState = pageBundle.copy(streamClusters = pageClusters) suggestionsState = pageBundle.copy(streamClusters = pageClusters)
_suggestionsBundle.tryEmit(suggestionsState) _suggestionsBundle.value = suggestionsState
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch suggestions stream", exception) Log.e(TAG, "Failed to fetch suggestions stream", exception)
_suggestionsBundle.tryEmit(StreamBundle.EMPTY) _suggestionsBundle.value = StreamBundle.EMPTY
} }
} }
} }
@@ -369,7 +371,10 @@ class AppDetailsViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val nextPage = appDetailsHelper.getNextStreamCluster(cluster.clusterNextPageUrl) val nextPage = appDetailsHelper.getNextStreamCluster(
cluster.id,
cluster.clusterNextPageUrl
)
val existing = suggestionsState.streamClusters[cluster.id] ?: return@launch val existing = suggestionsState.streamClusters[cluster.id] ?: return@launch
val mergedCluster = existing.copy( val mergedCluster = existing.copy(
clusterAppList = existing.clusterAppList + nextPage.clusterAppList, clusterAppList = existing.clusterAppList + nextPage.clusterAppList,
@@ -378,7 +383,7 @@ class AppDetailsViewModel @Inject constructor(
suggestionsState = suggestionsState.copy( suggestionsState = suggestionsState.copy(
streamClusters = suggestionsState.streamClusters + (cluster.id to mergedCluster) streamClusters = suggestionsState.streamClusters + (cluster.id to mergedCluster)
) )
_suggestionsBundle.tryEmit(suggestionsState) _suggestionsBundle.value = suggestionsState
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch next cluster page", exception) Log.e(TAG, "Failed to fetch next cluster page", exception)
} }

View File

@@ -27,15 +27,17 @@ import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.data.models.details.DevStream 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.AppDetailsHelper
import com.aurora.gplayapi.helpers.StreamHelper import com.aurora.gplayapi.helpers.StreamHelper
import com.aurora.gplayapi.helpers.contracts.StreamContract 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.model.ViewState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@HiltViewModel @HiltViewModel
class DevProfileViewModel @Inject constructor( class DevProfileViewModel @Inject constructor(
@@ -44,43 +46,43 @@ class DevProfileViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
val liveData: MutableLiveData<ViewState> = MutableLiveData() val liveData: MutableLiveData<ViewState> = MutableLiveData()
var devStream: DevStream = DevStream() var devStream: DevStream = DevStream.EMPTY
var streamBundle: StreamBundle = StreamBundle() var streamBundle: StreamBundle = StreamBundle.EMPTY
lateinit var type: StreamContract.Type lateinit var type: StreamContract.Type
lateinit var category: StreamContract.Category lateinit var category: StreamContract.Category
fun getStreamBundle(devId: String) { fun getStreamBundle(devId: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
supervisorScope { try {
try { devStream = appDetailsHelper.getDeveloperStream(devId)
devStream = appDetailsHelper.getDeveloperStream(devId) streamBundle = devStream.streamBundle
streamBundle = devStream.streamBundle liveData.postValue(ViewState.Success(devStream))
liveData.postValue(ViewState.Success(devStream)) } catch (e: GooglePlayException.AuthException) {
} catch (e: Exception) { Log.w(TAG, "Developer stream fetch returned ${e.code}, redirecting to Splash")
liveData.postValue(ViewState.Error(e.message)) AuroraApp.events.send(AuthEvent.SessionExpired())
} } catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
} }
} }
} }
fun observeCluster(streamCluster: StreamCluster) { fun observeCluster(streamCluster: StreamCluster) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
supervisorScope { try {
try { if (streamCluster.hasNext()) {
if (streamCluster.hasNext()) { val newCluster = streamHelper.getNextStreamCluster(
val newCluster = streamHelper.getNextStreamCluster( streamCluster.id,
streamCluster.clusterNextPageUrl streamCluster.clusterNextPageUrl
) )
updateCluster(newCluster) updateCluster(newCluster)
devStream = devStream.copy(streamBundle = streamBundle) devStream = devStream.copy(streamBundle = streamBundle)
liveData.postValue(ViewState.Success(devStream)) liveData.postValue(ViewState.Success(devStream))
} else { } else {
Log.i(TAG, "End of cluster") Log.i(TAG, "End of cluster")
}
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
} }
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
} }
} }
} }

View File

@@ -11,7 +11,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.AppDetailsHelper 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.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -43,6 +46,9 @@ class MoreViewModel @AssistedInject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
_dependentApps.value = appDetailsHelper.getAppByPackageName(dependencies) _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) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch dependencies", exception) Log.e(TAG, "Failed to fetch dependencies", exception)
_dependentApps.value = null _dependentApps.value = null

View File

@@ -13,8 +13,11 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.Review import com.aurora.gplayapi.data.models.Review
import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.PageResult 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.paging.GenericPagingSource.Companion.manualPager
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@@ -64,8 +67,9 @@ class ReviewViewModel @AssistedInject constructor(
} }
} }
} }
} catch (exception: Exception) { } catch (exception: GooglePlayException.AuthException) {
Log.e(TAG, "Failed to fetch reviews for $page: $reviewsNextPageUrl", exception) Log.w(TAG, "Reviews fetch returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired(packageName))
emptyList() emptyList()
} }
PageResult(items) PageResult(items)

View File

@@ -72,6 +72,7 @@ class StreamViewModel @Inject constructor(
// Fetch new stream bundle // Fetch new stream bundle
val newBundle = if (bundle.hasCluster()) { val newBundle = if (bundle.hasCluster()) {
streamContract.nextStreamBundle( streamContract.nextStreamBundle(
bundle.id,
category, category,
bundle.streamNextPageUrl bundle.streamNextPageUrl
) )
@@ -79,7 +80,6 @@ class StreamViewModel @Inject constructor(
streamContract.fetch(type, category) streamContract.fetch(type, category)
} }
// Update old bundle
val mergedBundle = bundle.copy( val mergedBundle = bundle.copy(
streamClusters = bundle.streamClusters + newBundle.streamClusters, streamClusters = bundle.streamClusters + newBundle.streamClusters,
streamNextPageUrl = newBundle.streamNextPageUrl streamNextPageUrl = newBundle.streamNextPageUrl
@@ -103,6 +103,7 @@ class StreamViewModel @Inject constructor(
try { try {
if (streamCluster.hasNext()) { if (streamCluster.hasNext()) {
val newCluster = streamContract.nextStreamCluster( val newCluster = streamContract.nextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl streamCluster.clusterNextPageUrl
) )
@@ -158,6 +159,6 @@ class StreamViewModel @Inject constructor(
private fun targetBundle(category: StreamContract.Category): StreamBundle = private fun targetBundle(category: StreamContract.Category): StreamBundle =
stash.getOrPut(category) { stash.getOrPut(category) {
StreamBundle() StreamBundle(id = category.value.hashCode())
} }
} }

View File

@@ -18,10 +18,12 @@ import com.aurora.extensions.requiresGMS
import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.SearchSuggestEntry
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.SearchHelper import com.aurora.gplayapi.exceptions.GooglePlayException
import com.aurora.gplayapi.helpers.contracts.SearchContract import com.aurora.gplayapi.helpers.contracts.SearchContract
import com.aurora.gplayapi.helpers.web.WebSearchHelper import com.aurora.gplayapi.helpers.web.WebSearchHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.PageResult 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.model.SearchFilter
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.AuthProvider
@@ -41,12 +43,11 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
val authProvider: AuthProvider, val authProvider: AuthProvider,
private val searchHelper: SearchHelper,
private val webSearchHelper: WebSearchHelper private val webSearchHelper: WebSearchHelper
) : ViewModel() { ) : ViewModel() {
private val contract: SearchContract private val contract: SearchContract
get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper get() = webSearchHelper
private val _suggestions = MutableStateFlow<List<SearchSuggestEntry>>(emptyList()) private val _suggestions = MutableStateFlow<List<SearchSuggestEntry>>(emptyList())
val suggestions = _suggestions.asStateFlow() val suggestions = _suggestions.asStateFlow()
@@ -108,8 +109,9 @@ class SearchViewModel @Inject constructor(
} }
} }
} }
} catch (exception: Exception) { } catch (exception: GooglePlayException.AuthException) {
Log.e(TAG, "Failed to search results for $query", exception) Log.w(TAG, "Search returned ${exception.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
emptyList() emptyList()
} }
PageResult(items) PageResult(items)
@@ -123,7 +125,7 @@ class SearchViewModel @Inject constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_suggestions.value = contract.searchSuggestions(query) _suggestions.value = contract.searchSuggestions(query)
.filter { it.title.isNotBlank() } .filter { it.title.isNotBlank() }
.take(3) .take(5)
} }
} }
} }

View File

@@ -21,12 +21,10 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@HiltViewModel(assistedFactory = CategoryStreamViewModel.Factory::class) @HiltViewModel(assistedFactory = CategoryStreamViewModel.Factory::class)
class CategoryStreamViewModel @AssistedInject constructor( class CategoryStreamViewModel @AssistedInject constructor(
@@ -39,83 +37,71 @@ class CategoryStreamViewModel @AssistedInject constructor(
fun create(browseUrl: String): CategoryStreamViewModel fun create(browseUrl: String): CategoryStreamViewModel
} }
// SharedFlow (instead of StateFlow) because StreamBundle/StreamCluster override equals private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
// to compare only id, which is preserved by copy(). StateFlow would conflate every update val viewState: StateFlow<ViewState> = _viewState.asStateFlow()
// and break paginated scroll loading.
private val _viewState = MutableSharedFlow<ViewState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val viewState: SharedFlow<ViewState> = _viewState.asSharedFlow()
private val categoryStreamContract: CategoryStreamContract private val categoryStreamContract: CategoryStreamContract
get() = webCategoryStreamHelper get() = webCategoryStreamHelper
private var streamBundle = StreamBundle() private var streamBundle = StreamBundle.EMPTY
init { init {
fetchNextPage() fetch()
} }
fun fetchNextPage() { fun fetch() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
supervisorScope { try {
if (streamBundle.streamClusters.isNotEmpty()) { if (!streamBundle.hasCluster() || streamBundle.hasNext()) {
_viewState.tryEmit(ViewState.Success(streamBundle)) val newBundle = if (streamBundle.streamClusters.isEmpty()) {
} categoryStreamContract.fetch(browseUrl)
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))
} else { } 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) { fun fetchNextCluster(streamCluster: StreamCluster) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
supervisorScope { try {
try { if (streamCluster.hasNext()) {
if (streamCluster.hasNext()) { val newCluster = categoryStreamContract.nextStreamCluster(
val newCluster = categoryStreamContract.nextStreamCluster( streamCluster.id,
streamCluster.clusterNextPageUrl streamCluster.clusterNextPageUrl
) )
val mergedCluster = streamCluster.copy( val mergedCluster = streamCluster.copy(
clusterNextPageUrl = newCluster.clusterNextPageUrl, clusterNextPageUrl = newCluster.clusterNextPageUrl,
clusterAppList = clusterAppList =
streamCluster.clusterAppList + newCluster.clusterAppList streamCluster.clusterAppList + newCluster.clusterAppList
) )
val newClusters = streamBundle.streamClusters.toMutableMap().apply { val newClusters = streamBundle.streamClusters.toMutableMap().apply {
this[streamCluster.id] = mergedCluster this[streamCluster.id] = mergedCluster
}
streamBundle = streamBundle.copy(streamClusters = newClusters)
_viewState.tryEmit(ViewState.Success(streamBundle))
} else {
Log.i(TAG, "End of cluster")
} }
} catch (e: Exception) { streamBundle = streamBundle.copy(streamClusters = newClusters)
Log.e(TAG, "Failed to fetch next cluster", e) _viewState.value = ViewState.Success(streamBundle)
} else {
Log.i(TAG, "End of cluster")
} }
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch next cluster", e)
} }
} }
} }

View File

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

View File

@@ -0,0 +1,24 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M480,800q-134,0 -227,-93t-93,-227q0,-134 93,-227t227,-93q69,0 132,28.5T720,270v-110h80v280L520,440v-80h168q-32,-56 -87.5,-88T480,240q-100,0 -170,70t-70,170q0,100 70,170t170,70q77,0 139,-44t87,-116h84q-28,106 -114,173t-196,67Z"
android:fillColor="#e3e3e3"/>
</vector>

View File

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