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:
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user