Merge branch 'dev' into 'master'

dev

See merge request AuroraOSS/AuroraStore!585
This commit is contained in:
Rahul Patel
2026-06-09 00:12:58 +05:30
46 changed files with 2544 additions and 307 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

@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
@@ -51,6 +52,7 @@ fun CategoryItem(modifier: Modifier = Modifier, category: Category, onClick: ()
.build(), .build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = Modifier modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_category)) .requiredSize(dimensionResource(R.dimen.icon_size_category))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small)))

View File

@@ -31,7 +31,7 @@ sealed class Destination {
data class PermissionRationale(val permissions: Set<PermissionType>) : Destination() data class PermissionRationale(val permissions: Set<PermissionType>) : Destination()
data object Accounts : Destination() data object Accounts : Destination()
data object GoogleLogin : Destination() data class GoogleLogin(val addAccount: Boolean = false) : Destination()
data object About : Destination() data object About : Destination()
data object Favourite : Destination() data object Favourite : Destination()
data object Spoof : 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.event.InstallerEvent
import com.aurora.store.data.model.AccountType import com.aurora.store.data.model.AccountType
import com.aurora.store.data.providers.AccountProvider 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.PackageUtil
import com.aurora.store.util.Preferences 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 * Navigation display for compose screens
@@ -83,7 +97,11 @@ fun NavDisplay(startDestination: NavKey) {
fun handleMicroGRemoved() { fun handleMicroGRemoved() {
context.toast(R.string.microg_removed_auth_warning) 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 { val intent = Intent(context, ComposeActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) 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.DevProfile -> backstack.add(Screen.DevProfile(destination.devId))
is Destination.AppUpdate -> Unit is Destination.AppUpdate -> Unit
is Destination.StreamBrowse -> backstack.add(Screen.StreamBrowse(destination.cluster)) 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.Search -> backstack.add(Screen.Search)
Destination.Downloads -> backstack.add(Screen.Downloads) Destination.Downloads -> backstack.add(Screen.Downloads)
Destination.Accounts -> backstack.add(Screen.Accounts) Destination.Accounts -> backstack.add(Screen.Accounts)
Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin)
Destination.About -> backstack.add(Screen.About) Destination.About -> backstack.add(Screen.About)
Destination.Favourite -> backstack.add(Screen.Favourite) Destination.Favourite -> backstack.add(Screen.Favourite)
Destination.Spoof -> backstack.add(Screen.Spoof) Destination.Spoof -> backstack.add(Screen.Spoof)
@@ -279,11 +297,18 @@ fun NavDisplay(startDestination: NavKey) {
onNavigateTo = ::navigate onNavigateTo = ::navigate
) )
} }
entry<Screen.GoogleLogin> { screen ->
GoogleLoginScreen(
addAccount = screen.addAccount,
onNavigateTo = ::navigate
)
}
entry<Screen.Onboarding> { OnboardingScreen() } entry<Screen.Onboarding> { OnboardingScreen() }
entry<Screen.Blacklist> { BlacklistScreen() } entry<Screen.Blacklist> { BlacklistScreen() }
entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) } entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) }
entry<Screen.Accounts> { AccountsScreen(onNavigateTo = ::navigate) } entry<Screen.Accounts> { AccountsScreen(onNavigateTo = ::navigate) }
entry<Screen.GoogleLogin> { GoogleLoginScreen(onNavigateTo = ::navigate) }
entry<Screen.About> { AboutScreen() } entry<Screen.About> { AboutScreen() }
entry<Screen.Favourite> { FavouriteScreen(onNavigateTo = ::navigate) } entry<Screen.Favourite> { FavouriteScreen(onNavigateTo = ::navigate) }
entry<Screen.Spoof> { SpoofScreen(onNavigateTo = ::navigate) } entry<Screen.Spoof> { SpoofScreen(onNavigateTo = ::navigate) }

View File

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

View File

