compose: overhaul SplashScreen and add GoogleLoginScreen

SplashScreen reacts to NetworkProvider and ditches the separate
SplashButton class for inline buttons. Google sign-in moves into the
dedicated GoogleLoginScreen reachable from the splash flow.
This commit is contained in:
Rahul Patel
2026-05-19 12:12:42 +05:30
parent 0c60df635f
commit f2474cfb91
2 changed files with 504 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.accounts
import android.os.Build
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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 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.util.AC2DMUtil
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; })();"
@Composable
fun GoogleLoginScreen(
onNavigateTo: (Destination) -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val context = LocalContext.current
var progress by remember { mutableFloatStateOf(0f) }
var isIndeterminate by remember { mutableStateOf(true) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
AuroraApp.events.authEvent.collect { event ->
if (event is AuthEvent.GoogleLogin) {
if (event.success) {
AccountProvider.login(
context,
event.email,
event.token,
AuthHelper.Token.AAS,
AccountType.GOOGLE
)
} else {
Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG)
.show()
}
onNavigateTo(Destination.Splash)
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
if (isLoading) {
if (isIndeterminate) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
}
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
val cookieManager = CookieManager.getInstance()
WebView(ctx).apply {
cookieManager.removeAllCookies(null)
cookieManager.acceptThirdPartyCookies(this)
cookieManager.setAcceptThirdPartyCookies(this, true)
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
if (newProgress != 0) {
isIndeterminate = false
progress = newProgress / 100f
isLoading = newProgress < 100
} else {
isIndeterminate = true
isLoading = true
}
}
}
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)
}
}
}
}
}
settings.apply {
allowContentAccess = true
domStorageEnabled = true
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
safeBrowsingEnabled = false
}
}
loadUrl(EMBEDDED_SETUP_URL)
}
}
)
}
}

View File

