Add support for multiple accounts

This commit is contained in:
Rahul Patel
2026-06-07 01:35:27 +05:30
parent fd350c739c
commit 92ec5742c8
41 changed files with 2422 additions and 303 deletions

View File

@@ -0,0 +1,518 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "3673db774ee7f10031b6460a5ac18647",
"entities": [
{
"tableName": "download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `size` INTEGER NOT NULL, `id` INTEGER NOT NULL, `downloadStatus` TEXT NOT NULL, `progress` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `timeRemaining` INTEGER NOT NULL, `totalFiles` INTEGER NOT NULL, `downloadedFiles` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `downloadedAt` INTEGER NOT NULL, `requiresGMS` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionCode",
"columnName": "versionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "offerType",
"columnName": "offerType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isInstalled",
"columnName": "isInstalled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "downloadStatus",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "speed",
"columnName": "speed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeRemaining",
"columnName": "timeRemaining",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalFiles",
"columnName": "totalFiles",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "downloadedFiles",
"columnName": "downloadedFiles",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fileList",
"columnName": "fileList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sharedLibs",
"columnName": "sharedLibs",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "targetSdk",
"columnName": "targetSdk",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "downloadedAt",
"columnName": "downloadedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "requiresGMS",
"columnName": "requiresGMS",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "favourite",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `added` INTEGER NOT NULL, `mode` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "added",
"columnName": "added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "mode",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "update",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `versionName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `changelog` TEXT NOT NULL, `id` INTEGER NOT NULL, `developerName` TEXT NOT NULL, `size` INTEGER NOT NULL, `updatedOn` TEXT NOT NULL, `hasValidCert` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `isIncompatible` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionCode",
"columnName": "versionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "versionName",
"columnName": "versionName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "changelog",
"columnName": "changelog",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "developerName",
"columnName": "developerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedOn",
"columnName": "updatedOn",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasValidCert",
"columnName": "hasValidCert",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "offerType",
"columnName": "offerType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fileList",
"columnName": "fileList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sharedLibs",
"columnName": "sharedLibs",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "targetSdk",
"columnName": "targetSdk",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isIncompatible",
"columnName": "isIncompatible",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "ignored_update",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoredVersionCode` INTEGER, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ignoredVersionCode",
"columnName": "ignoredVersionCode",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "review",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `accountEmail` TEXT NOT NULL, `title` TEXT NOT NULL, `comment` TEXT NOT NULL, `rating` INTEGER NOT NULL, `commentId` TEXT NOT NULL, `userName` TEXT NOT NULL, `userPhotoUrl` TEXT NOT NULL, `appVersion` TEXT NOT NULL, `timeStamp` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`packageName`, `accountEmail`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountEmail",
"columnName": "accountEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "commentId",
"columnName": "commentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "userName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userPhotoUrl",
"columnName": "userPhotoUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "appVersion",
"columnName": "appVersion",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timeStamp",
"columnName": "timeStamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "synced",
"columnName": "synced",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName",
"accountEmail"
]
}
},
{
"tableName": "account",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `profilePicUrl` TEXT, `aasToken` TEXT, `authToken` TEXT, `tokenType` TEXT NOT NULL, `authViaMicroG` INTEGER NOT NULL, `authDataJson` TEXT, `isDefault` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "profilePicUrl",
"columnName": "profilePicUrl",
"affinity": "TEXT"
},
{
"fieldPath": "aasToken",
"columnName": "aasToken",
"affinity": "TEXT"
},
{
"fieldPath": "authToken",
"columnName": "authToken",
"affinity": "TEXT"
},
{
"fieldPath": "tokenType",
"columnName": "tokenType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authViaMicroG",
"columnName": "authViaMicroG",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authDataJson",
"columnName": "authDataJson",
"affinity": "TEXT"
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "addedAt",
"columnName": "addedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_account_binding",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `accountId` TEXT NOT NULL, PRIMARY KEY(`packageName`), FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
},
"indices": [
{
"name": "index_app_account_binding_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_account_binding_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3673db774ee7f10031b6460a5ac18647')"
]
}
}

View File

@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.store.R
import com.aurora.store.data.room.account.Account
@Composable
fun AccountListItem(account: Account, onClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_medium)
),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.crossfade(true)
.data(account.profilePicUrl ?: R.mipmap.ic_launcher)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_small))
.clip(CircleShape)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = if (account.isAnonymous) {
stringResource(R.string.account_anonymous)
} else {
account.displayName ?: account.email
},
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = account.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (account.isDefault) {
Text(
text = stringResource(R.string.account_default),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(
horizontal = dimensionResource(R.dimen.spacing_small),
vertical = dimensionResource(R.dimen.spacing_xsmall)
)
)
}
}
}

View File

@@ -31,7 +31,7 @@ sealed class Destination {
data class PermissionRationale(val permissions: Set<PermissionType>) : Destination()
data object Accounts : Destination()
data object GoogleLogin : Destination()
data class GoogleLogin(val addAccount: Boolean = false) : Destination()
data object About : Destination()
data object Favourite : Destination()
data object Spoof : Destination()

View File

@@ -64,8 +64,22 @@ 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
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
/**
* Lets this VM-less navigation host reach the [AuthProvider] singleton for full sign-out.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface NavDisplayEntryPoint {
fun authProvider(): AuthProvider
}
/**
* Navigation display for compose screens
@@ -83,7 +97,11 @@ fun NavDisplay(startDestination: NavKey) {
fun handleMicroGRemoved() {
context.toast(R.string.microg_removed_auth_warning)
AccountProvider.logout(context)
// Full sign-out: clears the account DB rows (incl. the now-invalid microG account) as
// well as the legacy prefs, so the stale account can't linger as the DB is the source
// of truth.
EntryPointAccessors.fromApplication(context, NavDisplayEntryPoint::class.java)
.authProvider().logout()
val intent = Intent(context, ComposeActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -146,11 +164,11 @@ fun NavDisplay(startDestination: NavKey) {
is Destination.DevProfile -> backstack.add(Screen.DevProfile(destination.devId))
is Destination.AppUpdate -> Unit
is Destination.StreamBrowse -> backstack.add(Screen.StreamBrowse(destination.cluster))
is Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin(destination.addAccount))
Destination.Search -> backstack.add(Screen.Search)
Destination.Downloads -> backstack.add(Screen.Downloads)
Destination.Accounts -> backstack.add(Screen.Accounts)
Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin)
Destination.About -> backstack.add(Screen.About)
Destination.Favourite -> backstack.add(Screen.Favourite)
Destination.Spoof -> backstack.add(Screen.Spoof)
@@ -279,11 +297,18 @@ fun NavDisplay(startDestination: NavKey) {
onNavigateTo = ::navigate
)
}
entry<Screen.GoogleLogin> { screen ->
GoogleLoginScreen(
addAccount = screen.addAccount,
onNavigateTo = ::navigate
)
}
entry<Screen.Onboarding> { OnboardingScreen() }
entry<Screen.Blacklist> { BlacklistScreen() }
entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) }
entry<Screen.Accounts> { AccountsScreen(onNavigateTo = ::navigate) }
entry<Screen.GoogleLogin> { GoogleLoginScreen(onNavigateTo = ::navigate) }
entry<Screen.About> { AboutScreen() }
entry<Screen.Favourite> { FavouriteScreen(onNavigateTo = ::navigate) }
entry<Screen.Spoof> { SpoofScreen(onNavigateTo = ::navigate) }

View File

@@ -48,7 +48,7 @@ sealed class Screen : NavKey, Parcelable {
data object Accounts : Screen()
@Serializable
data object GoogleLogin : Screen()
data class GoogleLogin(val addAccount: Boolean = false) : Screen()
@Serializable
data object About : Screen()

View File

@@ -5,107 +5,319 @@
package com.aurora.store.compose.ui.accounts
import android.widget.Toast
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.Constants.URL_DISCLAIMER
import com.aurora.Constants.URL_LICENSE
import com.aurora.Constants.URL_TOS
import com.aurora.extensions.browse
import com.aurora.store.R
import com.aurora.store.compose.composable.AccountListItem
import com.aurora.store.compose.composable.SectionHeader
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.compose.ui.commons.LoadingDialog
import com.aurora.store.compose.ui.sheets.AccountActionsSheet
import com.aurora.store.data.room.account.Account
import com.aurora.store.util.RestartUtil
import com.aurora.store.viewmodel.accounts.AccountsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(
onNavigateTo: (Destination) -> Unit,
viewModel: AccountsViewModel = hiltViewModel()
) {
val userProfile = viewModel.authProvider.authData?.userProfile
val avatar = when {
userProfile != null -> userProfile.artwork.url
else -> R.mipmap.ic_launcher
}
val name = when {
viewModel.authProvider.isAnonymous -> stringResource(R.string.account_anonymous)
userProfile != null -> userProfile.name
else -> stringResource(R.string.account_anonymous)
}
val email = when {
userProfile != null -> userProfile.email
else -> stringResource(R.string.account_anonymous_email)
}
val context = LocalContext.current
var shouldShowLogoutDialog by rememberSaveable { mutableStateOf(false) }
val activity = LocalActivity.current
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
var showAddSheet by remember { mutableStateOf(false) }
var selectedAccount by remember { mutableStateOf<Account?>(null) }
var accountToRemove by remember { mutableStateOf<Account?>(null) }
var accountToSetDefault by remember { mutableStateOf<Account?>(null) }
var isSwitchingDefault by remember { mutableStateOf(false) }
var isAddingAccount by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
if (shouldShowLogoutDialog) {
LogoutDialog(
onConfirm = {
shouldShowLogoutDialog = false
AccountProvider.logout(context)
onNavigateTo(Destination.Splash())
},
onDismiss = { shouldShowLogoutDialog = false }
LaunchedEffect(Unit) {
viewModel.switched.collect { ok ->
if (ok) {
RestartUtil.restart(context)
} else {
isSwitchingDefault = false
Toast.makeText(
context,
context.getString(R.string.account_switch_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
LaunchedEffect(Unit) {
viewModel.removeResult.collect { result ->
when (result) {
AccountsViewModel.RemoveResult.LOGGED_OUT -> onNavigateTo(Destination.Splash())
AccountsViewModel.RemoveResult.RESTART -> RestartUtil.restart(context)
}
}
}
LaunchedEffect(Unit) {
viewModel.addResult.collect { added ->
isAddingAccount = false
if (!added) {
Toast.makeText(
context,
context.getString(R.string.account_add_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
LaunchedEffect(Unit) {
viewModel.refreshResult.collect { failures ->
isRefreshing = false
val message = if (failures == 0) {
context.getString(R.string.account_refresh_done)
} else {
context.resources.getQuantityString(
R.plurals.account_refresh_failed,
failures,
failures
)
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
ScreenContent(
avatar = avatar,
name = name,
email = email,
onLogout = { shouldShowLogoutDialog = true }
accounts = accounts,
onAccountClick = { selectedAccount = it },
onAddAccount = { showAddSheet = true },
onRefreshAll = {
isRefreshing = true
viewModel.refreshAll(activity)
}
)
selectedAccount?.let { account ->
AccountActionsSheet(
account = account,
onSetDefault = {
selectedAccount = null
accountToSetDefault = account
},
onRemove = {
selectedAccount = null
accountToRemove = account
},
onDismiss = { selectedAccount = null }
)
}
accountToSetDefault?.let { account ->
AlertDialog(
onDismissRequest = { accountToSetDefault = null },
title = { Text(text = stringResource(R.string.account_set_default_title)) },
text = { Text(text = stringResource(R.string.account_set_default_message)) },
confirmButton = {
TextButton(
onClick = {
accountToSetDefault = null
isSwitchingDefault = true
viewModel.setDefault(account)
}
) {
Text(text = stringResource(R.string.action_restart))
}
},
dismissButton = {
TextButton(onClick = { accountToSetDefault = null }) {
Text(text = stringResource(R.string.action_cancel))
}
}
)
}
if (isSwitchingDefault) {
LoadingDialog(message = stringResource(R.string.account_switching))
}
if (isAddingAccount) {
LoadingDialog(message = stringResource(R.string.account_adding))
}
if (isRefreshing) {
LoadingDialog(message = stringResource(R.string.account_refreshing))
}
accountToRemove?.let { account ->
val isLast = accounts.size <= 1
val name = if (account.isAnonymous) {
stringResource(R.string.account_anonymous)
} else {
account.displayName ?: account.email
}
AlertDialog(
onDismissRequest = { accountToRemove = null },
title = { Text(text = stringResource(R.string.account_remove_title)) },
text = {
Text(
text = if (isLast) {
stringResource(R.string.account_remove_last_message)
} else {
stringResource(R.string.account_remove_message, name)
}
)
},
confirmButton = {
TextButton(
onClick = {
accountToRemove = null
viewModel.remove(account)
}
) {
Text(text = stringResource(R.string.account_remove))
}
},
dismissButton = {
TextButton(onClick = { accountToRemove = null }) {
Text(text = stringResource(R.string.action_cancel))
}
}
)
}
if (showAddSheet) {
val deviceEmails by produceState(initialValue = emptyList()) {
value = viewModel.discoverableEmails()
}
ModalBottomSheet(
onDismissRequest = { showAddSheet = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dimensionResource(R.dimen.spacing_small))
) {
Text(
text = stringResource(R.string.account_add),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_large),
vertical = dimensionResource(R.dimen.spacing_small)
)
)
if (deviceEmails.isNotEmpty()) {
SectionLabel(text = stringResource(R.string.account_add_device))
deviceEmails.forEach { email ->
AddOptionRow(
iconRes = R.drawable.ic_account,
label = email,
onClick = {
showAddSheet = false
isAddingAccount = true
viewModel.addSystemAccount(email, activity)
}
)
}
HorizontalDivider(
modifier = Modifier.padding(
vertical = dimensionResource(R.dimen.spacing_xsmall)
)
)
}
AddOptionRow(
iconRes = R.drawable.ic_google,
label = stringResource(R.string.account_add_google),
onClick = {
showAddSheet = false
onNavigateTo(Destination.GoogleLogin(addAccount = true))
}
)
if (accounts.none { it.isAnonymous }) {
AddOptionRow(
iconRes = R.drawable.ic_anonymous,
label = stringResource(R.string.account_add_anonymous),
onClick = {
showAddSheet = false
isAddingAccount = true
viewModel.addAnonymousAccount()
}
)
}
Spacer(Modifier.navigationBarsPadding())
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScreenContent(
avatar: Any = R.drawable.ic_account,
name: String = stringResource(R.string.account_anonymous),
email: String = stringResource(R.string.account_anonymous_email),
onLogout: () -> Unit = {}
accounts: List<Account> = emptyList(),
onAccountClick: (Account) -> Unit = {},
onAddAccount: () -> Unit = {},
onRefreshAll: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(
title = stringResource(R.string.title_account_manager)
title = stringResource(R.string.title_account_manager),
actions = { OverflowMenu(onRefreshAll = onRefreshAll) }
)
}
) { paddingValues ->
@@ -113,64 +325,21 @@ private fun ScreenContent(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.spacing_small)),
verticalArrangement = Arrangement.SpaceBetween
) {
AssistHeader()
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.crossfade(true)
.data(avatar)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_avatar))
.clip(CircleShape)
)
Text(
text = name,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = email,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
SectionHeader(
title = pluralStringResource(R.plurals.account_count, accounts.size, accounts.size),
trailing = {
TextButton(onClick = onAddAccount) {
Text(text = stringResource(R.string.account_add))
}
}
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dimensionResource(R.dimen.height_bottom_adj)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_medium)
)
) {
Text(
text = stringResource(R.string.account_logout),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
OutlinedButton(onClick = onLogout) {
Icon(
painter = painterResource(R.drawable.ic_logout),
contentDescription = null
)
Spacer(modifier = Modifier.width(dimensionResource(R.dimen.spacing_xsmall)))
Text(
text = stringResource(R.string.action_logout)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(items = accounts, key = { it.id }) { account ->
AccountListItem(
account = account,
onClick = { onAccountClick(account) }
)
}
}
@@ -179,26 +348,80 @@ private fun ScreenContent(
}
@Composable
private fun AssistHeader() {
private fun SectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_large),
vertical = dimensionResource(R.dimen.spacing_xsmall)
)
)
}
@Composable
private fun AddOptionRow(iconRes: Int, label: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.spacing_large),
vertical = dimensionResource(R.dimen.spacing_medium)
),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun OverflowMenu(onRefreshAll: () -> Unit) {
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
val links = mapOf(
R.string.menu_terms to URL_TOS,
R.string.menu_disclaimer to URL_DISCLAIMER,
R.string.menu_license to URL_LICENSE
)
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.spacing_medium)),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium))
) {
items(items = links.keys.toList(), key = { item -> item }) { label ->
AssistChip(
onClick = { context.browse(links.getValue(label)) },
label = { Text(text = stringResource(label)) }
Box {
IconButton(onClick = { expanded = true }) {
Icon(
painter = painterResource(R.drawable.ic_more_vert),
contentDescription = stringResource(R.string.menu)
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.account_refresh_all)) },
onClick = {
expanded = false
onRefreshAll()
}
)
HorizontalDivider()
links.forEach { (label, url) ->
DropdownMenuItem(
text = { Text(text = stringResource(label)) },
onClick = {
expanded = false
context.browse(url)
}
)
}
}
}
}

View File

@@ -54,6 +54,7 @@ private const val JS_PROFILE_EMAIL = """
@Composable
fun GoogleLoginScreen(
onNavigateTo: (Destination) -> Unit,
addAccount: Boolean = false,
viewModel: AuthViewModel = hiltViewModel()
) {
val context = LocalContext.current
@@ -66,20 +67,37 @@ fun GoogleLoginScreen(
AuroraApp.events.authEvent.collect { event ->
if (event is AuthEvent.GoogleLogin) {
if (event.success) {
if (addAccount) {
viewModel.addGoogleAuthData(event.email, event.token)
} else {
viewModel.buildGoogleAuthData(
event.email,
event.token,
AuthHelper.Token.AAS
)
}
} else {
Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG)
.show()
onNavigateTo(Destination.Splash())
onNavigateTo(if (addAccount) Destination.Accounts else Destination.Splash())
}
}
}
}
// In add-account mode the user is already signed in (authState is Valid), so don't let the
// authState effect bounce to Main. Navigate back to the account screen once the add completes.
if (addAccount) {
LaunchedEffect(Unit) {
viewModel.accountAdded.collect { ok ->
if (!ok) {
Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG)
.show()
}
onNavigateTo(Destination.Accounts)
}
}
} else {
LaunchedEffect(authState) {
when (authState) {
AuthState.SignedIn, AuthState.Valid -> onNavigateTo(
@@ -91,6 +109,7 @@ fun GoogleLoginScreen(
else -> Unit
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
if (isLoading) {

View File

@@ -1,49 +0,0 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.accounts
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.aurora.store.R
import com.aurora.store.compose.preview.ThemePreviewProvider
/**
* Dialog for confirming log out
* @param onConfirm Callback on confirmation
* @param onDismiss Callback on dismiss
*/
@Composable
fun LogoutDialog(onConfirm: () -> Unit = {}, onDismiss: () -> Unit = {}) {
AlertDialog(
title = { Text(text = stringResource(R.string.action_logout_confirmation_title)) },
text = {
Text(text = stringResource(R.string.action_logout_confirmation_message))
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
}
)
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun LogoutDialogPreview() {
LogoutDialog()
}

View File

@@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.commons
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.aurora.store.R
import com.aurora.store.compose.preview.ThemePreviewProvider
/**
* A non-dismissable dialog showing a spinner alongside a short status message, for blocking
* operations the user must wait on (e.g. switching account, adding account).
* @param message Status message to display next to the spinner
*/
@Composable
fun LoadingDialog(message: String) {
Dialog(
onDismissRequest = {},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Surface(
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_large)),
tonalElevation = dimensionResource(R.dimen.spacing_xsmall)
) {
Row(
modifier = Modifier.padding(dimensionResource(R.dimen.spacing_large)),
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_large)
),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator()
Text(
text = message,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun LoadingDialogPreview() {
LoadingDialog(message = stringResource(R.string.account_switching))
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
@@ -95,6 +96,7 @@ import com.aurora.store.compose.ui.details.menu.AppDetailsMenu
import com.aurora.store.compose.ui.details.menu.MenuItem
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.compose.ui.sheets.AccountPickerSheet
import com.aurora.store.compose.ui.sheets.InstallErrorSheet
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.AppState
@@ -103,6 +105,7 @@ import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
import com.aurora.store.data.providers.PermissionProvider.Companion.isPermittedToInstall
import com.aurora.store.data.room.account.Account
import com.aurora.store.util.FlavouredUtil
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.ShortcutManagerUtil
@@ -129,6 +132,7 @@ fun AppDetailsScreen(
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
val installError by viewModel.installError.collectAsStateWithLifecycle()
val suggestionsBundle by viewModel.suggestionsBundle.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
@@ -184,7 +188,11 @@ fun AppDetailsScreen(
exodusReport = exodusReport,
onNavigateTo = onNavigateTo,
onLoadMoreCluster = { cluster -> viewModel.loadMoreCluster(cluster) },
accounts = accounts,
onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
onDownloadWith = { requestedApp, accountId ->
viewModel.enqueueDownloadWith(requestedApp, accountId)
},
onFavorite = { viewModel.toggleFavourite(loadedApp) },
onCancelDownload = { viewModel.cancelDownload(loadedApp) },
onUninstall = { AppInstaller.uninstall(context, packageName) },
@@ -274,6 +282,7 @@ private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? =
/**
* Composable to display app details and suggestions
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScreenContentApp(
app: App,
@@ -288,7 +297,9 @@ private fun ScreenContentApp(
exodusReport: Report? = null,
onNavigateTo: (Destination) -> Unit = {},
onLoadMoreCluster: (cluster: StreamCluster) -> Unit = {},
accounts: List<Account> = emptyList(),
onDownload: (requestedApp: App) -> Unit = {},
onDownloadWith: (requestedApp: App, accountId: String) -> Unit = { _, _ -> },
onFavorite: () -> Unit = {},
onCancelDownload: () -> Unit = {},
onUninstall: () -> Unit = {},
@@ -322,6 +333,7 @@ private fun ScreenContentApp(
val shouldShowMenuOnMainPane = scaffoldNavigator
.scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden
var showRestartDialog by remember { mutableStateOf(false) }
var showAccountPicker by remember { mutableStateOf(false) }
if (showRestartDialog) {
ForceRestartDialog(onConfirm = onForceRestart)
@@ -339,15 +351,23 @@ private fun ScreenContentApp(
}
}
fun onInstall(requestedApp: App = app, ignoreMicroG: Boolean = false) {
fun onInstall(
requestedApp: App = app,
ignoreMicroG: Boolean = false,
accountId: String? = null
) {
if (isPermittedToInstall(context, app)) {
val shouldPromptMicroGInstall = app.requiresGMS() &&
FlavouredUtil.promptMicroGInstall(context)
if (shouldPromptMicroGInstall && !ignoreMicroG) {
showExtraPane(ExtraScreen.MicroG)
} else {
if (accountId != null) {
onDownloadWith(requestedApp, accountId)
} else {
onDownload(requestedApp)
}
onNavigateBack()
}
} else {
@@ -360,12 +380,24 @@ private fun ScreenContentApp(
}
}
if (showAccountPicker) {
AccountPickerSheet(
accounts = accounts,
onSelect = { account ->
showAccountPicker = false
onInstall(accountId = account.id)
},
onDismiss = { showAccountPicker = false }
)
}
@Composable
fun SetupMenu() {
AppDetailsMenu(
isFavorite = isFavorite,
state = state,
canManualDownload = canAcquire
canManualDownload = canAcquire,
canUseOtherAccount = accounts.size > 1
) { menuItem ->
when (menuItem) {
MenuItem.FAVORITE -> onFavorite()
@@ -374,6 +406,10 @@ private fun ScreenContentApp(
showExtraPane(ExtraScreen.ManualDownload)
}
MenuItem.INSTALL_OTHER_ACCOUNT -> {
showAccountPicker = true
}
MenuItem.SHARE -> context.share(app.displayName, app.packageName)
MenuItem.APP_INFO -> context.appInfo(app.packageName)
@@ -448,7 +484,9 @@ private fun ScreenContentApp(
Actions(
primaryActionDisplayName = primaryActionName,
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
secondaryActionDisplayName = stringResource(
R.string.title_manual_download
),
isPrimaryActionEnabled = canAcquire,
isSecondaryActionEnabled = canAcquire,
onPrimaryAction = ::onInstall,

View File

@@ -43,6 +43,7 @@ fun AppDetailsMenu(
isFavorite: Boolean = false,
isExpanded: Boolean = false,
canManualDownload: Boolean = true,
canUseOtherAccount: Boolean = false,
onMenuItemClicked: (menuItem: MenuItem) -> Unit = {}
) {
val context = LocalContext.current
@@ -83,6 +84,11 @@ fun AppDetailsMenu(
onClick = { onClick(MenuItem.MANUAL_DOWNLOAD) },
enabled = canManualDownload && !state.inProgress()
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_switch_account)) },
onClick = { onClick(MenuItem.INSTALL_OTHER_ACCOUNT) },
enabled = canUseOtherAccount && !state.inProgress()
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_info)) },
onClick = { onClick(MenuItem.APP_INFO) },

View File

@@ -13,6 +13,7 @@ enum class MenuItem {
FAVORITE,
SHARE,
MANUAL_DOWNLOAD,
INSTALL_OTHER_ACCOUNT,
APP_INFO,
ADD_TO_HOME,
PLAY_STORE

View File

@@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.sheets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.aurora.store.R
import com.aurora.store.data.room.account.Account
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountActionsSheet(
account: Account,
onSetDefault: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit
) {
val name = if (account.isAnonymous) {
stringResource(R.string.account_anonymous)
} else {
account.displayName ?: account.email
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dimensionResource(R.dimen.spacing_small))
) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_large)
)
)
Text(
text = account.email,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_large)
)
)
if (!account.isDefault) {
ActionRow(
labelRes = R.string.account_set_default,
onClick = onSetDefault
)
}
ActionRow(
labelRes = R.string.account_remove,
onClick = onRemove
)
Spacer(Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun ActionRow(labelRes: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.spacing_large),
vertical = dimensionResource(R.dimen.spacing_medium)
),
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_medium)
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.bodyLarge
)
}
}

View File

@@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.sheets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import com.aurora.store.R
import com.aurora.store.data.room.account.Account
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountPickerSheet(
accounts: List<Account>,
onSelect: (Account) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.action_switch_account),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
)
)
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.spacing_xsmall))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(vertical = dimensionResource(R.dimen.spacing_xsmall))
) {
accounts.forEachIndexed { index, account ->
if (index > 0) {
HorizontalDivider()
}
val suffix = if (account.isDefault) {
" · " + stringResource(R.string.account_default)
} else {
" · " + account.email
}
val name = if (account.isAnonymous) {
stringResource(R.string.account_anonymous)
} else {
account.displayName ?: account.email
}
Text(
text = name + suffix,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(account) }
.padding(
horizontal = dimensionResource(R.dimen.spacing_large),
vertical = dimensionResource(R.dimen.spacing_small)
)
)
}
}
Spacer(Modifier.navigationBarsPadding())
}
}
}

View File

@@ -5,12 +5,19 @@
package com.aurora.store.compose.ui.sheets
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
@@ -20,21 +27,28 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.draw.clip
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.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
@@ -45,6 +59,7 @@ import com.aurora.store.R
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.room.account.Account
import com.aurora.store.data.room.update.Update
import com.aurora.store.viewmodel.sheets.AppUpdateViewModel
@@ -58,6 +73,8 @@ fun AppUpdateSheet(
) {
val context = LocalContext.current
val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(update.packageName)
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
var showAccounts by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -105,6 +122,26 @@ fun AppUpdateSheet(
}
)
// Per-app update override: only meaningful with more than one account. The primary
// update button keeps using the default (or the app's existing binding).
if (accounts.size > 1) {
HorizontalDivider(
modifier = Modifier.padding(
vertical = dimensionResource(R.dimen.spacing_small)
)
)
AccountAccordion(
expanded = showAccounts,
onToggle = { showAccounts = !showAccounts },
accounts = accounts,
onSelect = { account ->
viewModel.updateWithAccount(update, account.id)
onDismiss()
}
)
}
HorizontalDivider(
modifier = Modifier.padding(
vertical = dimensionResource(R.dimen.spacing_small)
@@ -222,6 +259,90 @@ private fun Item(label: String, onClick: () -> Unit) {
)
}
/**
* Expandable "update using another account" row. Tapping the header toggles a tinted sub-menu
* listing the available accounts, so it reads as a nested section rather than a sibling action.
*/
@Composable
private fun AccountAccordion(
expanded: Boolean,
onToggle: () -> Unit,
accounts: List<Account>,
onSelect: (Account) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.spacing_xsmall))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
.clickable(onClick = onToggle)
.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_small)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.action_switch_account),
style = MaterialTheme.typography.bodyLarge
)
Icon(
painter = painterResource(
if (expanded) R.drawable.ic_keyboard_arrow_up else R.drawable.ic_keyboard_arrow_down
),
contentDescription = null
)
}
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.spacing_xsmall))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(vertical = dimensionResource(R.dimen.spacing_xsmall))
) {
accounts.forEachIndexed { index, account ->
if (index > 0) {
HorizontalDivider()
}
val suffix = if (account.isDefault) {
" · " + stringResource(R.string.account_default)
} else {
" · " + account.email
}
val name = if (account.isAnonymous) {
stringResource(R.string.account_anonymous)
} else {
account.displayName ?: account.email
}
Text(
text = name + suffix,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(account) }
.padding(
horizontal = dimensionResource(R.dimen.spacing_large),
vertical = dimensionResource(R.dimen.spacing_small)
)
)
}
}
}
}
@Composable
private fun ChangelogSection(html: String) {
Text(

View File

@@ -279,7 +279,7 @@ fun SplashScreen(
)
)
} else {
onNavigateTo(Destination.GoogleLogin)
onNavigateTo(Destination.GoogleLogin())
}
}
) {

View File

@@ -40,7 +40,6 @@ import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.ui.spoof.menu.MenuItem
import com.aurora.store.compose.ui.spoof.menu.SpoofMenu
import com.aurora.store.compose.ui.spoof.navigation.SpoofPage
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.viewmodel.spoof.SpoofViewModel
import kotlinx.coroutines.launch
@@ -49,7 +48,8 @@ fun SpoofScreen(onNavigateTo: (Destination) -> Unit, viewModel: SpoofViewModel =
ScreenContent(
onNavigateTo = onNavigateTo,
onDeviceSpoofImport = { uri -> viewModel.importDeviceSpoof(uri) },
onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) }
onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) },
onLogout = { viewModel.logout() }
)
}
@@ -58,7 +58,8 @@ private fun ScreenContent(
pages: List<SpoofPage> = listOf(SpoofPage.DEVICE, SpoofPage.LOCALE),
onNavigateTo: (Destination) -> Unit = {},
onDeviceSpoofImport: (uri: Uri) -> Unit = {},
onDeviceSpoofExport: (uri: Uri) -> Unit = {}
onDeviceSpoofExport: (uri: Uri) -> Unit = {},
onLogout: () -> Unit = {}
) {
val context = LocalContext.current
val pagerState = rememberPagerState { pages.size }
@@ -95,7 +96,7 @@ private fun ScreenContent(
)
when (result) {
SnackbarResult.ActionPerformed -> {
AccountProvider.logout(context)
onLogout()
onNavigateTo(Destination.Splash())
}

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data
import com.aurora.store.data.room.account.Account
import com.aurora.store.data.room.account.AccountDao
import com.aurora.store.data.room.account.AppAccountBinding
import com.aurora.store.data.room.account.AppAccountBindingDao
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
@Singleton
class AccountRepository @Inject constructor(
private val accountDao: AccountDao,
private val bindingDao: AppAccountBindingDao
) {
val accounts: Flow<List<Account>> = accountDao.accounts()
val defaultAccount: Flow<Account?> = accountDao.observeDefault()
suspend fun getAll(): List<Account> = accountDao.getAll()
suspend fun getDefault(): Account? = accountDao.getDefault()
suspend fun getById(id: String): Account? = accountDao.getById(id)
/** Inserts/updates an account. Enforces the single-anonymous invariant. */
suspend fun upsertAccount(account: Account) {
if (account.isAnonymous) {
accountDao.upsert(account.copy(id = Account.ANONYMOUS_ID))
} else {
accountDao.upsert(account)
}
}
suspend fun setDefault(id: String) = accountDao.replaceDefault(id)
/**
* Removes an account and its bindings. If it was the default, promotes a new default
* (anonymous preferred, else the oldest remaining). The default promotion is atomic; the
* binding cleanup runs first as a separate statement (FK cascade is not enabled).
*/
suspend fun removeAccount(id: String) {
// SQLite only cascades FK deletes when `PRAGMA foreign_keys = ON`, which this project
// does not enable, so this explicit delete is the actual enforcer of the binding cleanup.
bindingDao.deleteByAccountId(id)
accountDao.deleteAndEnsureDefault(id)
}
/**
* The account id to use for [packageName]: its binding if one exists, otherwise the current
* default account's id. Falls back to [Account.ANONYMOUS_ID] when no default is set yet
* (e.g. immediately after migration before login) — callers must treat this as "use the
* anonymous account", and the anonymous account is expected to exist by the time a download
* runs.
*/
suspend fun resolveAccountId(packageName: String): String {
bindingDao.getForPackage(packageName)?.let { return it.accountId }
return accountDao.getDefault()?.id ?: Account.ANONYMOUS_ID
}
/**
* Records the account an app was installed with. Binding to the current default clears any
* existing binding (default-account installs are never tagged).
*/
suspend fun bindApp(packageName: String, accountId: String) {
val defaultId = accountDao.getDefault()?.id
if (accountId == defaultId) {
bindingDao.delete(packageName)
} else {
bindingDao.upsert(AppAccountBinding(packageName, accountId))
}
}
suspend fun unbindApp(packageName: String) = bindingDao.delete(packageName)
}

View File

@@ -14,6 +14,7 @@ import androidx.work.WorkRequest
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App
import com.aurora.store.AuroraApp
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus
@@ -40,7 +41,8 @@ import kotlinx.coroutines.launch
class DownloadHelper @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadDao: DownloadDao,
private val appInstaller: AppInstaller
private val appInstaller: AppInstaller,
private val accountRepository: AccountRepository
) {
companion object {
@@ -158,6 +160,15 @@ class DownloadHelper @Inject constructor(
enqueue(Download.fromApp(app))
}
/**
* Enqueues an app for download using a chosen account. Binding to the default clears any
* existing binding; a non-default account is persisted so future updates use it.
*/
suspend fun enqueueApp(app: App, accountId: String) {
accountRepository.bindApp(app.packageName, accountId)
enqueueApp(app)
}
/**
* Enqueues an update for download & install
* @param update [Update] to download
@@ -166,6 +177,15 @@ class DownloadHelper @Inject constructor(
enqueue(Download.fromUpdate(update))
}
/**
* Enqueues an update using a chosen account. Binding to the default clears any existing
* binding; a non-default account is persisted so this and future updates use it.
*/
suspend fun enqueueUpdate(update: Update, accountId: String) {
accountRepository.bindApp(update.packageName, accountId)
enqueueUpdate(update)
}
/**
* Enqueues ExternalApk for download & install
* @param externalApk [ExternalApk] to download

View File

@@ -19,16 +19,21 @@
package com.aurora.store.data.providers
import android.app.Activity
import android.content.Context
import android.util.Log
import com.aurora.Constants
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.AuthData
import com.aurora.gplayapi.data.models.PlayResponse
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.AuroraApp
import com.aurora.store.R
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.model.AccountType
import com.aurora.store.data.model.Auth
import com.aurora.store.data.room.account.Account
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_AUTH_DATA
import com.aurora.store.util.Preferences.PREFERENCE_DISPENSER_URLS
@@ -36,6 +41,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -44,8 +51,25 @@ class AuthProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val json: Json,
private val spoofProvider: SpoofProvider,
private val httpClient: IHttpClient
private val httpClient: IHttpClient,
private val accountRepository: AccountRepository,
private val tokenProvider: GoogleAccountTokenProvider
) {
@Volatile
private var cachedDefault: Account? = null
@Volatile
private var cacheWarm = false
init {
AuroraApp.scope.launch {
importLegacyAccountIfNeeded()
accountRepository.defaultAccount.collect { account ->
cachedDefault = account
cacheWarm = true
}
}
}
val dispenserURL: String?
get() {
@@ -53,19 +77,32 @@ class AuthProvider @Inject constructor(
return if (dispensers.isNotEmpty()) dispensers.random() else null
}
/** Default account's AuthData (or BOGUS). Served from the cache when warm (see [init]). */
val authData: AuthData?
get() {
Log.i(TAG, "Loading saved AuthData")
val rawAuth: String = Preferences.getString(context, PREFERENCE_AUTH_DATA)
return if (rawAuth.isNotBlank()) {
json.decodeFromString<AuthData>(rawAuth)
get() = decodeAuthData(defaultAccountSnapshot())
/** Whether the default account is anonymous. Served from the cache when warm (see [init]). */
val isAnonymous: Boolean
get() = defaultAccountSnapshot()?.isAnonymous ?: true
/**
* The default account: from the in-memory cache when warm, otherwise a one-off blocking query
* that also runs the legacy import. The cache is kept current by the [init] collector, so the
* blocking path only runs on the very first read before the first Flow emission.
*/
private fun defaultAccountSnapshot(): Account? = if (cacheWarm) {
cachedDefault
} else {
AuthData("BOGUS")
runBlocking {
importLegacyAccountIfNeeded()
accountRepository.getDefault()
}
}
val isAnonymous: Boolean
get() = AccountProvider.getAccountType(context) == AccountType.ANONYMOUS
private fun decodeAuthData(account: Account?): AuthData =
account?.authDataJson?.takeIf { it.isNotBlank() }
?.let { json.decodeFromString<AuthData>(it) }
?: AuthData("BOGUS")
/**
* Checks whether saved AuthData is valid or not
@@ -134,6 +171,128 @@ class AuthProvider @Inject constructor(
}
}
/**
* Returns the [AuthData] for the given [accountId], or null if not found / not yet cached.
*/
suspend fun getAuthData(accountId: String): AuthData? {
val account = accountRepository.getById(accountId) ?: return null
return account.authDataJson?.takeIf { it.isNotBlank() }
?.let { json.decodeFromString<AuthData>(it) }
}
/**
* Rebuilds and persists [accountId]'s AuthData. Pass an [activity] for user-initiated
* refreshes so a device (microG) account can show any consent UI it needs; background
* callers omit it and rely on a non-interactive token mint.
*/
suspend fun refresh(accountId: String, activity: Activity? = null): Result<AuthData> {
val account = accountRepository.getById(accountId)
?: return Result.failure(IllegalStateException("No account $accountId"))
return runCatching {
val authData = when (account.type) {
AccountType.ANONYMOUS -> buildAnonymousAuthData().getOrThrow()
AccountType.GOOGLE -> when (account.tokenType) {
AuthHelper.Token.AAS -> {
val aasToken = account.aasToken
?: throw IllegalStateException(
"AAS account ${account.id} has no aasToken"
)
buildGoogleAuthData(account.email, aasToken, AuthHelper.Token.AAS)
.getOrThrow()
}
AuthHelper.Token.AUTH -> {
val token = tokenProvider.fetchAuthToken(
account.email,
account.authToken,
activity
)
buildGoogleAuthData(account.email, token, AuthHelper.Token.AAS).getOrThrow()
}
}
}
accountRepository.upsertAccount(
account.copy(
email = authData.email,
authDataJson = json.encodeToString(authData),
displayName = authData.userProfile?.name ?: account.displayName,
profilePicUrl = authData.userProfile?.artwork?.url ?: account.profilePicUrl
)
)
authData
}
}
/** Refreshes the target account, then marks it default. Leaves the old default if it fails. */
suspend fun setDefaultAccount(accountId: String): Result<Unit> = refresh(accountId).map {
accountRepository.setDefault(accountId)
syncDefaultToPrefs()
}
/**
* Mirrors the current DB default account into the legacy single-account prefs. The login gate
* (`AccountProvider.isLoggedIn`) and the session-refresh paths still read those prefs, so this
* must be called after every change of *which* account is the default (login, set-default,
* account removal) to keep them in sync with the DB. Clears the prefs when no default remains.
*/
suspend fun syncDefaultToPrefs() {
val account = accountRepository.getDefault()
if (account == null) {
AccountProvider.logout(context)
return
}
val token = account.aasToken ?: account.authToken.orEmpty()
AccountProvider.login(context, account.email, token, account.tokenType, account.type)
account.authDataJson?.let { Preferences.putString(context, PREFERENCE_AUTH_DATA, it) }
Preferences.putBoolean(
context,
Preferences.PREFERENCE_AUTH_VIA_MICROG,
account.authViaMicroG
)
}
/**
* Persists [authData] for the given account into the account table so the DB-backed
* [authData] stays current. Upserts the row (id derived from type+email) and, when
* [makeDefault] is true, marks it the default.
*/
suspend fun persistAccount(
authData: AuthData,
accountType: AccountType,
authViaMicroG: Boolean,
makeDefault: Boolean = true
) {
val tokenType =
if (authData.aasToken.isBlank()) AuthHelper.Token.AUTH else AuthHelper.Token.AAS
val account = Account(
id = Account.idFor(accountType, authData.email),
type = accountType,
email = authData.email,
displayName = authData.userProfile?.name,
profilePicUrl = authData.userProfile?.artwork?.url,
aasToken = authData.aasToken.ifBlank { null },
authToken = authData.authToken.ifBlank { null },
tokenType = tokenType,
authViaMicroG = authViaMicroG,
authDataJson = json.encodeToString(authData),
isDefault = false
)
accountRepository.upsertAccount(account)
if (makeDefault) {
accountRepository.setDefault(account.id)
syncDefaultToPrefs()
}
}
/**
* Full sign-out: removes every account from the DB and clears the legacy single-account
* prefs. Used by the auth-failure / reset paths so a stale account row can't linger as the
* DB is now the source of truth. Uses `runBlocking` (see [authData]); call sparingly.
*/
fun logout() = runBlocking {
accountRepository.getAll().forEach { accountRepository.removeAccount(it.id) }
AccountProvider.logout(context)
}
/**
* Saves given [AuthData]
*/
@@ -148,6 +307,39 @@ class AuthProvider @Inject constructor(
Preferences.remove(context, PREFERENCE_AUTH_DATA)
}
private suspend fun importLegacyAccountIfNeeded() {
if (accountRepository.getAll().isNotEmpty()) return
if (!Preferences.getBoolean(context, Constants.ACCOUNT_SIGNED_IN, false)) return
val type = if (Preferences.getString(context, Constants.ACCOUNT_TYPE) == "GOOGLE") {
AccountType.GOOGLE
} else {
AccountType.ANONYMOUS
}
val email = Preferences.getString(context, Constants.ACCOUNT_EMAIL_PLAIN)
val aas = Preferences.getString(context, Constants.ACCOUNT_AAS_PLAIN).ifBlank { null }
val auth = Preferences.getString(context, Constants.ACCOUNT_AUTH_PLAIN).ifBlank { null }
val rawAuth = Preferences.getString(context, PREFERENCE_AUTH_DATA).ifBlank { null }
accountRepository.upsertAccount(
Account(
id = Account.idFor(type, email),
type = type,
email = email.ifBlank { "anonymous@aurora" },
aasToken = aas,
authToken = auth,
tokenType = if (aas != null) AuthHelper.Token.AAS else AuthHelper.Token.AUTH,
authViaMicroG = Preferences.getBoolean(
context,
Preferences.PREFERENCE_AUTH_VIA_MICROG,
false
),
authDataJson = rawAuth,
isDefault = true
)
)
}
@Throws(Exception::class)
private fun throwError(playResponse: PlayResponse, context: Context) {
when (playResponse.code) {

View File

@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.providers
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.AccountManagerCallback
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE
import com.aurora.extensions.TAG
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 dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
/**
* Wraps the system [AccountManager] for Google/microG accounts: enumerating the accounts present
* on-device and minting Play Store AUTH tokens for them.
*/
@Singleton
class GoogleAccountTokenProvider @Inject constructor(
@ApplicationContext private val context: Context
) {
/** E-mails of Google accounts present on-device (e.g. provided by microG). */
fun systemGoogleAccountEmails(): List<String> = try {
AccountManager.get(context).getAccountsByType(GOOGLE_ACCOUNT_TYPE)
.map { it.name }
.distinct()
} catch (e: Exception) {
Log.e(TAG, "Failed to enumerate system Google accounts", e)
emptyList()
}
/**
* Mints a Play Store AUTH token for [email]. Pass an [activity] for interactive requests
* (e.g. adding an account) so microG can show any consent UI it needs; omit it for
* background refreshes, which fall back to the non-interactive overload.
*/
suspend fun fetchAuthToken(
email: String,
oldToken: String? = null,
activity: Activity? = null
): String = suspendCancellableCoroutine { continuation ->
fetchAuthToken(email, oldToken, activity) { future ->
try {
val token = future.result.getString(AccountManager.KEY_AUTHTOKEN)
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(
IllegalStateException("Auth token is null")
)
}
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}
}
private fun fetchAuthToken(
email: String,
oldToken: String?,
activity: Activity?,
callback: AccountManagerCallback<Bundle>
) {
try {
val accountManager = AccountManager.get(context)
if (oldToken != null) {
accountManager.invalidateAuthToken(GOOGLE_ACCOUNT_TYPE, oldToken)
}
val account = Account(email, GOOGLE_ACCOUNT_TYPE)
val options = Bundle().apply {
putString("overridePackage", PACKAGE_NAME_PLAY_STORE)
putByteArray(
"overrideCertificate",
Base64.decode(GOOGLE_PLAY_CERT, Base64.DEFAULT)
)
}
val handler = Handler(Looper.getMainLooper())
if (activity != null) {
accountManager.getAuthToken(
account,
GOOGLE_PLAY_AUTH_TOKEN_TYPE,
options,
activity,
callback,
handler
)
} else {
accountManager.getAuthToken(
account,
GOOGLE_PLAY_AUTH_TOKEN_TYPE,
options,
true,
callback,
handler
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch auth token", e)
callback.run(null)
}
}
}

View File

@@ -3,6 +3,11 @@ package com.aurora.store.data.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.aurora.store.data.room.account.Account
import com.aurora.store.data.room.account.AccountConverter
import com.aurora.store.data.room.account.AccountDao
import com.aurora.store.data.room.account.AppAccountBinding
import com.aurora.store.data.room.account.AppAccountBindingDao
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao
@@ -21,16 +26,20 @@ import com.aurora.store.data.room.update.UpdateDao
Favourite::class,
Update::class,
IgnoredUpdate::class,
LocalReview::class
LocalReview::class,
Account::class,
AppAccountBinding::class
],
version = 9,
version = 10,
exportSchema = true
)
@TypeConverters(DownloadConverter::class)
@TypeConverters(DownloadConverter::class, AccountConverter::class)
abstract class AuroraDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
abstract fun favouriteDao(): FavouriteDao
abstract fun updateDao(): UpdateDao
abstract fun ignoredUpdateDao(): IgnoredUpdateDao
abstract fun reviewDao(): ReviewDao
abstract fun accountDao(): AccountDao
abstract fun appAccountBindingDao(): AppAccountBindingDao
}

View File

@@ -42,6 +42,10 @@ object MigrationHelper {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom8To9(db)
}
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom9To10(db)
}
private const val TAG = "MigrationHelper"
private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
@@ -193,4 +197,48 @@ object MigrationHelper {
database.endTransaction()
}
}
/**
* Add account & app_account_binding tables for multi-account support. The existing single
* account is imported from SharedPreferences in code on first launch (see AccountRepository),
* because a Room Migration cannot read SharedPreferences.
*/
private fun migrateFrom9To10(database: SupportSQLiteDatabase) {
database.beginTransaction()
try {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `account` (" +
"`id` TEXT NOT NULL, " +
"`type` TEXT NOT NULL, " +
"`email` TEXT NOT NULL, " +
"`displayName` TEXT, " +
"`profilePicUrl` TEXT, " +
"`aasToken` TEXT, " +
"`authToken` TEXT, " +
"`tokenType` TEXT NOT NULL, " +
"`authViaMicroG` INTEGER NOT NULL, " +
"`authDataJson` TEXT, " +
"`isDefault` INTEGER NOT NULL, " +
"`addedAt` INTEGER NOT NULL, " +
"PRIMARY KEY(`id`))"
)
database.execSQL(
"CREATE TABLE IF NOT EXISTS `app_account_binding` (" +
"`packageName` TEXT NOT NULL, " +
"`accountId` TEXT NOT NULL, " +
"PRIMARY KEY(`packageName`), " +
"FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_app_account_binding_accountId` " +
"ON `app_account_binding` (`accountId`)"
)
database.setTransactionSuccessful()
} catch (exception: Exception) {
Log.e(TAG, "Failed while migrating from database version 9 to 10", exception)
} finally {
database.endTransaction()
}
}
}