@@ -5,107 +5,319 @@
package com.aurora.store.compose.ui.accounts 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip 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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import coil3.compose.AsyncImage import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.Constants.URL_DISCLAIMER import com.aurora.Constants.URL_DISCLAIMER
import com.aurora.Constants.URL_LICENSE import com.aurora.Constants.URL_LICENSE
import com.aurora.Constants.URL_TOS import com.aurora.Constants.URL_TOS
import com.aurora.extensions.browse import com.aurora.extensions.browse
import com.aurora.store.R 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.composable.TopAppBar
import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.preview.ThemePreviewProvider 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 import com.aurora.store.viewmodel.accounts.AccountsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AccountsScreen( fun AccountsScreen(
onNavigateTo: (Destination) -> Unit, onNavigateTo: (Destination) -> Unit,
viewModel: AccountsViewModel = hiltViewModel() 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 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) { LaunchedEffect(Unit) {
LogoutDialog( viewModel.switched.collect { ok ->
onConfirm = { if (ok) {
shouldShowLogoutDialog = false RestartUtil.restart(context)
AccountProvider.logout(context) } else {
onNavigateTo(Destination.Splash()) isSwitchingDefault = false
}, Toast.makeText(
onDismiss = { shouldShowLogoutDialog = false } 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( ScreenContent(
avatar = avatar, accounts = accounts,
name = name, onAccountClick = { selectedAccount = it },
email = email, onAddAccount = { showAddSheet = true },
onLogout = { shouldShowLogoutDialog = 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 @Composable
private fun ScreenContent( private fun ScreenContent(
avatar: Any = R.drawable.ic_account, accounts: List<Account> = emptyList(),
name: String = stringResource(R.string.account_anonymous), onAccountClick: (Account) -> Unit = {},
email: String = stringResource(R.string.account_anonymous_email), onAddAccount: () -> Unit = {},
onLogout: () -> Unit = {} onRefreshAll: () -> Unit = {}
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = stringResource(R.string.title_account_manager) title = stringResource(R.string.title_account_manager),
actions = { OverflowMenu(onRefreshAll = onRefreshAll) }
) )
} }
) { paddingValues -> ) { paddingValues ->
@@ -113,64 +325,21 @@ private fun ScreenContent(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize() .fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.spacing_small)),
verticalArrangement = Arrangement.SpaceBetween
) { ) {
AssistHeader() SectionHeader(
title = pluralStringResource(R.plurals.account_count, accounts.size, accounts.size),
trailing = {
TextButton(onClick = onAddAccount) {
Text(text = stringResource(R.string.account_add))
}
}
)
Column( LazyColumn(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(), items(items = accounts, key = { it.id }) { account ->
horizontalAlignment = Alignment.CenterHorizontally, AccountListItem(
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) account = account,
) { onClick = { onAccountClick(account) }
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
)
}
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)
) )
} }
} }
@@ -179,26 +348,80 @@ private fun ScreenContent(
} }
@Composable @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 val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
val links = mapOf( val links = mapOf(
R.string.menu_terms to URL_TOS, R.string.menu_terms to URL_TOS,
R.string.menu_disclaimer to URL_DISCLAIMER, R.string.menu_disclaimer to URL_DISCLAIMER,
R.string.menu_license to URL_LICENSE R.string.menu_license to URL_LICENSE
) )
LazyRow( Box {
modifier = Modifier IconButton(onClick = { expanded = true }) {
.fillMaxWidth() Icon(
.padding(horizontal = dimensionResource(R.dimen.spacing_medium)), painter = painterResource(R.drawable.ic_more_vert),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) contentDescription = stringResource(R.string.menu)
) {
items(items = links.keys.toList(), key = { item -> item }) { label ->
AssistChip(
onClick = { context.browse(links.getValue(label)) },
label = { Text(text = stringResource(label)) }
) )
} }
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 @Composable
fun GoogleLoginScreen( fun GoogleLoginScreen(
onNavigateTo: (Destination) -> Unit, onNavigateTo: (Destination) -> Unit,
addAccount: Boolean = false,
viewModel: AuthViewModel = hiltViewModel() viewModel: AuthViewModel = hiltViewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -66,29 +67,47 @@ fun GoogleLoginScreen(
AuroraApp.events.authEvent.collect { event -> AuroraApp.events.authEvent.collect { event ->
if (event is AuthEvent.GoogleLogin) { if (event is AuthEvent.GoogleLogin) {
if (event.success) { if (event.success) {
viewModel.buildGoogleAuthData( if (addAccount) {
event.email, viewModel.addGoogleAuthData(event.email, event.token)
event.token, } else {
AuthHelper.Token.AAS viewModel.buildGoogleAuthData(
) event.email,
event.token,
AuthHelper.Token.AAS
)
}
} else { } else {
Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG) Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG)
.show() .show()
onNavigateTo(Destination.Splash()) onNavigateTo(if (addAccount) Destination.Accounts else Destination.Splash())
} }
} }
} }
} }
LaunchedEffect(authState) { // In add-account mode the user is already signed in (authState is Valid), so don't let the
when (authState) { // authState effect bounce to Main. Navigate back to the account screen once the add completes.
AuthState.SignedIn, AuthState.Valid -> onNavigateTo( if (addAccount) {
Destination.Main( LaunchedEffect(Unit) {
Preferences.getInteger(context, Preferences.PREFERENCE_DEFAULT_SELECTED_TAB) 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(
Destination.Main(
Preferences.getInteger(context, Preferences.PREFERENCE_DEFAULT_SELECTED_TAB)
)
) )
) is AuthState.Failed -> onNavigateTo(Destination.Splash())
is AuthState.Failed -> onNavigateTo(Destination.Splash()) else -> Unit
else -> Unit }
} }
} }

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