@@ -0,0 +1,364 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.splash
import android.accounts.Account
import android.accounts.AccountManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.model.AuthState
import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.util.CertUtil.GOOGLE_ACCOUNT_TYPE
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_AUTH_TOKEN_TYPE
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_CERT
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import com.aurora.store.viewmodel.auth.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SplashScreen(
viewModel: AuthViewModel = hiltViewModel(),
deepLinkPackageName: String? = null,
deepLinkDevId: String? = null,
onNavigateTo: (Destination) -> Unit = {}
) {
val context = LocalContext.current
val activity = LocalActivity.current as? ComponentActivity
val authState by viewModel.authState.collectAsStateWithLifecycle()
val networkStatus by viewModel.networkProvider.status.collectAsStateWithLifecycle(
initialValue = NetworkStatus.AVAILABLE
)
val isOnline = networkStatus == NetworkStatus.AVAILABLE
val canMicroGLogin = PackageUtil.hasSupportedMicroGVariant(context) &&
Preferences.getBoolean(context, Preferences.PREFERENCE_MICROG_AUTH, true)
var anonymousLoading by remember { mutableStateOf(false) }
var googleLoading by remember { mutableStateOf(false) }
val accountLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val accountName = result.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
if (!accountName.isNullOrBlank()) {
requestAuthTokenForGoogle(
viewModel = viewModel,
context = context,
accountName = accountName,
oldToken = null,
activity = activity,
onError = { googleLoading = false }
)
} else {
googleLoading = false
}
}
LaunchedEffect(authState) {
when (val state = authState) {
AuthState.Valid, AuthState.SignedIn -> {
anonymousLoading = false
googleLoading = false
when {
!deepLinkDevId.isNullOrBlank() -> onNavigateTo(
Destination.DevProfile(deepLinkDevId)
)
!deepLinkPackageName.isNullOrBlank() -> onNavigateTo(
Destination.AppDetails(deepLinkPackageName)
)
else -> onNavigateTo(
Destination.Main(
Preferences.getInteger(
context,
Preferences.PREFERENCE_DEFAULT_SELECTED_TAB
)
)
)
}
}
is AuthState.PendingAccountManager -> requestAuthTokenForGoogle(
viewModel = viewModel,
context = context,
accountName = state.email,
oldToken = state.token,
activity = activity,
onError = { googleLoading = false }
)
is AuthState.Failed -> {
anonymousLoading = false
googleLoading = false
}
else -> Unit
}
}
LaunchedEffect(isOnline) {
if (isOnline && authState is AuthState.Failed) {
viewModel.retry()
}
}
val isLoading = authState == AuthState.Init ||
authState == AuthState.Available ||
authState == AuthState.Fetching ||
authState == AuthState.Verifying ||
authState is AuthState.PendingAccountManager
val showLoginButtons = authState is AuthState.Unavailable ||
authState is AuthState.Failed ||
authState is AuthState.SignedOut
Scaffold(
topBar = {
TopAppBar(
title = {},
actions = {
IconButton(onClick = { onNavigateTo(Destination.Settings) }) {
Icon(
painter = painterResource(R.drawable.ic_menu_settings),
contentDescription = stringResource(R.string.title_settings)
)
}
IconButton(onClick = { onNavigateTo(Destination.About) }) {
Icon(
painter = painterResource(R.drawable.ic_menu_about),
contentDescription = stringResource(R.string.title_about)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.navigationBarsPadding()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.50f),
contentAlignment = Alignment.BottomCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(R.drawable.ic_logo_alt),
contentDescription = null,
modifier = Modifier.size(dimensionResource(R.dimen.icon_size_splash)),
contentScale = ContentScale.FillBounds
)
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.1f)
.padding(horizontal = dimensionResource(R.dimen.padding_xlarge)),
contentAlignment = Alignment.Center
) {
val (statusText, statusColor) = when {
!isOnline && showLoginButtons -> stringResource(R.string.title_no_network) to
MaterialTheme.colorScheme.error
authState is AuthState.Failed -> (authState as AuthState.Failed).status to
MaterialTheme.colorScheme.error
else -> statusText(authState) to MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
color = statusColor,
textAlign = TextAlign.Center
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.40f),
contentAlignment = Alignment.TopCenter
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else if (showLoginButtons) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.margin_small)
)
) {
Button(
modifier = Modifier.width(dimensionResource(R.dimen.width_button)),
enabled = !anonymousLoading && !googleLoading && isOnline,
onClick = {
if (canMicroGLogin) {
googleLoading = true
accountLauncher.launch(
AccountManager.newChooseAccountIntent(
null,
null,
arrayOf(GOOGLE_ACCOUNT_TYPE),
null,
null,
null,
null
)
)
} else {
onNavigateTo(Destination.GoogleLogin)
}
}
) {
Text(stringResource(R.string.account_google))
}
if (BuildConfig.SHOW_ANONYMOUS_LOGIN) {
OutlinedButton(
modifier = Modifier.width(dimensionResource(R.dimen.width_button)),
enabled = !googleLoading && !anonymousLoading && isOnline,
onClick = {
anonymousLoading = true
viewModel.buildAnonymousAuthData()
}
) {
Text(stringResource(R.string.account_anonymous))
}
}
if (googleLoading || anonymousLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
}
}
}
@Composable
private fun statusText(authState: AuthState): String = when (authState) {
AuthState.Init -> stringResource(R.string.session_init)
AuthState.Available -> stringResource(R.string.session_verifying)
AuthState.Fetching -> stringResource(R.string.requesting_new_session)
AuthState.Verifying -> stringResource(R.string.verifying_new_session)
AuthState.Valid -> stringResource(R.string.session_verifying)
AuthState.SignedIn -> stringResource(R.string.session_good)
AuthState.Unavailable, AuthState.SignedOut -> stringResource(R.string.session_login)
is AuthState.PendingAccountManager -> stringResource(R.string.requesting_new_session)
is AuthState.Failed -> authState.status
}
private fun requestAuthTokenForGoogle(
viewModel: AuthViewModel,
context: android.content.Context,
accountName: String,
oldToken: String?,
activity: ComponentActivity?,
onError: () -> Unit
) {
try {
val accountManager = AccountManager.get(context)
if (oldToken != null) {
accountManager.invalidateAuthToken(GOOGLE_ACCOUNT_TYPE, oldToken)
}
accountManager.getAuthToken(
Account(accountName, GOOGLE_ACCOUNT_TYPE),
GOOGLE_PLAY_AUTH_TOKEN_TYPE,
Bundle().apply {
putString("overridePackage", PACKAGE_NAME_PLAY_STORE)
putByteArray("overrideCertificate", Base64.decode(GOOGLE_PLAY_CERT, Base64.DEFAULT))
},
activity,
{ result ->
val token = result.result.getString(AccountManager.KEY_AUTHTOKEN)
if (token != null) {
viewModel.buildGoogleAuthData(accountName, token, AuthHelper.Token.AUTH)
} else {
Log.e("SplashScreen", "AccountManager returned null auth token")
onError()
}
},
Handler(Looper.getMainLooper())
)
} catch (_: Exception) {
Log.e("SplashScreen", "Failed to get authToken for Google login")
onError()
}
}