View File

@@ -10,6 +10,10 @@ import com.aurora.store.data.room.MigrationHelper.MIGRATION_5_6
import com.aurora.store.data.room.MigrationHelper.MIGRATION_6_7
import com.aurora.store.data.room.MigrationHelper.MIGRATION_7_8
import com.aurora.store.data.room.MigrationHelper.MIGRATION_8_9
import com.aurora.store.data.room.MigrationHelper.MIGRATION_9_10
import com.aurora.store.data.room.account.AccountConverter
import com.aurora.store.data.room.account.AccountDao
import com.aurora.store.data.room.account.AppAccountBindingDao
import com.aurora.store.data.room.download.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.room.favourite.FavouriteDao
@@ -33,7 +37,8 @@ object RoomModule {
@Provides
fun providesRoomInstance(
@ApplicationContext context: Context,
downloadConverter: DownloadConverter
downloadConverter: DownloadConverter,
accountConverter: AccountConverter
): AuroraDatabase = Room.databaseBuilder(context, AuroraDatabase::class.java, DATABASE)
.addMigrations(
MIGRATION_1_2,
@@ -43,9 +48,11 @@ object RoomModule {
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9
MIGRATION_8_9,
MIGRATION_9_10
)
.addTypeConverter(downloadConverter)
.addTypeConverter(accountConverter)
.build()
@Provides
@@ -65,4 +72,11 @@ object RoomModule {
@Provides
fun providesReviewDao(auroraDatabase: AuroraDatabase): ReviewDao = auroraDatabase.reviewDao()
@Provides
fun providesAccountDao(auroraDatabase: AuroraDatabase): AccountDao = auroraDatabase.accountDao()
@Provides
fun providesAppAccountBindingDao(auroraDatabase: AuroraDatabase): AppAccountBindingDao =
auroraDatabase.appAccountBindingDao()
}

View File

@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.account
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.store.data.model.AccountType
@Entity(tableName = "account")
data class Account(
@PrimaryKey val id: String,
val type: AccountType,
val email: String,
val displayName: String? = null,
val profilePicUrl: String? = null,
val aasToken: String? = null,
val authToken: String? = null,
val tokenType: AuthHelper.Token = AuthHelper.Token.AAS,
val authViaMicroG: Boolean = false,
val authDataJson: String? = null,
val isDefault: Boolean = false,
val addedAt: Long = System.currentTimeMillis()
) {
val isAnonymous: Boolean get() = type == AccountType.ANONYMOUS
companion object {
const val ANONYMOUS_ID = "anonymous"
fun idFor(type: AccountType, email: String): String =
if (type == AccountType.ANONYMOUS) ANONYMOUS_ID else email.lowercase()
}
}

View File

@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.account
import androidx.room.ProvidedTypeConverter
import androidx.room.TypeConverter
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.store.data.model.AccountType
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@ProvidedTypeConverter
class AccountConverter @Inject constructor() {
@TypeConverter
fun toAccountType(value: String): AccountType = AccountType.valueOf(value)
@TypeConverter
fun fromAccountType(type: AccountType): String = type.name
@TypeConverter
fun toToken(value: String): AuthHelper.Token = AuthHelper.Token.valueOf(value)
@TypeConverter
fun fromToken(token: AuthHelper.Token): String = token.name
}

View File

@@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.account
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.aurora.store.data.model.AccountType
import kotlinx.coroutines.flow.Flow
@Dao
interface AccountDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(account: Account)
@Query("SELECT * FROM account ORDER BY addedAt ASC")
fun accounts(): Flow<List<Account>>
@Query("SELECT * FROM account ORDER BY addedAt ASC")
suspend fun getAll(): List<Account>
@Query("SELECT * FROM account WHERE id = :id")
suspend fun getById(id: String): Account?
@Query("SELECT * FROM account WHERE isDefault = 1 LIMIT 1")
suspend fun getDefault(): Account?
@Query("SELECT * FROM account WHERE isDefault = 1 LIMIT 1")
fun observeDefault(): Flow<Account?>
@Query("SELECT COUNT(*) FROM account WHERE type = :type")
suspend fun countByType(type: AccountType): Int
@Query("DELETE FROM account WHERE id = :id")
suspend fun delete(id: String)
@Query("UPDATE account SET isDefault = (id = :id)")
suspend fun setDefaultOnly(id: String)
@Transaction
suspend fun replaceDefault(id: String) = setDefaultOnly(id)
/**
* Deletes [id] and, only if it was the default, promotes a new default (anonymous preferred,
* otherwise the oldest remaining account). Runs in one transaction so observers never see a
* transient null default.
*/
@Transaction
suspend fun deleteAndEnsureDefault(id: String) {
val wasDefault = getById(id)?.isDefault == true
delete(id)
if (wasDefault) {
val remaining = getAll()
val next = remaining.firstOrNull { it.isAnonymous } ?: remaining.firstOrNull()
next?.let { setDefaultOnly(it.id) }
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.account
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "app_account_binding",
foreignKeys = [
ForeignKey(
entity = Account::class,
parentColumns = ["id"],
childColumns = ["accountId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("accountId")]
)
data class AppAccountBinding(
@PrimaryKey val packageName: String,
val accountId: String
)

View File

@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.account
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface AppAccountBindingDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(binding: AppAccountBinding)
@Query("SELECT * FROM app_account_binding WHERE packageName = :packageName")
suspend fun getForPackage(packageName: String): AppAccountBinding?
@Query("DELETE FROM app_account_binding WHERE packageName = :packageName")
suspend fun delete(packageName: String)
@Query("DELETE FROM app_account_binding WHERE accountId = :accountId")
suspend fun deleteByAccountId(accountId: String)
}

View File

@@ -1,32 +1,20 @@
package com.aurora.store.data.work
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.AccountManagerCallback
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.AuthData
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.store.data.model.AccountType
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.data.providers.AuthProvider
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.data.providers.GoogleAccountTokenProvider
import com.aurora.store.util.Preferences
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
/**
* Worker to refresh [AuthData] in background
@@ -35,6 +23,7 @@ import kotlin.coroutines.suspendCoroutine
@HiltWorker
open class AuthWorker @AssistedInject constructor(
private val authProvider: AuthProvider,
private val tokenProvider: GoogleAccountTokenProvider,
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
@@ -73,11 +62,8 @@ open class AuthWorker @AssistedInject constructor(
}
AuthHelper.Token.AUTH -> {
Log.i(
TAG,
"Refreshing AuthData for personal account using AccountManager"
)
val newToken = fetchAuthToken(email, tokenPair.first)
Log.i(TAG, "Refreshing AuthData via AccountManager")
val newToken = tokenProvider.fetchAuthToken(email, tokenPair.first)
authProvider.buildGoogleAuthData(email, newToken, AuthHelper.Token.AAS)
.getOrThrow()
}
@@ -91,6 +77,16 @@ open class AuthWorker @AssistedInject constructor(
}
require(verifyAndSaveAuth(authData, accountType) != null)
authProvider.persistAccount(
authData = authData,
accountType = accountType,
authViaMicroG = Preferences.getBoolean(
context,
Preferences.PREFERENCE_AUTH_VIA_MICROG,
false
),
makeDefault = true
)
Log.i(TAG, "Refreshed AuthData successfully")
return Result.success()
} catch (exception: Exception) {
@@ -99,62 +95,6 @@ open class AuthWorker @AssistedInject constructor(
}
}
private suspend fun fetchAuthToken(email: String, oldToken: String? = null): String =
suspendCoroutine { continuation ->
fetchAuthToken(email, oldToken) { future ->
try {
val bundle = future.result
val token = bundle.getString(AccountManager.KEY_AUTHTOKEN)
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(
IllegalStateException("Auth token is null")
)
}
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}
}
private fun fetchAuthToken(
email: String,
oldToken: String? = null,
callback: AccountManagerCallback<Bundle>
) {
try {
if (oldToken != null) {
// Invalidate the old token before requesting a new one
AccountManager.get(context)
.invalidateAuthToken(
GOOGLE_ACCOUNT_TYPE,
oldToken
)
}
AccountManager.get(context)
.getAuthToken(
Account(email, 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)
)
},
true,
callback,
Handler(Looper.getMainLooper())
)
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch auth token", e)
callback.run(null)
}
}
private fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType): AuthData? =
if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) {
authProvider.saveAuthData(authData)
@@ -168,7 +108,7 @@ open class AuthWorker @AssistedInject constructor(
authData
} else {
authProvider.removeAuthData(context)
AccountProvider.logout(context)
authProvider.logout()
null
}
}

View File

@@ -28,10 +28,12 @@ import com.aurora.extensions.isQAndAbove
import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.requiresObbDir
import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.AuroraApp
import com.aurora.store.R
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.installer.AppInstaller
@@ -40,6 +42,7 @@ import com.aurora.store.data.model.DownloadInfo
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.network.HttpClient
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.GoogleAccountTokenProvider
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.util.CertUtil
@@ -74,14 +77,15 @@ import kotlinx.coroutines.withContext
*/
@HiltWorker
class DownloadWorker @AssistedInject constructor(
authProvider: AuthProvider,
private val authProvider: AuthProvider,
tokenProvider: GoogleAccountTokenProvider,
private val downloadDao: DownloadDao,
private val appInstaller: AppInstaller,
private val httpClient: IHttpClient,
private val purchaseHelper: PurchaseHelper,
private val accountRepository: AccountRepository,
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters
) : AuthWorker(authProvider, context, workerParams) {
) : AuthWorker(authProvider, tokenProvider, context, workerParams) {
companion object {
private const val NOTIFICATION_ID: Int = 200
@@ -92,6 +96,7 @@ class DownloadWorker @AssistedInject constructor(
}
private lateinit var download: Download
private lateinit var purchaseHelper: PurchaseHelper
private val notificationManager = context.getSystemService<NotificationManager>()!!
@@ -130,6 +135,7 @@ class DownloadWorker @AssistedInject constructor(
// Fetch required data for download
try {
download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!)
purchaseHelper = resolvePurchaseHelper(download.packageName)
} catch (exception: Exception) {
return onFailure(exception)
}
@@ -366,6 +372,27 @@ class DownloadWorker @AssistedInject constructor(
}
}
/**
* Builds a [PurchaseHelper] bound to the account this download should use: the app's
* per-app binding if any, otherwise the default account. Refreshes that account's session
* first if it is missing/expired so the purchase isn't rejected.
*/
private suspend fun resolvePurchaseHelper(packageName: String): PurchaseHelper {
val accountId = accountRepository.resolveAccountId(packageName)
var authData = authProvider.getAuthData(accountId)
if (authData == null || !AuthHelper.isValid(authData)) {
// Refresh the resolved account; propagate failure instead of silently falling back to
// a different account, which would purchase a bound app under the wrong identity.
authData = authProvider.refresh(accountId).getOrElse { error ->
throw IllegalStateException(
"Could not refresh session for account $accountId; re-authentication required",
error
)
}
}
return PurchaseHelper(authData).using(httpClient)
}
/**
* Purchases the app to get the download URL of the required files
* @param packageName The packageName of the app

View File

@@ -26,6 +26,7 @@ import com.aurora.store.data.network.HttpClient
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.BlacklistProvider
import com.aurora.store.data.providers.GoogleAccountTokenProvider
import com.aurora.store.data.room.update.Update
import com.aurora.store.data.room.update.UpdateDao
import com.aurora.store.util.CertUtil
@@ -61,10 +62,11 @@ class UpdateWorker @AssistedInject constructor(
private val updateDao: UpdateDao,
private val downloadHelper: DownloadHelper,
private val authProvider: AuthProvider,
tokenProvider: GoogleAccountTokenProvider,
private val appDetailsHelper: AppDetailsHelper,
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters
) : AuthWorker(authProvider, context, workerParams) {
) : AuthWorker(authProvider, tokenProvider, context, workerParams) {
companion object {
private const val NOTIFICATION_ID = 100

View File

@@ -3,7 +3,6 @@ package com.aurora.store.module
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.CategoryHelper
import com.aurora.gplayapi.helpers.ExpandedBrowseHelper
import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.gplayapi.helpers.SearchHelper
import com.aurora.gplayapi.helpers.StreamHelper
@@ -77,14 +76,6 @@ object HelperModule {
): SearchHelper = SearchHelper(authProvider.authData!!)
.using(httpClient)
@Singleton
@Provides
fun providesPurchaseHelperInstance(
authProvider: AuthProvider,
httpClient: IHttpClient
): PurchaseHelper = PurchaseHelper(authProvider.authData!!)
.using(httpClient)
@Singleton
@Provides
fun providesWebStreamHelperInstance(

View File

@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.util
import android.content.Context
import com.jakewharton.processphoenix.ProcessPhoenix
object RestartUtil {
/**
* Restarts the whole process so all Hilt @Singletons (incl. the gplayapi helpers, which are
* bound to the previous default account's AuthData at construction) are rebuilt against the
* newly selected default account. Uses ProcessPhoenix, matching the app's other restart sites.
*/
fun restart(context: Context) {
ProcessPhoenix.triggerRebirth(context)
}
}

View File

@@ -5,10 +5,124 @@
package com.aurora.store.viewmodel.accounts
import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.model.AccountType
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.GoogleAccountTokenProvider
import com.aurora.store.data.room.account.Account
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AccountsViewModel @Inject constructor(val authProvider: AuthProvider) : ViewModel()
class AccountsViewModel @Inject constructor(
val authProvider: AuthProvider,
private val accountRepository: AccountRepository,
private val tokenProvider: GoogleAccountTokenProvider
) : ViewModel() {
val accounts = accountRepository.accounts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Emits true when the default switched (caller should restart), false on failure. */
private val _switched = MutableSharedFlow<Boolean>()
val switched = _switched.asSharedFlow()
/** Post-removal navigation signal for the screen. */
enum class RemoveResult { RESTART, LOGGED_OUT }
private val _removeResult = MutableSharedFlow<RemoveResult>()
val removeResult = _removeResult.asSharedFlow()
/** Emits the outcome of an account add (true = added) so the screen can dismiss progress. */
private val _addResult = MutableSharedFlow<Boolean>()
val addResult = _addResult.asSharedFlow()
/** Number of accounts that failed to refresh in the last refresh-all (0 = all succeeded). */
private val _refreshResult = MutableSharedFlow<Int>()
val refreshResult = _refreshResult.asSharedFlow()
/** Refreshes the session/token for every account; the default's prefs mirror is re-synced. */
fun refreshAll(activity: Activity?) {
viewModelScope.launch(Dispatchers.IO) {
val all = accountRepository.getAll()
val failures = all.count { authProvider.refresh(it.id, activity).isFailure }
authProvider.syncDefaultToPrefs()
_refreshResult.emit(failures)
}
}
fun setDefault(account: Account) {
viewModelScope.launch(Dispatchers.IO) {
val ok = authProvider.setDefaultAccount(account.id).isSuccess
_switched.emit(ok)
}
}
fun remove(account: Account) {
viewModelScope.launch(Dispatchers.IO) {
val wasDefault = account.isDefault
accountRepository.removeAccount(account.id)
// Keep the legacy login gate / refresh prefs in sync with the new DB default (or clear
// them when nothing is left) — otherwise removing the active account doesn't log out.
authProvider.syncDefaultToPrefs()
when {
accountRepository.getDefault() == null -> _removeResult.emit(
RemoveResult.LOGGED_OUT
)
wasDefault -> _removeResult.emit(RemoveResult.RESTART)
else -> Unit
}
}
}
/** Adds an anonymous account (non-default) so it can be used without making it active. */
fun addAnonymousAccount() {
viewModelScope.launch(Dispatchers.IO) {
val added = runCatching {
val authData = authProvider.buildAnonymousAuthData().getOrThrow()
authProvider.persistAccount(
authData = authData,
accountType = AccountType.ANONYMOUS,
authViaMicroG = false,
makeDefault = false
)
}.isSuccess
_addResult.emit(added)
}
}
/** Google accounts present on-device (via microG) that aren't already added. */
suspend fun discoverableEmails(): List<String> {
val existing = accountRepository.getAll().map { it.email.lowercase() }.toSet()
return tokenProvider.systemGoogleAccountEmails().filter { it.lowercase() !in existing }
}
/** Adds a device (microG) Google account by minting an AUTH token and building AuthData. */
fun addSystemAccount(email: String, activity: Activity?) {
viewModelScope.launch(Dispatchers.IO) {
val added = runCatching {
val token = tokenProvider.fetchAuthToken(email = email, activity = activity)
val authData = authProvider
.buildGoogleAuthData(email, token, AuthHelper.Token.AUTH)
.getOrThrow()
authProvider.persistAccount(
authData = authData,
accountType = AccountType.GOOGLE,
authViaMicroG = true,
makeDefault = false
)
}.isSuccess
_addResult.emit(added)
}
}
}

View File

@@ -43,7 +43,9 @@ import java.net.ConnectException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@@ -57,6 +59,10 @@ class AuthViewModel @Inject constructor(
private val _authState: MutableStateFlow<AuthState> = MutableStateFlow(AuthState.Init)
val authState = _authState.asStateFlow()
/** Emits the outcome of adding a Google account (non-default) so the caller can navigate back. */
private val _accountAdded = MutableSharedFlow<Boolean>()
val accountAdded = _accountAdded.asSharedFlow()
init {
updateAuthState()
}
@@ -77,6 +83,31 @@ class AuthViewModel @Inject constructor(
}
}
/**
* Adds a Google account WITHOUT changing the active default (used by the account screen's
* "add account" flow). Does not touch [authState], so the current session/UI is unaffected.
* Re-syncs the prefs to the real default afterwards, because the AC2DM step
* ([buildAuthData]) writes the new account's e-mail/token into the legacy prefs mid-flow.
*/
fun addGoogleAuthData(email: String, token: String) {
viewModelScope.launch(Dispatchers.IO) {
val added = runCatching {
val authData = authProvider
.buildGoogleAuthData(email, token, AuthHelper.Token.AAS)
.getOrThrow()
require(authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty())
authProvider.persistAccount(
authData = authData,
accountType = AccountType.GOOGLE,
authViaMicroG = false,
makeDefault = false
)
}.isSuccess
authProvider.syncDefaultToPrefs()
_accountAdded.emit(added)
}
}
fun buildAnonymousAuthData() {
_authState.value = AuthState.Fetching
viewModelScope.launch(Dispatchers.IO) {
@@ -178,7 +209,7 @@ class AuthViewModel @Inject constructor(
}
}
private fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType) {
private suspend fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType) {
_authState.value = AuthState.Verifying
if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) {
authProvider.saveAuthData(authData)
@@ -202,10 +233,20 @@ class AuthViewModel @Inject constructor(
tokenType == AuthHelper.Token.AUTH &&
PackageUtil.hasSupportedMicroGVariant(context)
)
authProvider.persistAccount(
authData = authData,
accountType = accountType,
authViaMicroG = Preferences.getBoolean(
context,
Preferences.PREFERENCE_AUTH_VIA_MICROG,
false
),
makeDefault = true
)
_authState.value = AuthState.SignedIn
} else {
authProvider.removeAuthData(context)
AccountProvider.logout(context)
authProvider.logout()
_authState.value =
AuthState.Failed(context.getString(R.string.failed_to_generate_session))
}

View File

@@ -27,6 +27,7 @@ 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.AccountRepository
import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper
@@ -81,7 +82,8 @@ class AppDetailsViewModel @Inject constructor(
private val favouriteDao: FavouriteDao,
private val reviewDao: ReviewDao,
private val httpClient: IHttpClient,
private val json: Json
private val json: Json,
private val accountRepository: AccountRepository
) : ViewModel() {
private val _app = MutableStateFlow<App?>(null)
@@ -321,12 +323,19 @@ class AppDetailsViewModel @Inject constructor(
}
}
val accounts = accountRepository.accounts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun enqueueDownload(app: App) {
viewModelScope.launch(Dispatchers.IO) {
downloadHelper.enqueueApp(app)
}
}
fun enqueueDownloadWith(app: App, accountId: String) {
viewModelScope.launch(Dispatchers.IO) { downloadHelper.enqueueApp(app, accountId) }
}
fun cancelDownload(app: App) {
viewModelScope.launch { downloadHelper.cancelDownload(app.packageName) }
}

View File

@@ -7,18 +7,28 @@ package com.aurora.store.viewmodel.sheets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.helper.UpdateHelper
import com.aurora.store.data.providers.BlacklistProvider
import com.aurora.store.data.room.update.Update
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AppUpdateViewModel @Inject constructor(
val blacklistProvider: BlacklistProvider,
private val updateHelper: UpdateHelper
private val updateHelper: UpdateHelper,
private val downloadHelper: DownloadHelper,
accountRepository: AccountRepository
) : ViewModel() {
val accounts = accountRepository.accounts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun ignoreAllUpdates(packageName: String) {
viewModelScope.launch { updateHelper.ignoreAll(packageName) }
}
@@ -26,4 +36,9 @@ class AppUpdateViewModel @Inject constructor(
fun ignoreVersion(packageName: String, versionCode: Long) {
viewModelScope.launch { updateHelper.ignoreVersion(packageName, versionCode) }
}
/** Downloads this update using a chosen account, recording the per-app binding. */
fun updateWithAccount(update: Update, accountId: String) {
viewModelScope.launch { downloadHelper.enqueueUpdate(update, accountId) }
}
}

View File

@@ -5,6 +5,7 @@ import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import com.aurora.extensions.TAG
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.NativeDeviceInfoProvider
import com.aurora.store.data.providers.SpoofProvider
import com.aurora.store.util.PathUtil
@@ -19,9 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow
@HiltViewModel
class SpoofViewModel @Inject constructor(
private val spoofProvider: SpoofProvider,
private val authProvider: AuthProvider,
@ApplicationContext private val context: Context
) : ViewModel() {
/** Full sign-out, clearing both the account DB rows and the legacy prefs. */
fun logout() = authProvider.logout()
val defaultLocale: Locale = Locale.getDefault()
val defaultProperties = NativeDeviceInfoProvider.getNativeDeviceProperties(context)

View File

@@ -49,6 +49,25 @@
<string name="account_google">"Google"</string>
<string name="account_login_using">Log in using</string>
<string name="account_logout">Log out from Aurora</string>
<string name="account_set_default">Set as default</string>
<string name="account_default">Default</string>
<string name="account_remove">Remove</string>
<string name="account_remove_title">Remove account?</string>
<string name="account_remove_message">Remove %1$s from Aurora Store?</string>
<string name="account_remove_last_message">This is your only account. Removing it will log you out, and you won\'t be able to use Aurora Store until you sign in again.</string>
<string name="account_add">Add account</string>
<string name="account_add_device">Device accounts</string>
<string name="account_add_google">Sign in with Google</string>
<string name="account_add_anonymous">Add anonymous account</string>
<string name="account_set_default_title">Switch default account?</string>
<string name="account_set_default_message">Aurora Store will restart to use the new default account.</string>
<string name="account_switching">Switching account…</string>
<string name="account_adding">Adding account…</string>
<string name="account_refresh_all">Refresh all accounts</string>
<string name="account_refreshing">Refreshing accounts…</string>
<string name="account_refresh_done">All accounts refreshed</string>
<string name="account_switch_failed">Couldn\'t refresh that account, so the default wasn\'t changed.</string>
<string name="account_add_failed">Couldn\'t add that account. Please try again.</string>
<string name="action_back">"Back"</string>
<string name="action_blacklist">"Blacklist"</string>
<string name="action_blacklist_add">"Add to blacklist"</string>
@@ -79,6 +98,7 @@
<string name="action_install">"Install"</string>
<string name="action_install_all">"Install all"</string>
<string name="action_install_microG">"Install microG Bundle"</string>
<string name="action_switch_account">Switch account</string>
<string name="action_installations">"Installations"</string>
<string name="action_installing">"Installing"</string>
<string name="action_join">"Join"</string>
@@ -613,6 +633,14 @@
<item quantity="one">%1$d favourite</item>
<item quantity="other">%1$d favourites</item>
</plurals>
<plurals name="account_count">
<item quantity="one">Total %1$d account</item>
<item quantity="other">Total %1$d accounts</item>
</plurals>
<plurals name="account_refresh_failed">
<item quantity="one">Couldn\'t refresh %1$d account</item>
<item quantity="other">Couldn\'t refresh %1$d accounts</item>
</plurals>
<plurals name="notification_installed_summary">
<item quantity="one">%d app installed</item>
<item quantity="other">%d apps installed</item>