Merge branch 'dev' into 'master'
dev Closes #1382 and #1153 See merge request AuroraOSS/AuroraStore!572
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
package com.aurora.store.compose.composable
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -29,7 +33,10 @@ import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.compose.theme.colorGreen
|
||||
import com.aurora.store.compose.theme.colorRed
|
||||
import com.aurora.store.data.room.favourite.Favourite
|
||||
import com.aurora.store.util.PackageUtil
|
||||
|
||||
@Composable
|
||||
fun FavouriteListItem(
|
||||
@@ -39,6 +46,9 @@ fun FavouriteListItem(
|
||||
onClear: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isInstalled = remember(favourite.packageName) {
|
||||
PackageUtil.isInstalled(context, favourite.packageName)
|
||||
}
|
||||
RemovableListItem(onRemove = onClear) { triggerRemove ->
|
||||
AuroraListItem(
|
||||
modifier = modifier,
|
||||
@@ -65,11 +75,26 @@ fun FavouriteListItem(
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
IconButton(onClick = triggerRemove) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_favorite_checked),
|
||||
contentDescription = stringResource(R.string.action_favourite)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
dimensionResource(R.dimen.spacing_xsmall)
|
||||
)
|
||||
) {
|
||||
if (isInstalled) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_check),
|
||||
contentDescription = stringResource(R.string.title_installed),
|
||||
tint = colorGreen
|
||||
)
|
||||
}
|
||||
IconButton(onClick = triggerRemove) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_favorite_checked),
|
||||
contentDescription = stringResource(R.string.action_favourite),
|
||||
tint = colorRed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -81,7 +81,7 @@ fun AccountsScreen(
|
||||
onConfirm = {
|
||||
shouldShowLogoutDialog = false
|
||||
AccountProvider.logout(context)
|
||||
onNavigateTo(Destination.Splash)
|
||||
onNavigateTo(Destination.Splash())
|
||||
},
|
||||
onDismiss = { shouldShowLogoutDialog = false }
|
||||
)
|
||||
|
||||
@@ -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,16 +128,12 @@ fun GoogleLoginScreen(
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
val cookies = CookieManager.getInstance().getCookie(url)
|
||||
if (cookies != null) {
|
||||
val cookieMap = AC2DMUtil.parseCookieString(cookies)
|
||||
if (cookieMap.isNotEmpty() && cookieMap[AUTH_TOKEN] != null) {
|
||||
val oauthToken = cookieMap[AUTH_TOKEN]
|
||||
view.evaluateJavascript(JS_PROFILE_EMAIL) { result ->
|
||||
val email = result.replace("\"", "")
|
||||
viewModel.buildAuthData(view.context, email, oauthToken)
|
||||
}
|
||||
}
|
||||
val cookies = CookieManager.getInstance().getCookie(url) ?: return
|
||||
val cookieMap = AC2DMUtil.parseCookieString(cookies)
|
||||
val oauthToken = cookieMap[AUTH_TOKEN] ?: return
|
||||
view.evaluateJavascript(JS_PROFILE_EMAIL) { result ->
|
||||
val email = result.trim('"')
|
||||
viewModel.buildAuthData(view.context, email, oauthToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ private fun ScreenContent(
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> {
|
||||
AccountProvider.logout(context)
|
||||
onNavigateTo(Destination.Splash)
|
||||
onNavigateTo(Destination.Splash())
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -27,15 +27,17 @@ import com.aurora.extensions.TAG
|
||||
import com.aurora.gplayapi.data.models.StreamBundle
|
||||
import com.aurora.gplayapi.data.models.StreamCluster
|
||||
import com.aurora.gplayapi.data.models.details.DevStream
|
||||
import com.aurora.gplayapi.exceptions.GooglePlayException
|
||||
import com.aurora.gplayapi.helpers.AppDetailsHelper
|
||||
import com.aurora.gplayapi.helpers.StreamHelper
|
||||
import com.aurora.gplayapi.helpers.contracts.StreamContract
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.data.event.AuthEvent
|
||||
import com.aurora.store.data.model.ViewState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
|
||||
@HiltViewModel
|
||||
class DevProfileViewModel @Inject constructor(
|
||||
@@ -44,43 +46,43 @@ class DevProfileViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
val liveData: MutableLiveData<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: Exception) {
|
||||
liveData.postValue(ViewState.Error(e.message))
|
||||
}
|
||||
try {
|
||||
devStream = appDetailsHelper.getDeveloperStream(devId)
|
||||
streamBundle = devStream.streamBundle
|
||||
liveData.postValue(ViewState.Success(devStream))
|
||||
} catch (e: GooglePlayException.AuthException) {
|
||||
Log.w(TAG, "Developer stream fetch returned ${e.code}, redirecting to Splash")
|
||||
AuroraApp.events.send(AuthEvent.SessionExpired())
|
||||
} catch (e: Exception) {
|
||||
liveData.postValue(ViewState.Error(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun observeCluster(streamCluster: StreamCluster) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
supervisorScope {
|
||||
try {
|
||||
if (streamCluster.hasNext()) {
|
||||
val newCluster = streamHelper.getNextStreamCluster(
|
||||
streamCluster.clusterNextPageUrl
|
||||
)
|
||||
updateCluster(newCluster)
|
||||
devStream = devStream.copy(streamBundle = streamBundle)
|
||||
liveData.postValue(ViewState.Success(devStream))
|
||||
} else {
|
||||
Log.i(TAG, "End of cluster")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
liveData.postValue(ViewState.Error(e.message))
|
||||
try {
|
||||
if (streamCluster.hasNext()) {
|
||||
val newCluster = streamHelper.getNextStreamCluster(
|
||||
streamCluster.id,
|
||||
streamCluster.clusterNextPageUrl
|
||||
)
|
||||
updateCluster(newCluster)
|
||||
devStream = devStream.copy(streamBundle = streamBundle)
|
||||
liveData.postValue(ViewState.Success(devStream))
|
||||
} else {
|
||||
Log.i(TAG, "End of cluster")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
liveData.postValue(ViewState.Error(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,10 @@ import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
|
||||
@HiltViewModel(assistedFactory = CategoryStreamViewModel.Factory::class)
|
||||
class CategoryStreamViewModel @AssistedInject constructor(
|
||||
@@ -39,83 +37,71 @@ class CategoryStreamViewModel @AssistedInject constructor(
|
||||
fun create(browseUrl: String): CategoryStreamViewModel
|
||||
}
|
||||
|
||||
// SharedFlow (instead of StateFlow) because StreamBundle/StreamCluster override equals
|
||||
// to compare only id, which is preserved by copy(). StateFlow would conflate every update
|
||||
// and break paginated scroll loading.
|
||||
private val _viewState = MutableSharedFlow<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(
|
||||
StreamContract.Category.NONE,
|
||||
streamBundle.streamNextPageUrl
|
||||
)
|
||||
}
|
||||
|
||||
streamBundle = streamBundle.copy(
|
||||
streamClusters = streamBundle.streamClusters + newBundle.streamClusters,
|
||||
streamNextPageUrl = newBundle.streamNextPageUrl
|
||||
)
|
||||
|
||||
_viewState.tryEmit(ViewState.Success(streamBundle))
|
||||
try {
|
||||
if (!streamBundle.hasCluster() || streamBundle.hasNext()) {
|
||||
val newBundle = if (streamBundle.streamClusters.isEmpty()) {
|
||||
categoryStreamContract.fetch(browseUrl)
|
||||
} else {
|
||||
Log.i(TAG, "End of Bundle")
|
||||
categoryStreamContract.nextStreamBundle(
|
||||
streamBundle.id,
|
||||
StreamContract.Category.NONE,
|
||||
streamBundle.streamNextPageUrl
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_viewState.tryEmit(ViewState.Error(e.message))
|
||||
|
||||
streamBundle = streamBundle.copy(
|
||||
streamClusters = streamBundle.streamClusters + newBundle.streamClusters,
|
||||
streamNextPageUrl = newBundle.streamNextPageUrl
|
||||
)
|
||||
|
||||
_viewState.value = ViewState.Success(streamBundle)
|
||||
} else {
|
||||
Log.i(TAG, "End of Bundle")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_viewState.value = ViewState.Error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchNextCluster(streamCluster: StreamCluster) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
supervisorScope {
|
||||
try {
|
||||
if (streamCluster.hasNext()) {
|
||||
val newCluster = categoryStreamContract.nextStreamCluster(
|
||||
streamCluster.clusterNextPageUrl
|
||||
)
|
||||
val mergedCluster = streamCluster.copy(
|
||||
clusterNextPageUrl = newCluster.clusterNextPageUrl,
|
||||
clusterAppList =
|
||||
streamCluster.clusterAppList + newCluster.clusterAppList
|
||||
)
|
||||
try {
|
||||
if (streamCluster.hasNext()) {
|
||||
val newCluster = categoryStreamContract.nextStreamCluster(
|
||||
streamCluster.id,
|
||||
streamCluster.clusterNextPageUrl
|
||||
)
|
||||
val mergedCluster = streamCluster.copy(
|
||||
clusterNextPageUrl = newCluster.clusterNextPageUrl,
|
||||
clusterAppList =
|
||||
streamCluster.clusterAppList + newCluster.clusterAppList
|
||||
)
|
||||
|
||||
val newClusters = streamBundle.streamClusters.toMutableMap().apply {
|
||||
this[streamCluster.id] = mergedCluster
|
||||
}
|
||||
streamBundle = streamBundle.copy(streamClusters = newClusters)
|
||||
_viewState.tryEmit(ViewState.Success(streamBundle))
|
||||
} else {
|
||||
Log.i(TAG, "End of cluster")
|
||||
val newClusters = streamBundle.streamClusters.toMutableMap().apply {
|
||||
this[streamCluster.id] = mergedCluster
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch next cluster", e)
|
||||
streamBundle = streamBundle.copy(streamClusters = newClusters)
|
||||
_viewState.value = ViewState.Success(streamBundle)
|
||||
} else {
|
||||
Log.i(TAG, "End of cluster")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch next cluster", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,38 +50,37 @@ class TopChartViewModel @Inject constructor(
|
||||
fun getStreamCluster(type: TopChartsContract.Type, chart: TopChartsContract.Chart) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (targetCluster(type, chart).clusterAppList.isNotEmpty()) {
|
||||
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
|
||||
_state.value = ViewState.Success(targetCluster(type, chart))
|
||||
return@launch
|
||||
}
|
||||
|
||||
_state.tryEmit(ViewState.Loading)
|
||||
_state.value = ViewState.Loading
|
||||
|
||||
try {
|
||||
val cluster = topChartsContract.getCluster(type.value, chart.value)
|
||||
updateCluster(type, chart, cluster)
|
||||
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
|
||||
_state.value = ViewState.Success(targetCluster(type, chart))
|
||||
} catch (e: Exception) {
|
||||
_state.tryEmit(ViewState.Error(e.message))
|
||||
_state.value = ViewState.Error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nextCluster(type: TopChartsContract.Type, chart: TopChartsContract.Chart) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
supervisorScope {
|
||||
try {
|
||||
val target = targetCluster(type, chart)
|
||||
if (target.hasNext()) {
|
||||
val newCluster = topChartsContract.getNextStreamCluster(
|
||||
target.clusterNextPageUrl
|
||||
)
|
||||
try {
|
||||
val target = targetCluster(type, chart)
|
||||
if (target.hasNext()) {
|
||||
val newCluster = topChartsContract.getNextStreamCluster(
|
||||
target.id,
|
||||
target.clusterNextPageUrl
|
||||
)
|
||||
|
||||
updateCluster(type, chart, newCluster)
|
||||
updateCluster(type, chart, newCluster)
|
||||
|
||||
_state.tryEmit(ViewState.Success(targetCluster(type, chart)))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_state.value = ViewState.Success(targetCluster(type, chart))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +105,7 @@ class TopChartViewModel @Inject constructor(
|
||||
): StreamCluster {
|
||||
val cluster = stash
|
||||
.getOrPut(type) { mutableMapOf() }
|
||||
.getOrPut(chart) { StreamCluster() }
|
||||
.getOrPut(chart) { StreamCluster.EMPTY }
|
||||
return cluster
|
||||
}
|
||||
}
|
||||
|
||||
24
app/src/main/res/drawable/ic_refresh.xml
Normal file
24
app/src/main/res/drawable/ic_refresh.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user