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 {
!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
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,13 +75,28 @@ fun FavouriteListItem(
)
},
trailing = {
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)
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.
*/
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()

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.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<Screen.Splash> { SplashScreen(onNavigateTo = ::navigate) }
entry<Screen.Splash> { screen ->
SplashScreen(
deepLinkPackageName = screen.packageName,
onNavigateTo = ::navigate
)
}
entry<Screen.Onboarding> { OnboardingScreen() }
entry<Screen.Blacklist> { BlacklistScreen() }
entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) }

View File

@@ -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()

View File

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

View File

@@ -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.
// <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
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,19 +128,15 @@ fun GoogleLoginScreen(
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
val cookies = CookieManager.getInstance().getCookie(url)
if (cookies != null) {
val cookies = CookieManager.getInstance().getCookie(url) ?: return
val cookieMap = AC2DMUtil.parseCookieString(cookies)
if (cookieMap.isNotEmpty() && cookieMap[AUTH_TOKEN] != null) {
val oauthToken = cookieMap[AUTH_TOKEN]
val oauthToken = cookieMap[AUTH_TOKEN] ?: return
view.evaluateJavascript(JS_PROFILE_EMAIL) { result ->
val email = result.replace("\"", "")
val email = result.trim('"')
viewModel.buildAuthData(view.context, email, oauthToken)
}
}
}
}
}
settings.apply {
allowContentAccess = true

View File

@@ -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)

View File

@@ -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)

View File

@@ -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<StreamCluster?>()
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,

View File

@@ -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() }
)
}
}

View File

@@ -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() }
)
}

View File

@@ -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() }
)
}

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.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,

View File

@@ -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() }
)
}

View File

@@ -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() }
)
}

View File

@@ -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() }
)
}

View File

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

View File

@@ -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() {

View File

@@ -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

View File

@@ -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

View File

@@ -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<String, String> {
if (email == null || oAuthToken == null) {
return mapOf()
}
fun getAC2DMResponse(email: String, oAuthToken: String): Map<String, String> {
val params: MutableMap<String, Any> = 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()
}
}

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) {
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, "")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
}
}
}

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.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>(AppState.Loading)
val state = _state.asStateFlow()
private val _suggestionsBundle = MutableSharedFlow<StreamBundle?>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val suggestionsBundle: SharedFlow<StreamBundle?> = _suggestionsBundle.asSharedFlow()
private val _suggestionsBundle = MutableStateFlow<StreamBundle?>(null)
val suggestionsBundle: StateFlow<StreamBundle?> = _suggestionsBundle.asStateFlow()
private var suggestionsState: StreamBundle = StreamBundle()
private var suggestionsState: StreamBundle = StreamBundle.EMPTY
private val _featuredReviews = MutableStateFlow<List<Review>>(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)
}

View File

@@ -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,32 +46,33 @@ class DevProfileViewModel @Inject constructor(
) : ViewModel() {
val liveData: MutableLiveData<ViewState> = 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: GooglePlayException.AuthException) {
Log.w(TAG, "Developer stream fetch returned ${e.code}, redirecting to Splash")
AuroraApp.events.send(AuthEvent.SessionExpired())
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
}
}
}
}
fun observeCluster(streamCluster: StreamCluster) {
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
try {
if (streamCluster.hasNext()) {
val newCluster = streamHelper.getNextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl
)
updateCluster(newCluster)
@@ -83,7 +86,6 @@ class DevProfileViewModel @Inject constructor(
}
}
}
}
private fun updateCluster(newCluster: StreamCluster) {
streamBundle.streamClusters[newCluster.id]?.let { oldCluster ->

View File

@@ -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

View File

@@ -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)

View File

@@ -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())
}
}

View File

@@ -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<List<SearchSuggestEntry>>(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)
}
}
}

View File

@@ -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,37 +37,27 @@ 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<ViewState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val viewState: SharedFlow<ViewState> = _viewState.asSharedFlow()
private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
val viewState: StateFlow<ViewState> = _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(
streamBundle.id,
StreamContract.Category.NONE,
streamBundle.streamNextPageUrl
)
@@ -80,23 +68,22 @@ class CategoryStreamViewModel @AssistedInject constructor(
streamNextPageUrl = newBundle.streamNextPageUrl
)
_viewState.tryEmit(ViewState.Success(streamBundle))
_viewState.value = ViewState.Success(streamBundle)
} else {
Log.i(TAG, "End of Bundle")
}
} catch (e: Exception) {
_viewState.tryEmit(ViewState.Error(e.message))
}
_viewState.value = ViewState.Error(e.message)
}
}
}
fun fetchNextCluster(streamCluster: StreamCluster) {
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
try {
if (streamCluster.hasNext()) {
val newCluster = categoryStreamContract.nextStreamCluster(
streamCluster.id,
streamCluster.clusterNextPageUrl
)
val mergedCluster = streamCluster.copy(
@@ -109,7 +96,7 @@ class CategoryStreamViewModel @AssistedInject constructor(
this[streamCluster.id] = mergedCluster
}
streamBundle = streamBundle.copy(streamClusters = newClusters)
_viewState.tryEmit(ViewState.Success(streamBundle))
_viewState.value = ViewState.Success(streamBundle)
} else {
Log.i(TAG, "End of cluster")
}
@@ -119,4 +106,3 @@ class CategoryStreamViewModel @AssistedInject constructor(
}
}
}
}

View File

@@ -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<ViewState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val state: SharedFlow<ViewState> = _state.asSharedFlow()
private val _state = MutableStateFlow<ViewState>(ViewState.Loading)
val state: StateFlow<ViewState> = _state.asStateFlow()
private val topChartsContract: TopChartsContract
get() = webTopChartsHelper
@@ -58,41 +50,40 @@ 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.id,
target.clusterNextPageUrl
)
updateCluster(type, chart, newCluster)
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
_state.value = ViewState.Success(targetCluster(type, chart))
}
} catch (_: Exception) {
}
}
}
}
private fun updateCluster(
type: TopChartsContract.Type,
@@ -114,7 +105,7 @@ class TopChartViewModel @Inject constructor(
): StreamCluster {
val cluster = stash
.getOrPut(type) { mutableMapOf() }
.getOrPut(chart) { StreamCluster() }
.getOrPut(chart) { StreamCluster.EMPTY }
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]
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"