@@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-FileCopyrightText: 2026 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.commons
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Intent
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
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 com.aurora.Constants.PACKAGE_NAME_GMS
import com.aurora.extensions.TAG
import com.aurora.store.R
import com.aurora.store.compose.preview.ThemePreviewProvider
private const val MICROG_SETTINGS_ACTIVITY = "org.microg.gms.ui.SettingsActivity"
/**
* Dialog informing users about the prerequisite for using the microG installer
* @param onConfirm Callback on confirmation
* @param onDismiss Callback on dismissal
*/
@Composable
fun MicroGInstallerPrerequisiteDialog(onConfirm: () -> Unit = {}, onDismiss: () -> Unit = {}) {
val context = LocalContext.current
AlertDialog(
title = { Text(text = stringResource(R.string.microg_installer_prerequisite_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_small)
)
) {
Text(text = stringResource(R.string.microg_installer_prerequisite_desc))
OutlinedButton(
onClick = {
try {
context.startActivity(
Intent().apply {
component = ComponentName(
PACKAGE_NAME_GMS,
MICROG_SETTINGS_ACTIVITY
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (_: ActivityNotFoundException) {
Log.i(TAG, "Unable to launch microG settings")
}
}
) {
Text(text = stringResource(R.string.microg_installer_open_settings))
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.action_cancel))
}
}
)
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun MicroGInstallerPrerequisiteDialogPreview() {
MicroGInstallerPrerequisiteDialog()
}

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,9 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
@@ -28,6 +30,7 @@ import com.aurora.store.R
import com.aurora.store.compose.composable.InstallerListItem import com.aurora.store.compose.composable.InstallerListItem
import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.commons.MicroGInstallerPrerequisiteDialog
import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.installer.SessionInstaller import com.aurora.store.data.installer.SessionInstaller
import com.aurora.store.data.model.Installer import com.aurora.store.data.model.Installer
@@ -61,6 +64,17 @@ private fun ScreenContent(
onInstallerSelected: (installer: Installer) -> Unit = {} onInstallerSelected: (installer: Installer) -> Unit = {}
) { ) {
val snackBarHostState = remember { snackBarHostState } val snackBarHostState = remember { snackBarHostState }
var showMicroGPrerequisite by remember { mutableStateOf(false) }
if (showMicroGPrerequisite) {
MicroGInstallerPrerequisiteDialog(
onConfirm = {
showMicroGPrerequisite = false
onInstallerSelected(Installer.MICROG)
},
onDismiss = { showMicroGPrerequisite = false }
)
}
Scaffold( Scaffold(
snackbarHost = { snackbarHost = {
@@ -82,7 +96,13 @@ private fun ScreenContent(
InstallerListItem( InstallerListItem(
installerInfo = installerInfo, installerInfo = installerInfo,
isSelected = installerInfo.installer == currentInstaller, isSelected = installerInfo.installer == currentInstaller,
onClick = { onInstallerSelected(installerInfo.installer) } onClick = {
if (installerInfo.installer == Installer.MICROG) {
showMicroGPrerequisite = true
} else {
onInstallerSelected(installerInfo.installer)
}
}
) )
} }
} }

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 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
@@ -20,21 +27,28 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.crossfade import coil3.request.crossfade
@@ -45,6 +59,7 @@ import com.aurora.store.R
import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.event.BusEvent import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.installer.AppInstaller 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.data.room.update.Update
import com.aurora.store.viewmodel.sheets.AppUpdateViewModel import com.aurora.store.viewmodel.sheets.AppUpdateViewModel
@@ -58,6 +73,8 @@ fun AppUpdateSheet(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(update.packageName) val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(update.packageName)
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
var showAccounts by remember { mutableStateOf(false) }
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, 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( HorizontalDivider(
modifier = Modifier.padding( modifier = Modifier.padding(
vertical = dimensionResource(R.dimen.spacing_small) 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 @Composable
private fun ChangelogSection(html: String) { private fun ChangelogSection(html: String) {
Text( Text(

View File

@@ -279,7 +279,7 @@ fun SplashScreen(
) )
) )
} else { } 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.MenuItem
import com.aurora.store.compose.ui.spoof.menu.SpoofMenu import com.aurora.store.compose.ui.spoof.menu.SpoofMenu
import com.aurora.store.compose.ui.spoof.navigation.SpoofPage import com.aurora.store.compose.ui.spoof.navigation.SpoofPage
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.viewmodel.spoof.SpoofViewModel import com.aurora.store.viewmodel.spoof.SpoofViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -49,7 +48,8 @@ fun SpoofScreen(onNavigateTo: (Destination) -> Unit, viewModel: SpoofViewModel =
ScreenContent( ScreenContent(
onNavigateTo = onNavigateTo, onNavigateTo = onNavigateTo,
onDeviceSpoofImport = { uri -> viewModel.importDeviceSpoof(uri) }, 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), pages: List<SpoofPage> = listOf(SpoofPage.DEVICE, SpoofPage.LOCALE),
onNavigateTo: (Destination) -> Unit = {}, onNavigateTo: (Destination) -> Unit = {},
onDeviceSpoofImport: (uri: Uri) -> Unit = {}, onDeviceSpoofImport: (uri: Uri) -> Unit = {},
onDeviceSpoofExport: (uri: Uri) -> Unit = {} onDeviceSpoofExport: (uri: Uri) -> Unit = {},
onLogout: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val pagerState = rememberPagerState { pages.size } val pagerState = rememberPagerState { pages.size }
@@ -95,7 +96,7 @@ private fun ScreenContent(
) )
when (result) { when (result) {
SnackbarResult.ActionPerformed -> { SnackbarResult.ActionPerformed -> {
AccountProvider.logout(context) onLogout()
onNavigateTo(Destination.Splash()) 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.extensions.TAG
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.model.DownloadStatus
@@ -40,7 +41,8 @@ import kotlinx.coroutines.launch
class DownloadHelper @Inject constructor( class DownloadHelper @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val downloadDao: DownloadDao, private val downloadDao: DownloadDao,
private val appInstaller: AppInstaller private val appInstaller: AppInstaller,
private val accountRepository: AccountRepository
) { ) {
companion object { companion object {
@@ -158,6 +160,15 @@ class DownloadHelper @Inject constructor(
enqueue(Download.fromApp(app)) 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 * Enqueues an update for download & install
* @param update [Update] to download * @param update [Update] to download
@@ -166,6 +177,15 @@ class DownloadHelper @Inject constructor(
enqueue(Download.fromUpdate(update)) 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 * Enqueues ExternalApk for download & install
* @param externalApk [ExternalApk] to download * @param externalApk [ExternalApk] to download

View File

@@ -41,6 +41,7 @@ import com.aurora.store.data.installer.base.IInstaller
import com.aurora.store.data.model.Installer import com.aurora.store.data.model.Installer
import com.aurora.store.data.model.InstallerInfo import com.aurora.store.data.model.InstallerInfo
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
import com.aurora.store.util.PackageUtil.hasMicroGCompanion
import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_INSTALLER_ID import com.aurora.store.util.Preferences.PREFERENCE_INSTALLER_ID
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
@@ -83,7 +84,7 @@ class AppInstaller @Inject constructor(
if (hasAuroraService(context)) ServiceInstaller.installerInfo else null, if (hasAuroraService(context)) ServiceInstaller.installerInfo else null,
if (hasAppManager(context)) AMInstaller.installerInfo else null, if (hasAppManager(context)) AMInstaller.installerInfo else null,
if (hasShizukuOrSui(context)) ShizukuInstaller.installerInfo else null, if (hasShizukuOrSui(context)) ShizukuInstaller.installerInfo else null,
if (hasMicroGInstaller(context)) MicroGInstaller.installerInfo else null if (hasMicroGCompanion(context)) MicroGInstaller.installerInfo else null
) )
/** /**
@@ -154,6 +155,7 @@ class AppInstaller @Inject constructor(
false false
} }
// TODO: Use microG's proposed API instead of relying on a hardcoded metadata flag that can be misleading (e.g. user can enable the installer from UI)
fun hasMicroGInstaller(context: Context): Boolean { fun hasMicroGInstaller(context: Context): Boolean {
if (!PackageUtil.hasMicroGCompanion(context)) return false if (!PackageUtil.hasMicroGCompanion(context)) return false
return try { return try {
@@ -236,7 +238,7 @@ class AppInstaller @Inject constructor(
defaultInstaller defaultInstaller
} }
Installer.MICROG -> if (hasMicroGInstaller(context)) { Installer.MICROG -> if (hasMicroGCompanion(context)) {
microGInstaller microGInstaller
} else { } else {
defaultInstaller defaultInstaller

View File

@@ -31,6 +31,7 @@ import com.aurora.store.data.installer.base.InstallerBase
import com.aurora.store.data.model.Installer import com.aurora.store.data.model.Installer
import com.aurora.store.data.model.InstallerInfo import com.aurora.store.data.model.InstallerInfo
import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download
import com.aurora.store.util.PackageUtil.hasMicroGCompanion
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -68,7 +69,7 @@ class MicroGInstaller @Inject constructor(
Log.i(TAG, "${download.packageName} already queued") Log.i(TAG, "${download.packageName} already queued")
} }
AppInstaller.hasMicroGInstaller(context) -> { hasMicroGCompanion(context) -> {
Log.i(TAG, "Received microG install request for ${download.packageName}") Log.i(TAG, "Received microG install request for ${download.packageName}")
val files = getFiles(download.packageName, download.versionCode) val files = getFiles(download.packageName, download.versionCode)

View File

@@ -19,16 +19,21 @@
package com.aurora.store.data.providers package com.aurora.store.data.providers
import android.app.Activity
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.aurora.Constants
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.AuthData
import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.data.models.PlayResponse
import com.aurora.gplayapi.helpers.AuthHelper import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.gplayapi.network.IHttpClient import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.AuroraApp
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.model.AccountType import com.aurora.store.data.model.AccountType
import com.aurora.store.data.model.Auth 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
import com.aurora.store.util.Preferences.PREFERENCE_AUTH_DATA import com.aurora.store.util.Preferences.PREFERENCE_AUTH_DATA
import com.aurora.store.util.Preferences.PREFERENCE_DISPENSER_URLS 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -44,8 +51,25 @@ class AuthProvider @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val json: Json, private val json: Json,
private val spoofProvider: SpoofProvider, 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? val dispenserURL: String?
get() { get() {
@@ -53,19 +77,32 @@ class AuthProvider @Inject constructor(
return if (dispensers.isNotEmpty()) dispensers.random() else null 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? val authData: AuthData?
get() { get() = decodeAuthData(defaultAccountSnapshot())
Log.i(TAG, "Loading saved AuthData")
val rawAuth: String = Preferences.getString(context, PREFERENCE_AUTH_DATA)
return if (rawAuth.isNotBlank()) {
json.decodeFromString<AuthData>(rawAuth)
} else {
AuthData("BOGUS")
}
}
/** Whether the default account is anonymous. Served from the cache when warm (see [init]). */
val isAnonymous: Boolean val isAnonymous: Boolean
get() = AccountProvider.getAccountType(context) == AccountType.ANONYMOUS 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 {
runBlocking {
importLegacyAccountIfNeeded()
accountRepository.getDefault()
}
}
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 * 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] * Saves given [AuthData]
*/ */
@@ -148,6 +307,39 @@ class AuthProvider @Inject constructor(
Preferences.remove(context, PREFERENCE_AUTH_DATA) 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) @Throws(Exception::class)
private fun throwError(playResponse: PlayResponse, context: Context) { private fun throwError(playResponse: PlayResponse, context: Context) {
when (playResponse.code) { 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.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters 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.Download
import com.aurora.store.data.room.download.DownloadConverter import com.aurora.store.data.room.download.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.data.room.download.DownloadDao
@@ -21,16 +26,20 @@ import com.aurora.store.data.room.update.UpdateDao
Favourite::class, Favourite::class,
Update::class, Update::class,
IgnoredUpdate::class, IgnoredUpdate::class,
LocalReview::class LocalReview::class,
Account::class,
AppAccountBinding::class
], ],
version = 9, version = 10,
exportSchema = true exportSchema = true
) )
@TypeConverters(DownloadConverter::class) @TypeConverters(DownloadConverter::class, AccountConverter::class)
abstract class AuroraDatabase : RoomDatabase() { abstract class AuroraDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao abstract fun downloadDao(): DownloadDao
abstract fun favouriteDao(): FavouriteDao abstract fun favouriteDao(): FavouriteDao
abstract fun updateDao(): UpdateDao abstract fun updateDao(): UpdateDao
abstract fun ignoredUpdateDao(): IgnoredUpdateDao abstract fun ignoredUpdateDao(): IgnoredUpdateDao
abstract fun reviewDao(): ReviewDao 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) 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 const val TAG = "MigrationHelper"
private fun migrateFrom1To2(database: SupportSQLiteDatabase) { private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
@@ -193,4 +197,48 @@ object MigrationHelper {
database.endTransaction() 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_6_7
import com.aurora.store.data.room.MigrationHelper.MIGRATION_7_8 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_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.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.room.favourite.FavouriteDao import com.aurora.store.data.room.favourite.FavouriteDao
@@ -33,7 +37,8 @@ object RoomModule {
@Provides @Provides
fun providesRoomInstance( fun providesRoomInstance(
@ApplicationContext context: Context, @ApplicationContext context: Context,
downloadConverter: DownloadConverter downloadConverter: DownloadConverter,
accountConverter: AccountConverter
): AuroraDatabase = Room.databaseBuilder(context, AuroraDatabase::class.java, DATABASE) ): AuroraDatabase = Room.databaseBuilder(context, AuroraDatabase::class.java, DATABASE)
.addMigrations( .addMigrations(
MIGRATION_1_2, MIGRATION_1_2,
@@ -43,9 +48,11 @@ object RoomModule {
MIGRATION_5_6, MIGRATION_5_6,
MIGRATION_6_7, MIGRATION_6_7,
MIGRATION_7_8, MIGRATION_7_8,
MIGRATION_8_9 MIGRATION_8_9,
MIGRATION_9_10
) )
.addTypeConverter(downloadConverter) .addTypeConverter(downloadConverter)
.addTypeConverter(accountConverter)
.build() .build()
@Provides @Provides
@@ -65,4 +72,11 @@ object RoomModule {
@Provides @Provides
fun providesReviewDao(auroraDatabase: AuroraDatabase): ReviewDao = auroraDatabase.reviewDao() 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 package com.aurora.store.data.work
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.AccountManagerCallback
import android.content.Context 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 android.util.Log
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.AuthData
import com.aurora.gplayapi.helpers.AuthHelper import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.store.data.model.AccountType import com.aurora.store.data.model.AccountType
import com.aurora.store.data.providers.AccountProvider import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.util.CertUtil.GOOGLE_ACCOUNT_TYPE import com.aurora.store.data.providers.GoogleAccountTokenProvider
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_AUTH_TOKEN_TYPE import com.aurora.store.util.Preferences
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_CERT
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
/** /**
* Worker to refresh [AuthData] in background * Worker to refresh [AuthData] in background
@@ -35,6 +23,7 @@ import kotlin.coroutines.suspendCoroutine
@HiltWorker @HiltWorker
open class AuthWorker @AssistedInject constructor( open class AuthWorker @AssistedInject constructor(
private val authProvider: AuthProvider, private val authProvider: AuthProvider,
private val tokenProvider: GoogleAccountTokenProvider,
@Assisted private val context: Context, @Assisted private val context: Context,
@Assisted workerParams: WorkerParameters @Assisted workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
@@ -73,11 +62,8 @@ open class AuthWorker @AssistedInject constructor(
} }
AuthHelper.Token.AUTH -> { AuthHelper.Token.AUTH -> {
Log.i( Log.i(TAG, "Refreshing AuthData via AccountManager")
TAG, val newToken = tokenProvider.fetchAuthToken(email, tokenPair.first)
"Refreshing AuthData for personal account using AccountManager"
)
val newToken = fetchAuthToken(email, tokenPair.first)
authProvider.buildGoogleAuthData(email, newToken, AuthHelper.Token.AAS) authProvider.buildGoogleAuthData(email, newToken, AuthHelper.Token.AAS)
.getOrThrow() .getOrThrow()
} }
@@ -91,6 +77,16 @@ open class AuthWorker @AssistedInject constructor(
} }
require(verifyAndSaveAuth(authData, accountType) != null) 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") Log.i(TAG, "Refreshed AuthData successfully")
return Result.success() return Result.success()
} catch (exception: Exception) { } 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? = private fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType): AuthData? =
if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) { if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) {
authProvider.saveAuthData(authData) authProvider.saveAuthData(authData)
@@ -168,7 +108,7 @@ open class AuthWorker @AssistedInject constructor(
authData authData
} else { } else {
authProvider.removeAuthData(context) authProvider.removeAuthData(context)
AccountProvider.logout(context) authProvider.logout()
null null
} }
} }

View File

@@ -28,10 +28,12 @@ import com.aurora.extensions.isQAndAbove
import com.aurora.extensions.isSAndAbove import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.requiresObbDir import com.aurora.extensions.requiresObbDir
import com.aurora.gplayapi.data.models.PlayFile import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.gplayapi.helpers.AuthHelper
import com.aurora.gplayapi.helpers.PurchaseHelper import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.gplayapi.network.IHttpClient import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.installer.AppInstaller 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.model.DownloadStatus
import com.aurora.store.data.network.HttpClient import com.aurora.store.data.network.HttpClient
import com.aurora.store.data.providers.AuthProvider 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.Download
import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.util.CertUtil import com.aurora.store.util.CertUtil
@@ -74,14 +77,15 @@ import kotlinx.coroutines.withContext
*/ */
@HiltWorker @HiltWorker
class DownloadWorker @AssistedInject constructor( class DownloadWorker @AssistedInject constructor(
authProvider: AuthProvider, private val authProvider: AuthProvider,
tokenProvider: GoogleAccountTokenProvider,
private val downloadDao: DownloadDao, private val downloadDao: DownloadDao,
private val appInstaller: AppInstaller, private val appInstaller: AppInstaller,
private val httpClient: IHttpClient, private val httpClient: IHttpClient,
private val purchaseHelper: PurchaseHelper, private val accountRepository: AccountRepository,
@Assisted private val context: Context, @Assisted private val context: Context,
@Assisted workerParams: WorkerParameters @Assisted workerParams: WorkerParameters
) : AuthWorker(authProvider, context, workerParams) { ) : AuthWorker(authProvider, tokenProvider, context, workerParams) {
companion object { companion object {
private const val NOTIFICATION_ID: Int = 200 private const val NOTIFICATION_ID: Int = 200
@@ -92,6 +96,7 @@ class DownloadWorker @AssistedInject constructor(
} }
private lateinit var download: Download private lateinit var download: Download
private lateinit var purchaseHelper: PurchaseHelper
private val notificationManager = context.getSystemService<NotificationManager>()!! private val notificationManager = context.getSystemService<NotificationManager>()!!
@@ -130,6 +135,7 @@ class DownloadWorker @AssistedInject constructor(
// Fetch required data for download // Fetch required data for download
try { try {
download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!) download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!)
purchaseHelper = resolvePurchaseHelper(download.packageName)
} catch (exception: Exception) { } catch (exception: Exception) {
return onFailure(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 * Purchases the app to get the download URL of the required files
* @param packageName The packageName of the app * @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.AccountProvider
import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.BlacklistProvider 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.Update
import com.aurora.store.data.room.update.UpdateDao import com.aurora.store.data.room.update.UpdateDao
import com.aurora.store.util.CertUtil import com.aurora.store.util.CertUtil
@@ -61,10 +62,11 @@ class UpdateWorker @AssistedInject constructor(
private val updateDao: UpdateDao, private val updateDao: UpdateDao,
private val downloadHelper: DownloadHelper, private val downloadHelper: DownloadHelper,
private val authProvider: AuthProvider, private val authProvider: AuthProvider,
tokenProvider: GoogleAccountTokenProvider,
private val appDetailsHelper: AppDetailsHelper, private val appDetailsHelper: AppDetailsHelper,
@Assisted private val context: Context, @Assisted private val context: Context,
@Assisted workerParams: WorkerParameters @Assisted workerParams: WorkerParameters
) : AuthWorker(authProvider, context, workerParams) { ) : AuthWorker(authProvider, tokenProvider, context, workerParams) {
companion object { companion object {
private const val NOTIFICATION_ID = 100 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.AppDetailsHelper
import com.aurora.gplayapi.helpers.CategoryHelper import com.aurora.gplayapi.helpers.CategoryHelper
import com.aurora.gplayapi.helpers.ExpandedBrowseHelper import com.aurora.gplayapi.helpers.ExpandedBrowseHelper
import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.gplayapi.helpers.SearchHelper import com.aurora.gplayapi.helpers.SearchHelper
import com.aurora.gplayapi.helpers.StreamHelper import com.aurora.gplayapi.helpers.StreamHelper
@@ -77,14 +76,6 @@ object HelperModule {
): SearchHelper = SearchHelper(authProvider.authData!!) ): SearchHelper = SearchHelper(authProvider.authData!!)
.using(httpClient) .using(httpClient)
@Singleton
@Provides
fun providesPurchaseHelperInstance(
authProvider: AuthProvider,
httpClient: IHttpClient
): PurchaseHelper = PurchaseHelper(authProvider.authData!!)
.using(httpClient)
@Singleton @Singleton
@Provides @Provides
fun providesWebStreamHelperInstance( 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 package com.aurora.store.viewmodel.accounts
import android.app.Activity
import androidx.lifecycle.ViewModel 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.AuthProvider
import com.aurora.store.data.providers.GoogleAccountTokenProvider
import com.aurora.store.data.room.account.Account
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject 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 @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 java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -57,6 +59,10 @@ class AuthViewModel @Inject constructor(
private val _authState: MutableStateFlow<AuthState> = MutableStateFlow(AuthState.Init) private val _authState: MutableStateFlow<AuthState> = MutableStateFlow(AuthState.Init)
val authState = _authState.asStateFlow() 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 { init {
updateAuthState() 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() { fun buildAnonymousAuthData() {
_authState.value = AuthState.Fetching _authState.value = AuthState.Fetching
viewModelScope.launch(Dispatchers.IO) { 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 _authState.value = AuthState.Verifying
if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) { if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) {
authProvider.saveAuthData(authData) authProvider.saveAuthData(authData)
@@ -202,10 +233,20 @@ class AuthViewModel @Inject constructor(
tokenType == AuthHelper.Token.AUTH && tokenType == AuthHelper.Token.AUTH &&
PackageUtil.hasSupportedMicroGVariant(context) 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 _authState.value = AuthState.SignedIn
} else { } else {
authProvider.removeAuthData(context) authProvider.removeAuthData(context)
AccountProvider.logout(context) authProvider.logout()
_authState.value = _authState.value =
AuthState.Failed(context.getString(R.string.failed_to_generate_session)) 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.gplayapi.network.IHttpClient
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.BuildConfig import com.aurora.store.BuildConfig
import com.aurora.store.data.AccountRepository
import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.event.AuthEvent
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.DownloadHelper
@@ -81,7 +82,8 @@ class AppDetailsViewModel @Inject constructor(
private val favouriteDao: FavouriteDao, private val favouriteDao: FavouriteDao,
private val reviewDao: ReviewDao, private val reviewDao: ReviewDao,
private val httpClient: IHttpClient, private val httpClient: IHttpClient,
private val json: Json private val json: Json,
private val accountRepository: AccountRepository
) : ViewModel() { ) : ViewModel() {
private val _app = MutableStateFlow<App?>(null) 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) { fun enqueueDownload(app: App) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
downloadHelper.enqueueApp(app) downloadHelper.enqueueApp(app)
} }
} }
fun enqueueDownloadWith(app: App, accountId: String) {
viewModelScope.launch(Dispatchers.IO) { downloadHelper.enqueueApp(app, accountId) }
}
fun cancelDownload(app: App) { fun cancelDownload(app: App) {
viewModelScope.launch { downloadHelper.cancelDownload(app.packageName) } 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.ViewModel
import androidx.lifecycle.viewModelScope 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.helper.UpdateHelper
import com.aurora.store.data.providers.BlacklistProvider import com.aurora.store.data.providers.BlacklistProvider
import com.aurora.store.data.room.update.Update
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class AppUpdateViewModel @Inject constructor( class AppUpdateViewModel @Inject constructor(
val blacklistProvider: BlacklistProvider, val blacklistProvider: BlacklistProvider,
private val updateHelper: UpdateHelper private val updateHelper: UpdateHelper,
private val downloadHelper: DownloadHelper,
accountRepository: AccountRepository
) : ViewModel() { ) : ViewModel() {
val accounts = accountRepository.accounts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun ignoreAllUpdates(packageName: String) { fun ignoreAllUpdates(packageName: String) {
viewModelScope.launch { updateHelper.ignoreAll(packageName) } viewModelScope.launch { updateHelper.ignoreAll(packageName) }
} }
@@ -26,4 +36,9 @@ class AppUpdateViewModel @Inject constructor(
fun ignoreVersion(packageName: String, versionCode: Long) { fun ignoreVersion(packageName: String, versionCode: Long) {
viewModelScope.launch { updateHelper.ignoreVersion(packageName, versionCode) } 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 android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.aurora.extensions.TAG 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.NativeDeviceInfoProvider
import com.aurora.store.data.providers.SpoofProvider import com.aurora.store.data.providers.SpoofProvider
import com.aurora.store.util.PathUtil import com.aurora.store.util.PathUtil
@@ -19,9 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow
@HiltViewModel @HiltViewModel
class SpoofViewModel @Inject constructor( class SpoofViewModel @Inject constructor(
private val spoofProvider: SpoofProvider, private val spoofProvider: SpoofProvider,
private val authProvider: AuthProvider,
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
/** Full sign-out, clearing both the account DB rows and the legacy prefs. */
fun logout() = authProvider.logout()
val defaultLocale: Locale = Locale.getDefault() val defaultLocale: Locale = Locale.getDefault()
val defaultProperties = NativeDeviceInfoProvider.getNativeDeviceProperties(context) val defaultProperties = NativeDeviceInfoProvider.getNativeDeviceProperties(context)

View File

@@ -25,7 +25,7 @@
<dimen name="spacing_xlarge">24dp</dimen> <dimen name="spacing_xlarge">24dp</dimen>
<dimen name="icon_size_default">24dp</dimen> <dimen name="icon_size_default">24dp</dimen>
<dimen name="icon_size_category">36dp</dimen> <dimen name="icon_size_category">32dp</dimen>
<dimen name="icon_size_small">48dp</dimen> <dimen name="icon_size_small">48dp</dimen>
<dimen name="icon_size_medium">56dp</dimen> <dimen name="icon_size_medium">56dp</dimen>
<dimen name="icon_size">64dp</dimen> <dimen name="icon_size">64dp</dimen>

View File

@@ -49,6 +49,25 @@
<string name="account_google">"Google"</string> <string name="account_google">"Google"</string>
<string name="account_login_using">Log in using</string> <string name="account_login_using">Log in using</string>
<string name="account_logout">Log out from Aurora</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_back">"Back"</string>
<string name="action_blacklist">"Blacklist"</string> <string name="action_blacklist">"Blacklist"</string>
<string name="action_blacklist_add">"Add to 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">"Install"</string>
<string name="action_install_all">"Install all"</string> <string name="action_install_all">"Install all"</string>
<string name="action_install_microG">"Install microG Bundle"</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_installations">"Installations"</string>
<string name="action_installing">"Installing"</string> <string name="action_installing">"Installing"</string>
<string name="action_join">"Join"</string> <string name="action_join">"Join"</string>
@@ -469,6 +489,9 @@
<string name="microg_removed_auth_warning">microG is no longer installed. Run setup again to keep things working smoothly.</string> <string name="microg_removed_auth_warning">microG is no longer installed. Run setup again to keep things working smoothly.</string>
<string name="microg_installer_subtitle">Requires microG companion app to be installed</string> <string name="microg_installer_subtitle">Requires microG companion app to be installed</string>
<string name="microg_installer_desc">Helps you bypass App Integrity (installer only) check</string> <string name="microg_installer_desc">Helps you bypass App Integrity (installer only) check</string>
<string name="microg_installer_prerequisite_title" translatable="false">Enable microG installer</string>
<string name="microg_installer_prerequisite_desc" translatable="false">Before using the microG installer, please ensure you have enabled it.\n\nPlay Store services → ⋮ → App Installer Settings → Allow App Installation</string>
<string name="microg_installer_open_settings" translatable="false">Open microG settings</string>
<string name="am_installer_subtitle">Full-featured open source package manager</string> <string name="am_installer_subtitle">Full-featured open source package manager</string>
<string name="am_installer_desc">Requires App Manager, need adb/root mode to install when miui optimization is on</string> <string name="am_installer_desc">Requires App Manager, need adb/root mode to install when miui optimization is on</string>
<string name="shizuku_installer_subtitle">Using system APIs directly with adb/root privileges</string> <string name="shizuku_installer_subtitle">Using system APIs directly with adb/root privileges</string>
@@ -610,6 +633,14 @@
<item quantity="one">%1$d favourite</item> <item quantity="one">%1$d favourite</item>
<item quantity="other">%1$d favourites</item> <item quantity="other">%1$d favourites</item>
</plurals> </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"> <plurals name="notification_installed_summary">
<item quantity="one">%d app installed</item> <item quantity="one">%d app installed</item>
<item quantity="other">%d apps installed</item> <item quantity="other">%d apps installed</item>