From fd350c739ccc9cf3cff16472fa261d19bbe6cb6f Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Wed, 3 Jun 2026 02:02:29 +0530 Subject: [PATCH 1/3] Enable microG Installer and add microG installer prerequisite dialog Partially addresses: #1485 Show the microG installer whenever the companion app is present instead of relying on the misleading vending_apps_install metadata flag, and warn users to enable it from microG settings before use. The dialog also offers a shortcut to open microG settings. microG only exposes the top-level settings activity; the App Installer Settings screen is a Navigation fragment with no deep link, so the dialog guides users through the remaining steps. --- .../MicroGInstallerPrerequisiteDialog.kt | 90 +++++++++++++++++++ .../installation/InstallerScreen.kt | 22 ++++- .../store/data/installer/AppInstaller.kt | 6 +- .../store/data/installer/MicroGInstaller.kt | 3 +- app/src/main/res/values/strings.xml | 3 + 5 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt new file mode 100644 index 000000000..7e18156a0 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt @@ -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() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt index 09a9a367b..cd8519a52 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt @@ -15,7 +15,9 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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.TopAppBar 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.SessionInstaller import com.aurora.store.data.model.Installer @@ -61,6 +64,17 @@ private fun ScreenContent( onInstallerSelected: (installer: Installer) -> Unit = {} ) { val snackBarHostState = remember { snackBarHostState } + var showMicroGPrerequisite by remember { mutableStateOf(false) } + + if (showMicroGPrerequisite) { + MicroGInstallerPrerequisiteDialog( + onConfirm = { + showMicroGPrerequisite = false + onInstallerSelected(Installer.MICROG) + }, + onDismiss = { showMicroGPrerequisite = false } + ) + } Scaffold( snackbarHost = { @@ -82,7 +96,13 @@ private fun ScreenContent( InstallerListItem( installerInfo = installerInfo, isSelected = installerInfo.installer == currentInstaller, - onClick = { onInstallerSelected(installerInfo.installer) } + onClick = { + if (installerInfo.installer == Installer.MICROG) { + showMicroGPrerequisite = true + } else { + onInstallerSelected(installerInfo.installer) + } + } ) } } diff --git a/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt index c5f73c51d..0e3d7e053 100644 --- a/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt @@ -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.InstallerInfo 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.PREFERENCE_INSTALLER_ID import com.topjohnwu.superuser.Shell @@ -83,7 +84,7 @@ class AppInstaller @Inject constructor( if (hasAuroraService(context)) ServiceInstaller.installerInfo else null, if (hasAppManager(context)) AMInstaller.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 } + // 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 { if (!PackageUtil.hasMicroGCompanion(context)) return false return try { @@ -236,7 +238,7 @@ class AppInstaller @Inject constructor( defaultInstaller } - Installer.MICROG -> if (hasMicroGInstaller(context)) { + Installer.MICROG -> if (hasMicroGCompanion(context)) { microGInstaller } else { defaultInstaller diff --git a/app/src/main/java/com/aurora/store/data/installer/MicroGInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/MicroGInstaller.kt index aa5fabf1e..e72fcf4cb 100644 --- a/app/src/main/java/com/aurora/store/data/installer/MicroGInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/MicroGInstaller.kt @@ -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.InstallerInfo import com.aurora.store.data.room.download.Download +import com.aurora.store.util.PackageUtil.hasMicroGCompanion import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -68,7 +69,7 @@ class MicroGInstaller @Inject constructor( Log.i(TAG, "${download.packageName} already queued") } - AppInstaller.hasMicroGInstaller(context) -> { + hasMicroGCompanion(context) -> { Log.i(TAG, "Received microG install request for ${download.packageName}") val files = getFiles(download.packageName, download.versionCode) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf7280757..dcc13a6a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -469,6 +469,9 @@ microG is no longer installed. Run setup again to keep things working smoothly. Requires microG companion app to be installed Helps you bypass App Integrity (installer only) check + Enable microG installer + Before using the microG installer, please ensure you have enabled it.\n\nPlay Store services → ⋮ → App Installer Settings → Allow App Installation + Open microG settings Full-featured open source package manager Requires App Manager, need adb/root mode to install when miui optimization is on Using system APIs directly with adb/root privileges From 92ec5742c876f740d5165f878424d40b8e7fff64 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sun, 7 Jun 2026 01:35:27 +0530 Subject: [PATCH 2/3] Add support for multiple accounts --- .../10.json | 518 ++++++++++++++++++ .../compose/composable/AccountListItem.kt | 94 ++++ .../store/compose/navigation/Destination.kt | 2 +- .../store/compose/navigation/NavDisplay.kt | 31 +- .../aurora/store/compose/navigation/Screen.kt | 2 +- .../compose/ui/accounts/AccountsScreen.kt | 451 +++++++++++---- .../compose/ui/accounts/GoogleLoginScreen.kt | 47 +- .../store/compose/ui/accounts/LogoutDialog.kt | 49 -- .../store/compose/ui/commons/LoadingDialog.kt | 68 +++ .../MicroGInstallerPrerequisiteDialog.kt | 2 +- .../compose/ui/details/AppDetailsScreen.kt | 46 +- .../compose/ui/details/menu/AppDetailsMenu.kt | 6 + .../store/compose/ui/details/menu/MenuItem.kt | 1 + .../compose/ui/sheets/AccountActionsSheet.kt | 105 ++++ .../compose/ui/sheets/AccountPickerSheet.kt | 91 +++ .../store/compose/ui/sheets/AppUpdateSheet.kt | 121 ++++ .../store/compose/ui/splash/SplashScreen.kt | 2 +- .../store/compose/ui/spoof/SpoofScreen.kt | 9 +- .../aurora/store/data/AccountRepository.kt | 79 +++ .../store/data/helper/DownloadHelper.kt | 22 +- .../store/data/providers/AuthProvider.kt | 214 +++++++- .../providers/GoogleAccountTokenProvider.kt | 119 ++++ .../aurora/store/data/room/AuroraDatabase.kt | 15 +- .../aurora/store/data/room/MigrationHelper.kt | 48 ++ .../com/aurora/store/data/room/RoomModule.kt | 18 +- .../aurora/store/data/room/account/Account.kt | 36 ++ .../data/room/account/AccountConverter.kt | 30 + .../store/data/room/account/AccountDao.kt | 64 +++ .../data/room/account/AppAccountBinding.kt | 28 + .../data/room/account/AppAccountBindingDao.kt | 27 + .../com/aurora/store/data/work/AuthWorker.kt | 92 +--- .../aurora/store/data/work/DownloadWorker.kt | 33 +- .../aurora/store/data/work/UpdateWorker.kt | 4 +- .../com/aurora/store/module/HelperModule.kt | 9 - .../java/com/aurora/store/util/RestartUtil.kt | 20 + .../viewmodel/accounts/AccountsViewModel.kt | 116 +++- .../store/viewmodel/auth/AuthViewModel.kt | 45 +- .../viewmodel/details/AppDetailsViewModel.kt | 11 +- .../viewmodel/sheets/AppUpdateViewModel.kt | 17 +- .../store/viewmodel/spoof/SpoofViewModel.kt | 5 + app/src/main/res/values/strings.xml | 28 + 41 files changed, 2422 insertions(+), 303 deletions(-) create mode 100644 app/schemas/com.aurora.store.data.room.AuroraDatabase/10.json create mode 100644 app/src/main/java/com/aurora/store/compose/composable/AccountListItem.kt delete mode 100644 app/src/main/java/com/aurora/store/compose/ui/accounts/LogoutDialog.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/commons/LoadingDialog.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/sheets/AccountActionsSheet.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/sheets/AccountPickerSheet.kt create mode 100644 app/src/main/java/com/aurora/store/data/AccountRepository.kt create mode 100644 app/src/main/java/com/aurora/store/data/providers/GoogleAccountTokenProvider.kt create mode 100644 app/src/main/java/com/aurora/store/data/room/account/Account.kt create mode 100644 app/src/main/java/com/aurora/store/data/room/account/AccountConverter.kt create mode 100644 app/src/main/java/com/aurora/store/data/room/account/AccountDao.kt create mode 100644 app/src/main/java/com/aurora/store/data/room/account/AppAccountBinding.kt create mode 100644 app/src/main/java/com/aurora/store/data/room/account/AppAccountBindingDao.kt create mode 100644 app/src/main/java/com/aurora/store/util/RestartUtil.kt diff --git a/app/schemas/com.aurora.store.data.room.AuroraDatabase/10.json b/app/schemas/com.aurora.store.data.room.AuroraDatabase/10.json new file mode 100644 index 000000000..0c774dfb2 --- /dev/null +++ b/app/schemas/com.aurora.store.data.room.AuroraDatabase/10.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aurora/store/compose/composable/AccountListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/AccountListItem.kt new file mode 100644 index 000000000..6eadf8731 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/AccountListItem.kt @@ -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) + ) + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt index 4c81f2368..e6948c757 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt @@ -31,7 +31,7 @@ sealed class Destination { data class PermissionRationale(val permissions: Set) : Destination() data object Accounts : Destination() - data object GoogleLogin : Destination() + data class GoogleLogin(val addAccount: Boolean = false) : Destination() data object About : Destination() data object Favourite : Destination() data object Spoof : Destination() diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index 46ede4929..9b46e8dc2 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -64,8 +64,22 @@ import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.model.AccountType import com.aurora.store.data.providers.AccountProvider +import com.aurora.store.data.providers.AuthProvider import com.aurora.store.util.PackageUtil import com.aurora.store.util.Preferences +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * Lets this VM-less navigation host reach the [AuthProvider] singleton for full sign-out. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface NavDisplayEntryPoint { + fun authProvider(): AuthProvider +} /** * Navigation display for compose screens @@ -83,7 +97,11 @@ fun NavDisplay(startDestination: NavKey) { fun handleMicroGRemoved() { context.toast(R.string.microg_removed_auth_warning) - AccountProvider.logout(context) + // Full sign-out: clears the account DB rows (incl. the now-invalid microG account) as + // well as the legacy prefs, so the stale account can't linger as the DB is the source + // of truth. + EntryPointAccessors.fromApplication(context, NavDisplayEntryPoint::class.java) + .authProvider().logout() val intent = Intent(context, ComposeActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) } @@ -146,11 +164,11 @@ fun NavDisplay(startDestination: NavKey) { is Destination.DevProfile -> backstack.add(Screen.DevProfile(destination.devId)) is Destination.AppUpdate -> Unit is Destination.StreamBrowse -> backstack.add(Screen.StreamBrowse(destination.cluster)) + is Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin(destination.addAccount)) Destination.Search -> backstack.add(Screen.Search) Destination.Downloads -> backstack.add(Screen.Downloads) Destination.Accounts -> backstack.add(Screen.Accounts) - Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin) Destination.About -> backstack.add(Screen.About) Destination.Favourite -> backstack.add(Screen.Favourite) Destination.Spoof -> backstack.add(Screen.Spoof) @@ -279,11 +297,18 @@ fun NavDisplay(startDestination: NavKey) { onNavigateTo = ::navigate ) } + + entry { screen -> + GoogleLoginScreen( + addAccount = screen.addAccount, + onNavigateTo = ::navigate + ) + } + entry { OnboardingScreen() } entry { BlacklistScreen() } entry { DownloadsScreen(onNavigateTo = ::navigate) } entry { AccountsScreen(onNavigateTo = ::navigate) } - entry { GoogleLoginScreen(onNavigateTo = ::navigate) } entry { AboutScreen() } entry { FavouriteScreen(onNavigateTo = ::navigate) } entry { SpoofScreen(onNavigateTo = ::navigate) } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt index 3e8dda653..7025155d4 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt @@ -48,7 +48,7 @@ sealed class Screen : NavKey, Parcelable { data object Accounts : Screen() @Serializable - data object GoogleLogin : Screen() + data class GoogleLogin(val addAccount: Boolean = false) : Screen() @Serializable data object About : Screen() diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt index 733510a27..07d5dee89 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt @@ -5,107 +5,319 @@ package com.aurora.store.compose.ui.accounts +import android.widget.Toast +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.AssistChip +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.Constants.URL_DISCLAIMER import com.aurora.Constants.URL_LICENSE import com.aurora.Constants.URL_TOS import com.aurora.extensions.browse import com.aurora.store.R +import com.aurora.store.compose.composable.AccountListItem +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.ThemePreviewProvider -import com.aurora.store.data.providers.AccountProvider +import com.aurora.store.compose.ui.commons.LoadingDialog +import com.aurora.store.compose.ui.sheets.AccountActionsSheet +import com.aurora.store.data.room.account.Account +import com.aurora.store.util.RestartUtil import com.aurora.store.viewmodel.accounts.AccountsViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountsScreen( onNavigateTo: (Destination) -> Unit, viewModel: AccountsViewModel = hiltViewModel() ) { - val userProfile = viewModel.authProvider.authData?.userProfile - val avatar = when { - userProfile != null -> userProfile.artwork.url - else -> R.mipmap.ic_launcher - } - val name = when { - viewModel.authProvider.isAnonymous -> stringResource(R.string.account_anonymous) - userProfile != null -> userProfile.name - else -> stringResource(R.string.account_anonymous) - } - val email = when { - userProfile != null -> userProfile.email - else -> stringResource(R.string.account_anonymous_email) - } - val context = LocalContext.current - var shouldShowLogoutDialog by rememberSaveable { mutableStateOf(false) } + val activity = LocalActivity.current + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + var showAddSheet by remember { mutableStateOf(false) } + var selectedAccount by remember { mutableStateOf(null) } + var accountToRemove by remember { mutableStateOf(null) } + var accountToSetDefault by remember { mutableStateOf(null) } + var isSwitchingDefault by remember { mutableStateOf(false) } + var isAddingAccount by remember { mutableStateOf(false) } + var isRefreshing by remember { mutableStateOf(false) } - if (shouldShowLogoutDialog) { - LogoutDialog( - onConfirm = { - shouldShowLogoutDialog = false - AccountProvider.logout(context) - onNavigateTo(Destination.Splash()) - }, - onDismiss = { shouldShowLogoutDialog = false } - ) + LaunchedEffect(Unit) { + viewModel.switched.collect { ok -> + if (ok) { + RestartUtil.restart(context) + } else { + isSwitchingDefault = false + Toast.makeText( + context, + context.getString(R.string.account_switch_failed), + Toast.LENGTH_SHORT + ).show() + } + } + } + + LaunchedEffect(Unit) { + viewModel.removeResult.collect { result -> + when (result) { + AccountsViewModel.RemoveResult.LOGGED_OUT -> onNavigateTo(Destination.Splash()) + AccountsViewModel.RemoveResult.RESTART -> RestartUtil.restart(context) + } + } + } + + LaunchedEffect(Unit) { + viewModel.addResult.collect { added -> + isAddingAccount = false + if (!added) { + Toast.makeText( + context, + context.getString(R.string.account_add_failed), + Toast.LENGTH_SHORT + ).show() + } + } + } + + LaunchedEffect(Unit) { + viewModel.refreshResult.collect { failures -> + isRefreshing = false + val message = if (failures == 0) { + context.getString(R.string.account_refresh_done) + } else { + context.resources.getQuantityString( + R.plurals.account_refresh_failed, + failures, + failures + ) + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } } ScreenContent( - avatar = avatar, - name = name, - email = email, - onLogout = { shouldShowLogoutDialog = true } + accounts = accounts, + onAccountClick = { selectedAccount = it }, + onAddAccount = { showAddSheet = true }, + onRefreshAll = { + isRefreshing = true + viewModel.refreshAll(activity) + } ) + + selectedAccount?.let { account -> + AccountActionsSheet( + account = account, + onSetDefault = { + selectedAccount = null + accountToSetDefault = account + }, + onRemove = { + selectedAccount = null + accountToRemove = account + }, + onDismiss = { selectedAccount = null } + ) + } + + accountToSetDefault?.let { account -> + AlertDialog( + onDismissRequest = { accountToSetDefault = null }, + title = { Text(text = stringResource(R.string.account_set_default_title)) }, + text = { Text(text = stringResource(R.string.account_set_default_message)) }, + confirmButton = { + TextButton( + onClick = { + accountToSetDefault = null + isSwitchingDefault = true + viewModel.setDefault(account) + } + ) { + Text(text = stringResource(R.string.action_restart)) + } + }, + dismissButton = { + TextButton(onClick = { accountToSetDefault = null }) { + Text(text = stringResource(R.string.action_cancel)) + } + } + ) + } + + if (isSwitchingDefault) { + LoadingDialog(message = stringResource(R.string.account_switching)) + } + + if (isAddingAccount) { + LoadingDialog(message = stringResource(R.string.account_adding)) + } + + if (isRefreshing) { + LoadingDialog(message = stringResource(R.string.account_refreshing)) + } + + accountToRemove?.let { account -> + val isLast = accounts.size <= 1 + val name = if (account.isAnonymous) { + stringResource(R.string.account_anonymous) + } else { + account.displayName ?: account.email + } + AlertDialog( + onDismissRequest = { accountToRemove = null }, + title = { Text(text = stringResource(R.string.account_remove_title)) }, + text = { + Text( + text = if (isLast) { + stringResource(R.string.account_remove_last_message) + } else { + stringResource(R.string.account_remove_message, name) + } + ) + }, + confirmButton = { + TextButton( + onClick = { + accountToRemove = null + viewModel.remove(account) + } + ) { + Text(text = stringResource(R.string.account_remove)) + } + }, + dismissButton = { + TextButton(onClick = { accountToRemove = null }) { + Text(text = stringResource(R.string.action_cancel)) + } + } + ) + } + + if (showAddSheet) { + val deviceEmails by produceState(initialValue = emptyList()) { + value = viewModel.discoverableEmails() + } + + ModalBottomSheet( + onDismissRequest = { showAddSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensionResource(R.dimen.spacing_small)) + ) { + Text( + text = stringResource(R.string.account_add), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_large), + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) + + if (deviceEmails.isNotEmpty()) { + SectionLabel(text = stringResource(R.string.account_add_device)) + deviceEmails.forEach { email -> + AddOptionRow( + iconRes = R.drawable.ic_account, + label = email, + onClick = { + showAddSheet = false + isAddingAccount = true + viewModel.addSystemAccount(email, activity) + } + ) + } + HorizontalDivider( + modifier = Modifier.padding( + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) + } + + AddOptionRow( + iconRes = R.drawable.ic_google, + label = stringResource(R.string.account_add_google), + onClick = { + showAddSheet = false + onNavigateTo(Destination.GoogleLogin(addAccount = true)) + } + ) + + if (accounts.none { it.isAnonymous }) { + AddOptionRow( + iconRes = R.drawable.ic_anonymous, + label = stringResource(R.string.account_add_anonymous), + onClick = { + showAddSheet = false + isAddingAccount = true + viewModel.addAnonymousAccount() + } + ) + } + + Spacer(Modifier.navigationBarsPadding()) + } + } + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ScreenContent( - avatar: Any = R.drawable.ic_account, - name: String = stringResource(R.string.account_anonymous), - email: String = stringResource(R.string.account_anonymous_email), - onLogout: () -> Unit = {} + accounts: List = emptyList(), + onAccountClick: (Account) -> Unit = {}, + onAddAccount: () -> Unit = {}, + onRefreshAll: () -> Unit = {} ) { Scaffold( topBar = { TopAppBar( - title = stringResource(R.string.title_account_manager) + title = stringResource(R.string.title_account_manager), + actions = { OverflowMenu(onRefreshAll = onRefreshAll) } ) } ) { paddingValues -> @@ -113,64 +325,21 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.spacing_small)), - verticalArrangement = Arrangement.SpaceBetween ) { - AssistHeader() + SectionHeader( + title = pluralStringResource(R.plurals.account_count, accounts.size, accounts.size), + trailing = { + TextButton(onClick = onAddAccount) { + Text(text = stringResource(R.string.account_add)) + } + } + ) - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .crossfade(true) - .data(avatar) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .requiredSize(dimensionResource(R.dimen.icon_size_avatar)) - .clip(CircleShape) - ) - Text( - text = name, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = email, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensionResource(R.dimen.height_bottom_adj)), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.spacing_medium) - ) - ) { - Text( - text = stringResource(R.string.account_logout), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - OutlinedButton(onClick = onLogout) { - Icon( - painter = painterResource(R.drawable.ic_logout), - contentDescription = null - ) - Spacer(modifier = Modifier.width(dimensionResource(R.dimen.spacing_xsmall))) - Text( - text = stringResource(R.string.action_logout) + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = accounts, key = { it.id }) { account -> + AccountListItem( + account = account, + onClick = { onAccountClick(account) } ) } } @@ -179,26 +348,80 @@ private fun ScreenContent( } @Composable -private fun AssistHeader() { +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_large), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) +} + +@Composable +private fun AddOptionRow(iconRes: Int, label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.spacing_large), + vertical = dimensionResource(R.dimen.spacing_medium) + ), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun OverflowMenu(onRefreshAll: () -> Unit) { val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } val links = mapOf( R.string.menu_terms to URL_TOS, R.string.menu_disclaimer to URL_DISCLAIMER, R.string.menu_license to URL_LICENSE ) - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) - ) { - items(items = links.keys.toList(), key = { item -> item }) { label -> - AssistChip( - onClick = { context.browse(links.getValue(label)) }, - label = { Text(text = stringResource(label)) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.menu) ) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.account_refresh_all)) }, + onClick = { + expanded = false + onRefreshAll() + } + ) + HorizontalDivider() + links.forEach { (label, url) -> + DropdownMenuItem( + text = { Text(text = stringResource(label)) }, + onClick = { + expanded = false + context.browse(url) + } + ) + } + } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt index 36764da81..0713343c0 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt @@ -54,6 +54,7 @@ private const val JS_PROFILE_EMAIL = """ @Composable fun GoogleLoginScreen( onNavigateTo: (Destination) -> Unit, + addAccount: Boolean = false, viewModel: AuthViewModel = hiltViewModel() ) { val context = LocalContext.current @@ -66,29 +67,47 @@ fun GoogleLoginScreen( AuroraApp.events.authEvent.collect { event -> if (event is AuthEvent.GoogleLogin) { if (event.success) { - viewModel.buildGoogleAuthData( - event.email, - event.token, - AuthHelper.Token.AAS - ) + if (addAccount) { + viewModel.addGoogleAuthData(event.email, event.token) + } else { + viewModel.buildGoogleAuthData( + event.email, + event.token, + AuthHelper.Token.AAS + ) + } } else { Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG) .show() - onNavigateTo(Destination.Splash()) + onNavigateTo(if (addAccount) Destination.Accounts else Destination.Splash()) } } } } - LaunchedEffect(authState) { - when (authState) { - AuthState.SignedIn, AuthState.Valid -> onNavigateTo( - Destination.Main( - Preferences.getInteger(context, Preferences.PREFERENCE_DEFAULT_SELECTED_TAB) + // In add-account mode the user is already signed in (authState is Valid), so don't let the + // authState effect bounce to Main. Navigate back to the account screen once the add completes. + if (addAccount) { + LaunchedEffect(Unit) { + viewModel.accountAdded.collect { ok -> + if (!ok) { + Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG) + .show() + } + onNavigateTo(Destination.Accounts) + } + } + } else { + LaunchedEffect(authState) { + when (authState) { + AuthState.SignedIn, AuthState.Valid -> onNavigateTo( + Destination.Main( + Preferences.getInteger(context, Preferences.PREFERENCE_DEFAULT_SELECTED_TAB) + ) ) - ) - is AuthState.Failed -> onNavigateTo(Destination.Splash()) - else -> Unit + is AuthState.Failed -> onNavigateTo(Destination.Splash()) + else -> Unit + } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/LogoutDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/LogoutDialog.kt deleted file mode 100644 index 2d98ec8bb..000000000 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/LogoutDialog.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/LoadingDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/LoadingDialog.kt new file mode 100644 index 000000000..6f0fec345 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/LoadingDialog.kt @@ -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)) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt index 7e18156a0..e17542629 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/MicroGInstallerPrerequisiteDialog.kt @@ -47,7 +47,7 @@ fun MicroGInstallerPrerequisiteDialog(onConfirm: () -> Unit = {}, onDismiss: () ) ) { Text(text = stringResource(R.string.microg_installer_prerequisite_desc)) - OutlinedButton ( + OutlinedButton( onClick = { try { context.startActivity( diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index 658fd1395..509c2b4cd 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 @@ -95,6 +96,7 @@ import com.aurora.store.compose.ui.details.menu.AppDetailsMenu import com.aurora.store.compose.ui.details.menu.MenuItem import com.aurora.store.compose.ui.details.navigation.ExtraScreen import com.aurora.store.compose.ui.dev.DevProfileScreen +import com.aurora.store.compose.ui.sheets.AccountPickerSheet import com.aurora.store.compose.ui.sheets.InstallErrorSheet import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.AppState @@ -103,6 +105,7 @@ import com.aurora.store.data.model.Report import com.aurora.store.data.model.Scores import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted import com.aurora.store.data.providers.PermissionProvider.Companion.isPermittedToInstall +import com.aurora.store.data.room.account.Account import com.aurora.store.util.FlavouredUtil import com.aurora.store.util.PackageUtil import com.aurora.store.util.ShortcutManagerUtil @@ -129,6 +132,7 @@ fun AppDetailsScreen( val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() val installError by viewModel.installError.collectAsStateWithLifecycle() val suggestionsBundle by viewModel.suggestionsBundle.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) } @@ -184,7 +188,11 @@ fun AppDetailsScreen( exodusReport = exodusReport, onNavigateTo = onNavigateTo, onLoadMoreCluster = { cluster -> viewModel.loadMoreCluster(cluster) }, + accounts = accounts, onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) }, + onDownloadWith = { requestedApp, accountId -> + viewModel.enqueueDownloadWith(requestedApp, accountId) + }, onFavorite = { viewModel.toggleFavourite(loadedApp) }, onCancelDownload = { viewModel.cancelDownload(loadedApp) }, onUninstall = { AppInstaller.uninstall(context, packageName) }, @@ -274,6 +282,7 @@ private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? = /** * Composable to display app details and suggestions */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ScreenContentApp( app: App, @@ -288,7 +297,9 @@ private fun ScreenContentApp( exodusReport: Report? = null, onNavigateTo: (Destination) -> Unit = {}, onLoadMoreCluster: (cluster: StreamCluster) -> Unit = {}, + accounts: List = emptyList(), onDownload: (requestedApp: App) -> Unit = {}, + onDownloadWith: (requestedApp: App, accountId: String) -> Unit = { _, _ -> }, onFavorite: () -> Unit = {}, onCancelDownload: () -> Unit = {}, onUninstall: () -> Unit = {}, @@ -322,6 +333,7 @@ private fun ScreenContentApp( val shouldShowMenuOnMainPane = scaffoldNavigator .scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden var showRestartDialog by remember { mutableStateOf(false) } + var showAccountPicker by remember { mutableStateOf(false) } if (showRestartDialog) { ForceRestartDialog(onConfirm = onForceRestart) @@ -339,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)) { val shouldPromptMicroGInstall = app.requiresGMS() && FlavouredUtil.promptMicroGInstall(context) @@ -347,7 +363,11 @@ private fun ScreenContentApp( if (shouldPromptMicroGInstall && !ignoreMicroG) { showExtraPane(ExtraScreen.MicroG) } else { - onDownload(requestedApp) + if (accountId != null) { + onDownloadWith(requestedApp, accountId) + } else { + onDownload(requestedApp) + } onNavigateBack() } } else { @@ -360,12 +380,24 @@ private fun ScreenContentApp( } } + if (showAccountPicker) { + AccountPickerSheet( + accounts = accounts, + onSelect = { account -> + showAccountPicker = false + onInstall(accountId = account.id) + }, + onDismiss = { showAccountPicker = false } + ) + } + @Composable fun SetupMenu() { AppDetailsMenu( isFavorite = isFavorite, state = state, - canManualDownload = canAcquire + canManualDownload = canAcquire, + canUseOtherAccount = accounts.size > 1 ) { menuItem -> when (menuItem) { MenuItem.FAVORITE -> onFavorite() @@ -374,6 +406,10 @@ private fun ScreenContentApp( showExtraPane(ExtraScreen.ManualDownload) } + MenuItem.INSTALL_OTHER_ACCOUNT -> { + showAccountPicker = true + } + MenuItem.SHARE -> context.share(app.displayName, app.packageName) MenuItem.APP_INFO -> context.appInfo(app.packageName) @@ -448,7 +484,9 @@ private fun ScreenContentApp( Actions( primaryActionDisplayName = primaryActionName, - secondaryActionDisplayName = stringResource(R.string.title_manual_download), + secondaryActionDisplayName = stringResource( + R.string.title_manual_download + ), isPrimaryActionEnabled = canAcquire, isSecondaryActionEnabled = canAcquire, onPrimaryAction = ::onInstall, diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt b/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt index afd70198a..e0e9c8be8 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt @@ -43,6 +43,7 @@ fun AppDetailsMenu( isFavorite: Boolean = false, isExpanded: Boolean = false, canManualDownload: Boolean = true, + canUseOtherAccount: Boolean = false, onMenuItemClicked: (menuItem: MenuItem) -> Unit = {} ) { val context = LocalContext.current @@ -83,6 +84,11 @@ fun AppDetailsMenu( onClick = { onClick(MenuItem.MANUAL_DOWNLOAD) }, enabled = canManualDownload && !state.inProgress() ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_switch_account)) }, + onClick = { onClick(MenuItem.INSTALL_OTHER_ACCOUNT) }, + enabled = canUseOtherAccount && !state.inProgress() + ) DropdownMenuItem( text = { Text(text = stringResource(R.string.action_info)) }, onClick = { onClick(MenuItem.APP_INFO) }, diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/menu/MenuItem.kt b/app/src/main/java/com/aurora/store/compose/ui/details/menu/MenuItem.kt index 77ed06ec0..35df8c6f7 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/menu/MenuItem.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/menu/MenuItem.kt @@ -13,6 +13,7 @@ enum class MenuItem { FAVORITE, SHARE, MANUAL_DOWNLOAD, + INSTALL_OTHER_ACCOUNT, APP_INFO, ADD_TO_HOME, PLAY_STORE diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/AccountActionsSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/AccountActionsSheet.kt new file mode 100644 index 000000000..ac5880a7a --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/AccountActionsSheet.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/AccountPickerSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/AccountPickerSheet.kt new file mode 100644 index 000000000..b9b075b73 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/AccountPickerSheet.kt @@ -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, + 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()) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt index 7c4642a4c..afb167828 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt @@ -5,12 +5,19 @@ package com.aurora.store.compose.ui.sheets +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize @@ -20,21 +27,28 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextOverflow import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade @@ -45,6 +59,7 @@ import com.aurora.store.R import com.aurora.store.compose.navigation.Destination import com.aurora.store.data.event.BusEvent import com.aurora.store.data.installer.AppInstaller +import com.aurora.store.data.room.account.Account import com.aurora.store.data.room.update.Update import com.aurora.store.viewmodel.sheets.AppUpdateViewModel @@ -58,6 +73,8 @@ fun AppUpdateSheet( ) { val context = LocalContext.current val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(update.packageName) + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + var showAccounts by remember { mutableStateOf(false) } ModalBottomSheet( onDismissRequest = onDismiss, @@ -105,6 +122,26 @@ fun AppUpdateSheet( } ) + // Per-app update override: only meaningful with more than one account. The primary + // update button keeps using the default (or the app's existing binding). + if (accounts.size > 1) { + HorizontalDivider( + modifier = Modifier.padding( + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) + + AccountAccordion( + expanded = showAccounts, + onToggle = { showAccounts = !showAccounts }, + accounts = accounts, + onSelect = { account -> + viewModel.updateWithAccount(update, account.id) + onDismiss() + } + ) + } + HorizontalDivider( modifier = Modifier.padding( vertical = dimensionResource(R.dimen.spacing_small) @@ -222,6 +259,90 @@ private fun Item(label: String, onClick: () -> Unit) { ) } +/** + * Expandable "update using another account" row. Tapping the header toggles a tinted sub-menu + * listing the available accounts, so it reads as a nested section rather than a sibling action. + */ +@Composable +private fun AccountAccordion( + expanded: Boolean, + onToggle: () -> Unit, + accounts: List, + onSelect: (Account) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.spacing_xsmall)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + .clickable(onClick = onToggle) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.action_switch_account), + style = MaterialTheme.typography.bodyLarge + ) + Icon( + painter = painterResource( + if (expanded) R.drawable.ic_keyboard_arrow_up else R.drawable.ic_keyboard_arrow_down + ), + contentDescription = null + ) + } + + Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall))) + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.spacing_xsmall)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(vertical = dimensionResource(R.dimen.spacing_xsmall)) + ) { + accounts.forEachIndexed { index, account -> + if (index > 0) { + HorizontalDivider() + } + + val suffix = if (account.isDefault) { + " · " + stringResource(R.string.account_default) + } else { + " · " + account.email + } + + val name = if (account.isAnonymous) { + stringResource(R.string.account_anonymous) + } else { + account.displayName ?: account.email + } + + Text( + text = name + suffix, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(account) } + .padding( + horizontal = dimensionResource(R.dimen.spacing_large), + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) + } + } + } +} + @Composable private fun ChangelogSection(html: String) { Text( diff --git a/app/src/main/java/com/aurora/store/compose/ui/splash/SplashScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/splash/SplashScreen.kt index 388cac66e..91524f6db 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/splash/SplashScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/splash/SplashScreen.kt @@ -279,7 +279,7 @@ fun SplashScreen( ) ) } else { - onNavigateTo(Destination.GoogleLogin) + onNavigateTo(Destination.GoogleLogin()) } } ) { diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt index 17c539c19..9a7625e9a 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt @@ -40,7 +40,6 @@ import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.ui.spoof.menu.MenuItem import com.aurora.store.compose.ui.spoof.menu.SpoofMenu import com.aurora.store.compose.ui.spoof.navigation.SpoofPage -import com.aurora.store.data.providers.AccountProvider import com.aurora.store.viewmodel.spoof.SpoofViewModel import kotlinx.coroutines.launch @@ -49,7 +48,8 @@ fun SpoofScreen(onNavigateTo: (Destination) -> Unit, viewModel: SpoofViewModel = ScreenContent( onNavigateTo = onNavigateTo, onDeviceSpoofImport = { uri -> viewModel.importDeviceSpoof(uri) }, - onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) } + onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) }, + onLogout = { viewModel.logout() } ) } @@ -58,7 +58,8 @@ private fun ScreenContent( pages: List = listOf(SpoofPage.DEVICE, SpoofPage.LOCALE), onNavigateTo: (Destination) -> Unit = {}, onDeviceSpoofImport: (uri: Uri) -> Unit = {}, - onDeviceSpoofExport: (uri: Uri) -> Unit = {} + onDeviceSpoofExport: (uri: Uri) -> Unit = {}, + onLogout: () -> Unit = {} ) { val context = LocalContext.current val pagerState = rememberPagerState { pages.size } @@ -95,7 +96,7 @@ private fun ScreenContent( ) when (result) { SnackbarResult.ActionPerformed -> { - AccountProvider.logout(context) + onLogout() onNavigateTo(Destination.Splash()) } diff --git a/app/src/main/java/com/aurora/store/data/AccountRepository.kt b/app/src/main/java/com/aurora/store/data/AccountRepository.kt new file mode 100644 index 000000000..a0d9f93af --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/AccountRepository.kt @@ -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> = accountDao.accounts() + val defaultAccount: Flow = accountDao.observeDefault() + + suspend fun getAll(): List = 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) +} diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index 8c54d7560..2d65a31d4 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -14,6 +14,7 @@ import androidx.work.WorkRequest import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App import com.aurora.store.AuroraApp +import com.aurora.store.data.AccountRepository import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.DownloadStatus @@ -40,7 +41,8 @@ import kotlinx.coroutines.launch class DownloadHelper @Inject constructor( @ApplicationContext private val context: Context, private val downloadDao: DownloadDao, - private val appInstaller: AppInstaller + private val appInstaller: AppInstaller, + private val accountRepository: AccountRepository ) { companion object { @@ -158,6 +160,15 @@ class DownloadHelper @Inject constructor( enqueue(Download.fromApp(app)) } + /** + * Enqueues an app for download using a chosen account. Binding to the default clears any + * existing binding; a non-default account is persisted so future updates use it. + */ + suspend fun enqueueApp(app: App, accountId: String) { + accountRepository.bindApp(app.packageName, accountId) + enqueueApp(app) + } + /** * Enqueues an update for download & install * @param update [Update] to download @@ -166,6 +177,15 @@ class DownloadHelper @Inject constructor( enqueue(Download.fromUpdate(update)) } + /** + * Enqueues an update using a chosen account. Binding to the default clears any existing + * binding; a non-default account is persisted so this and future updates use it. + */ + suspend fun enqueueUpdate(update: Update, accountId: String) { + accountRepository.bindApp(update.packageName, accountId) + enqueueUpdate(update) + } + /** * Enqueues ExternalApk for download & install * @param externalApk [ExternalApk] to download diff --git a/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt b/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt index e82a5a882..646caf599 100644 --- a/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt +++ b/app/src/main/java/com/aurora/store/data/providers/AuthProvider.kt @@ -19,16 +19,21 @@ package com.aurora.store.data.providers +import android.app.Activity import android.content.Context import android.util.Log +import com.aurora.Constants import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.PlayResponse import com.aurora.gplayapi.helpers.AuthHelper import com.aurora.gplayapi.network.IHttpClient +import com.aurora.store.AuroraApp import com.aurora.store.R +import com.aurora.store.data.AccountRepository import com.aurora.store.data.model.AccountType import com.aurora.store.data.model.Auth +import com.aurora.store.data.room.account.Account import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences.PREFERENCE_AUTH_DATA import com.aurora.store.util.Preferences.PREFERENCE_DISPENSER_URLS @@ -36,6 +41,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -44,8 +51,25 @@ class AuthProvider @Inject constructor( @ApplicationContext private val context: Context, private val json: Json, private val spoofProvider: SpoofProvider, - private val httpClient: IHttpClient + private val httpClient: IHttpClient, + private val accountRepository: AccountRepository, + private val tokenProvider: GoogleAccountTokenProvider ) { + @Volatile + private var cachedDefault: Account? = null + + @Volatile + private var cacheWarm = false + + init { + AuroraApp.scope.launch { + importLegacyAccountIfNeeded() + accountRepository.defaultAccount.collect { account -> + cachedDefault = account + cacheWarm = true + } + } + } val dispenserURL: String? get() { @@ -53,19 +77,32 @@ class AuthProvider @Inject constructor( return if (dispensers.isNotEmpty()) dispensers.random() else null } + /** Default account's AuthData (or BOGUS). Served from the cache when warm (see [init]). */ val authData: AuthData? - get() { - Log.i(TAG, "Loading saved AuthData") - val rawAuth: String = Preferences.getString(context, PREFERENCE_AUTH_DATA) - return if (rawAuth.isNotBlank()) { - json.decodeFromString(rawAuth) - } else { - AuthData("BOGUS") - } - } + get() = decodeAuthData(defaultAccountSnapshot()) + /** Whether the default account is anonymous. Served from the cache when warm (see [init]). */ 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(it) } + ?: AuthData("BOGUS") /** * Checks whether saved AuthData is valid or not @@ -134,6 +171,128 @@ class AuthProvider @Inject constructor( } } + /** + * Returns the [AuthData] for the given [accountId], or null if not found / not yet cached. + */ + suspend fun getAuthData(accountId: String): AuthData? { + val account = accountRepository.getById(accountId) ?: return null + return account.authDataJson?.takeIf { it.isNotBlank() } + ?.let { json.decodeFromString(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 { + 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 = refresh(accountId).map { + accountRepository.setDefault(accountId) + syncDefaultToPrefs() + } + + /** + * Mirrors the current DB default account into the legacy single-account prefs. The login gate + * (`AccountProvider.isLoggedIn`) and the session-refresh paths still read those prefs, so this + * must be called after every change of *which* account is the default (login, set-default, + * account removal) to keep them in sync with the DB. Clears the prefs when no default remains. + */ + suspend fun syncDefaultToPrefs() { + val account = accountRepository.getDefault() + if (account == null) { + AccountProvider.logout(context) + return + } + val token = account.aasToken ?: account.authToken.orEmpty() + AccountProvider.login(context, account.email, token, account.tokenType, account.type) + account.authDataJson?.let { Preferences.putString(context, PREFERENCE_AUTH_DATA, it) } + Preferences.putBoolean( + context, + Preferences.PREFERENCE_AUTH_VIA_MICROG, + account.authViaMicroG + ) + } + + /** + * Persists [authData] for the given account into the account table so the DB-backed + * [authData] stays current. Upserts the row (id derived from type+email) and, when + * [makeDefault] is true, marks it the default. + */ + suspend fun persistAccount( + authData: AuthData, + accountType: AccountType, + authViaMicroG: Boolean, + makeDefault: Boolean = true + ) { + val tokenType = + if (authData.aasToken.isBlank()) AuthHelper.Token.AUTH else AuthHelper.Token.AAS + val account = Account( + id = Account.idFor(accountType, authData.email), + type = accountType, + email = authData.email, + displayName = authData.userProfile?.name, + profilePicUrl = authData.userProfile?.artwork?.url, + aasToken = authData.aasToken.ifBlank { null }, + authToken = authData.authToken.ifBlank { null }, + tokenType = tokenType, + authViaMicroG = authViaMicroG, + authDataJson = json.encodeToString(authData), + isDefault = false + ) + accountRepository.upsertAccount(account) + if (makeDefault) { + accountRepository.setDefault(account.id) + syncDefaultToPrefs() + } + } + + /** + * Full sign-out: removes every account from the DB and clears the legacy single-account + * prefs. Used by the auth-failure / reset paths so a stale account row can't linger as the + * DB is now the source of truth. Uses `runBlocking` (see [authData]); call sparingly. + */ + fun logout() = runBlocking { + accountRepository.getAll().forEach { accountRepository.removeAccount(it.id) } + AccountProvider.logout(context) + } + /** * Saves given [AuthData] */ @@ -148,6 +307,39 @@ class AuthProvider @Inject constructor( Preferences.remove(context, PREFERENCE_AUTH_DATA) } + private suspend fun importLegacyAccountIfNeeded() { + if (accountRepository.getAll().isNotEmpty()) return + if (!Preferences.getBoolean(context, Constants.ACCOUNT_SIGNED_IN, false)) return + + val type = if (Preferences.getString(context, Constants.ACCOUNT_TYPE) == "GOOGLE") { + AccountType.GOOGLE + } else { + AccountType.ANONYMOUS + } + val email = Preferences.getString(context, Constants.ACCOUNT_EMAIL_PLAIN) + val aas = Preferences.getString(context, Constants.ACCOUNT_AAS_PLAIN).ifBlank { null } + val auth = Preferences.getString(context, Constants.ACCOUNT_AUTH_PLAIN).ifBlank { null } + val rawAuth = Preferences.getString(context, PREFERENCE_AUTH_DATA).ifBlank { null } + + accountRepository.upsertAccount( + Account( + id = Account.idFor(type, email), + type = type, + email = email.ifBlank { "anonymous@aurora" }, + aasToken = aas, + authToken = auth, + tokenType = if (aas != null) AuthHelper.Token.AAS else AuthHelper.Token.AUTH, + authViaMicroG = Preferences.getBoolean( + context, + Preferences.PREFERENCE_AUTH_VIA_MICROG, + false + ), + authDataJson = rawAuth, + isDefault = true + ) + ) + } + @Throws(Exception::class) private fun throwError(playResponse: PlayResponse, context: Context) { when (playResponse.code) { diff --git a/app/src/main/java/com/aurora/store/data/providers/GoogleAccountTokenProvider.kt b/app/src/main/java/com/aurora/store/data/providers/GoogleAccountTokenProvider.kt new file mode 100644 index 000000000..1b384d046 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/providers/GoogleAccountTokenProvider.kt @@ -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 = 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 + ) { + 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) + } + } +} diff --git a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt index 69b35446c..66afb52d8 100644 --- a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt +++ b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt @@ -3,6 +3,11 @@ package com.aurora.store.data.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.aurora.store.data.room.account.Account +import com.aurora.store.data.room.account.AccountConverter +import com.aurora.store.data.room.account.AccountDao +import com.aurora.store.data.room.account.AppAccountBinding +import com.aurora.store.data.room.account.AppAccountBindingDao import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.DownloadConverter import com.aurora.store.data.room.download.DownloadDao @@ -21,16 +26,20 @@ import com.aurora.store.data.room.update.UpdateDao Favourite::class, Update::class, IgnoredUpdate::class, - LocalReview::class + LocalReview::class, + Account::class, + AppAccountBinding::class ], - version = 9, + version = 10, exportSchema = true ) -@TypeConverters(DownloadConverter::class) +@TypeConverters(DownloadConverter::class, AccountConverter::class) abstract class AuroraDatabase : RoomDatabase() { abstract fun downloadDao(): DownloadDao abstract fun favouriteDao(): FavouriteDao abstract fun updateDao(): UpdateDao abstract fun ignoredUpdateDao(): IgnoredUpdateDao abstract fun reviewDao(): ReviewDao + abstract fun accountDao(): AccountDao + abstract fun appAccountBindingDao(): AppAccountBindingDao } diff --git a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt index 65d437cae..8243640f2 100644 --- a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt +++ b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt @@ -42,6 +42,10 @@ object MigrationHelper { override fun migrate(db: SupportSQLiteDatabase) = migrateFrom8To9(db) } + val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) = migrateFrom9To10(db) + } + private const val TAG = "MigrationHelper" private fun migrateFrom1To2(database: SupportSQLiteDatabase) { @@ -193,4 +197,48 @@ object MigrationHelper { database.endTransaction() } } + + /** + * Add account & app_account_binding tables for multi-account support. The existing single + * account is imported from SharedPreferences in code on first launch (see AccountRepository), + * because a Room Migration cannot read SharedPreferences. + */ + private fun migrateFrom9To10(database: SupportSQLiteDatabase) { + database.beginTransaction() + try { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `account` (" + + "`id` TEXT NOT NULL, " + + "`type` TEXT NOT NULL, " + + "`email` TEXT NOT NULL, " + + "`displayName` TEXT, " + + "`profilePicUrl` TEXT, " + + "`aasToken` TEXT, " + + "`authToken` TEXT, " + + "`tokenType` TEXT NOT NULL, " + + "`authViaMicroG` INTEGER NOT NULL, " + + "`authDataJson` TEXT, " + + "`isDefault` INTEGER NOT NULL, " + + "`addedAt` INTEGER NOT NULL, " + + "PRIMARY KEY(`id`))" + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `app_account_binding` (" + + "`packageName` TEXT NOT NULL, " + + "`accountId` TEXT NOT NULL, " + + "PRIMARY KEY(`packageName`), " + + "FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_app_account_binding_accountId` " + + "ON `app_account_binding` (`accountId`)" + ) + database.setTransactionSuccessful() + } catch (exception: Exception) { + Log.e(TAG, "Failed while migrating from database version 9 to 10", exception) + } finally { + database.endTransaction() + } + } } diff --git a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt index 88c2b1722..2f23b34ff 100644 --- a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt +++ b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt @@ -10,6 +10,10 @@ import com.aurora.store.data.room.MigrationHelper.MIGRATION_5_6 import com.aurora.store.data.room.MigrationHelper.MIGRATION_6_7 import com.aurora.store.data.room.MigrationHelper.MIGRATION_7_8 import com.aurora.store.data.room.MigrationHelper.MIGRATION_8_9 +import com.aurora.store.data.room.MigrationHelper.MIGRATION_9_10 +import com.aurora.store.data.room.account.AccountConverter +import com.aurora.store.data.room.account.AccountDao +import com.aurora.store.data.room.account.AppAccountBindingDao import com.aurora.store.data.room.download.DownloadConverter import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.data.room.favourite.FavouriteDao @@ -33,7 +37,8 @@ object RoomModule { @Provides fun providesRoomInstance( @ApplicationContext context: Context, - downloadConverter: DownloadConverter + downloadConverter: DownloadConverter, + accountConverter: AccountConverter ): AuroraDatabase = Room.databaseBuilder(context, AuroraDatabase::class.java, DATABASE) .addMigrations( MIGRATION_1_2, @@ -43,9 +48,11 @@ object RoomModule { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, - MIGRATION_8_9 + MIGRATION_8_9, + MIGRATION_9_10 ) .addTypeConverter(downloadConverter) + .addTypeConverter(accountConverter) .build() @Provides @@ -65,4 +72,11 @@ object RoomModule { @Provides fun providesReviewDao(auroraDatabase: AuroraDatabase): ReviewDao = auroraDatabase.reviewDao() + + @Provides + fun providesAccountDao(auroraDatabase: AuroraDatabase): AccountDao = auroraDatabase.accountDao() + + @Provides + fun providesAppAccountBindingDao(auroraDatabase: AuroraDatabase): AppAccountBindingDao = + auroraDatabase.appAccountBindingDao() } diff --git a/app/src/main/java/com/aurora/store/data/room/account/Account.kt b/app/src/main/java/com/aurora/store/data/room/account/Account.kt new file mode 100644 index 000000000..69b3fdd37 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/account/Account.kt @@ -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() + } +} diff --git a/app/src/main/java/com/aurora/store/data/room/account/AccountConverter.kt b/app/src/main/java/com/aurora/store/data/room/account/AccountConverter.kt new file mode 100644 index 000000000..0ad5491e7 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/account/AccountConverter.kt @@ -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 +} diff --git a/app/src/main/java/com/aurora/store/data/room/account/AccountDao.kt b/app/src/main/java/com/aurora/store/data/room/account/AccountDao.kt new file mode 100644 index 000000000..d342faf7e --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/account/AccountDao.kt @@ -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> + + @Query("SELECT * FROM account ORDER BY addedAt ASC") + suspend fun getAll(): List + + @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 + + @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) } + } + } +} diff --git a/app/src/main/java/com/aurora/store/data/room/account/AppAccountBinding.kt b/app/src/main/java/com/aurora/store/data/room/account/AppAccountBinding.kt new file mode 100644 index 000000000..3d03c0d42 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/account/AppAccountBinding.kt @@ -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 +) diff --git a/app/src/main/java/com/aurora/store/data/room/account/AppAccountBindingDao.kt b/app/src/main/java/com/aurora/store/data/room/account/AppAccountBindingDao.kt new file mode 100644 index 000000000..d9ee5ff84 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/account/AppAccountBindingDao.kt @@ -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) +} diff --git a/app/src/main/java/com/aurora/store/data/work/AuthWorker.kt b/app/src/main/java/com/aurora/store/data/work/AuthWorker.kt index 19e30e6ac..a8fe54971 100644 --- a/app/src/main/java/com/aurora/store/data/work/AuthWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/AuthWorker.kt @@ -1,32 +1,20 @@ package com.aurora.store.data.work -import android.accounts.Account -import android.accounts.AccountManager -import android.accounts.AccountManagerCallback import android.content.Context -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Base64 import android.util.Log import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.helpers.AuthHelper import com.aurora.store.data.model.AccountType import com.aurora.store.data.providers.AccountProvider import com.aurora.store.data.providers.AuthProvider -import com.aurora.store.util.CertUtil.GOOGLE_ACCOUNT_TYPE -import com.aurora.store.util.CertUtil.GOOGLE_PLAY_AUTH_TOKEN_TYPE -import com.aurora.store.util.CertUtil.GOOGLE_PLAY_CERT +import com.aurora.store.data.providers.GoogleAccountTokenProvider +import com.aurora.store.util.Preferences import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine /** * Worker to refresh [AuthData] in background @@ -35,6 +23,7 @@ import kotlin.coroutines.suspendCoroutine @HiltWorker open class AuthWorker @AssistedInject constructor( private val authProvider: AuthProvider, + private val tokenProvider: GoogleAccountTokenProvider, @Assisted private val context: Context, @Assisted workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { @@ -73,11 +62,8 @@ open class AuthWorker @AssistedInject constructor( } AuthHelper.Token.AUTH -> { - Log.i( - TAG, - "Refreshing AuthData for personal account using AccountManager" - ) - val newToken = fetchAuthToken(email, tokenPair.first) + Log.i(TAG, "Refreshing AuthData via AccountManager") + val newToken = tokenProvider.fetchAuthToken(email, tokenPair.first) authProvider.buildGoogleAuthData(email, newToken, AuthHelper.Token.AAS) .getOrThrow() } @@ -91,6 +77,16 @@ open class AuthWorker @AssistedInject constructor( } require(verifyAndSaveAuth(authData, accountType) != null) + authProvider.persistAccount( + authData = authData, + accountType = accountType, + authViaMicroG = Preferences.getBoolean( + context, + Preferences.PREFERENCE_AUTH_VIA_MICROG, + false + ), + makeDefault = true + ) Log.i(TAG, "Refreshed AuthData successfully") return Result.success() } catch (exception: Exception) { @@ -99,62 +95,6 @@ open class AuthWorker @AssistedInject constructor( } } - private suspend fun fetchAuthToken(email: String, oldToken: String? = null): String = - suspendCoroutine { continuation -> - fetchAuthToken(email, oldToken) { future -> - try { - val bundle = future.result - val token = bundle.getString(AccountManager.KEY_AUTHTOKEN) - - if (token != null) { - continuation.resume(token) - } else { - continuation.resumeWithException( - IllegalStateException("Auth token is null") - ) - } - } catch (e: Exception) { - continuation.resumeWithException(e) - } - } - } - - private fun fetchAuthToken( - email: String, - oldToken: String? = null, - callback: AccountManagerCallback - ) { - try { - if (oldToken != null) { - // Invalidate the old token before requesting a new one - AccountManager.get(context) - .invalidateAuthToken( - GOOGLE_ACCOUNT_TYPE, - oldToken - ) - } - - AccountManager.get(context) - .getAuthToken( - Account(email, GOOGLE_ACCOUNT_TYPE), - GOOGLE_PLAY_AUTH_TOKEN_TYPE, - Bundle().apply { - putString("overridePackage", PACKAGE_NAME_PLAY_STORE) - putByteArray( - "overrideCertificate", - Base64.decode(GOOGLE_PLAY_CERT, Base64.DEFAULT) - ) - }, - true, - callback, - Handler(Looper.getMainLooper()) - ) - } catch (e: Exception) { - Log.e(TAG, "Failed to fetch auth token", e) - callback.run(null) - } - } - private fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType): AuthData? = if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) { authProvider.saveAuthData(authData) @@ -168,7 +108,7 @@ open class AuthWorker @AssistedInject constructor( authData } else { authProvider.removeAuthData(context) - AccountProvider.logout(context) + authProvider.logout() null } } diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index d9b07b676..2db3fb51c 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -28,10 +28,12 @@ import com.aurora.extensions.isQAndAbove import com.aurora.extensions.isSAndAbove import com.aurora.extensions.requiresObbDir import com.aurora.gplayapi.data.models.PlayFile +import com.aurora.gplayapi.helpers.AuthHelper import com.aurora.gplayapi.helpers.PurchaseHelper import com.aurora.gplayapi.network.IHttpClient import com.aurora.store.AuroraApp import com.aurora.store.R +import com.aurora.store.data.AccountRepository import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.installer.AppInstaller @@ -40,6 +42,7 @@ import com.aurora.store.data.model.DownloadInfo import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.network.HttpClient import com.aurora.store.data.providers.AuthProvider +import com.aurora.store.data.providers.GoogleAccountTokenProvider import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.util.CertUtil @@ -74,14 +77,15 @@ import kotlinx.coroutines.withContext */ @HiltWorker class DownloadWorker @AssistedInject constructor( - authProvider: AuthProvider, + private val authProvider: AuthProvider, + tokenProvider: GoogleAccountTokenProvider, private val downloadDao: DownloadDao, private val appInstaller: AppInstaller, private val httpClient: IHttpClient, - private val purchaseHelper: PurchaseHelper, + private val accountRepository: AccountRepository, @Assisted private val context: Context, @Assisted workerParams: WorkerParameters -) : AuthWorker(authProvider, context, workerParams) { +) : AuthWorker(authProvider, tokenProvider, context, workerParams) { companion object { private const val NOTIFICATION_ID: Int = 200 @@ -92,6 +96,7 @@ class DownloadWorker @AssistedInject constructor( } private lateinit var download: Download + private lateinit var purchaseHelper: PurchaseHelper private val notificationManager = context.getSystemService()!! @@ -130,6 +135,7 @@ class DownloadWorker @AssistedInject constructor( // Fetch required data for download try { download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!) + purchaseHelper = resolvePurchaseHelper(download.packageName) } catch (exception: Exception) { return onFailure(exception) } @@ -366,6 +372,27 @@ class DownloadWorker @AssistedInject constructor( } } + /** + * Builds a [PurchaseHelper] bound to the account this download should use: the app's + * per-app binding if any, otherwise the default account. Refreshes that account's session + * first if it is missing/expired so the purchase isn't rejected. + */ + private suspend fun resolvePurchaseHelper(packageName: String): PurchaseHelper { + val accountId = accountRepository.resolveAccountId(packageName) + var authData = authProvider.getAuthData(accountId) + if (authData == null || !AuthHelper.isValid(authData)) { + // Refresh the resolved account; propagate failure instead of silently falling back to + // a different account, which would purchase a bound app under the wrong identity. + authData = authProvider.refresh(accountId).getOrElse { error -> + throw IllegalStateException( + "Could not refresh session for account $accountId; re-authentication required", + error + ) + } + } + return PurchaseHelper(authData).using(httpClient) + } + /** * Purchases the app to get the download URL of the required files * @param packageName The packageName of the app diff --git a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt index 7275ff99f..8bdc08e6a 100644 --- a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt @@ -26,6 +26,7 @@ import com.aurora.store.data.network.HttpClient import com.aurora.store.data.providers.AccountProvider import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.BlacklistProvider +import com.aurora.store.data.providers.GoogleAccountTokenProvider import com.aurora.store.data.room.update.Update import com.aurora.store.data.room.update.UpdateDao import com.aurora.store.util.CertUtil @@ -61,10 +62,11 @@ class UpdateWorker @AssistedInject constructor( private val updateDao: UpdateDao, private val downloadHelper: DownloadHelper, private val authProvider: AuthProvider, + tokenProvider: GoogleAccountTokenProvider, private val appDetailsHelper: AppDetailsHelper, @Assisted private val context: Context, @Assisted workerParams: WorkerParameters -) : AuthWorker(authProvider, context, workerParams) { +) : AuthWorker(authProvider, tokenProvider, context, workerParams) { companion object { private const val NOTIFICATION_ID = 100 diff --git a/app/src/main/java/com/aurora/store/module/HelperModule.kt b/app/src/main/java/com/aurora/store/module/HelperModule.kt index a2b735258..86ac77f52 100644 --- a/app/src/main/java/com/aurora/store/module/HelperModule.kt +++ b/app/src/main/java/com/aurora/store/module/HelperModule.kt @@ -3,7 +3,6 @@ package com.aurora.store.module import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.CategoryHelper import com.aurora.gplayapi.helpers.ExpandedBrowseHelper -import com.aurora.gplayapi.helpers.PurchaseHelper import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.SearchHelper import com.aurora.gplayapi.helpers.StreamHelper @@ -77,14 +76,6 @@ object HelperModule { ): SearchHelper = SearchHelper(authProvider.authData!!) .using(httpClient) - @Singleton - @Provides - fun providesPurchaseHelperInstance( - authProvider: AuthProvider, - httpClient: IHttpClient - ): PurchaseHelper = PurchaseHelper(authProvider.authData!!) - .using(httpClient) - @Singleton @Provides fun providesWebStreamHelperInstance( diff --git a/app/src/main/java/com/aurora/store/util/RestartUtil.kt b/app/src/main/java/com/aurora/store/util/RestartUtil.kt new file mode 100644 index 000000000..d24c1fdaa --- /dev/null +++ b/app/src/main/java/com/aurora/store/util/RestartUtil.kt @@ -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) + } +} diff --git a/app/src/main/java/com/aurora/store/viewmodel/accounts/AccountsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/accounts/AccountsViewModel.kt index aca34c151..05461968b 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/accounts/AccountsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/accounts/AccountsViewModel.kt @@ -5,10 +5,124 @@ package com.aurora.store.viewmodel.accounts +import android.app.Activity import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aurora.gplayapi.helpers.AuthHelper +import com.aurora.store.data.AccountRepository +import com.aurora.store.data.model.AccountType import com.aurora.store.data.providers.AuthProvider +import com.aurora.store.data.providers.GoogleAccountTokenProvider +import com.aurora.store.data.room.account.Account import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch @HiltViewModel -class AccountsViewModel @Inject constructor(val authProvider: AuthProvider) : ViewModel() +class AccountsViewModel @Inject constructor( + val authProvider: AuthProvider, + private val accountRepository: AccountRepository, + private val tokenProvider: GoogleAccountTokenProvider +) : ViewModel() { + + val accounts = accountRepository.accounts + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Emits true when the default switched (caller should restart), false on failure. */ + private val _switched = MutableSharedFlow() + val switched = _switched.asSharedFlow() + + /** Post-removal navigation signal for the screen. */ + enum class RemoveResult { RESTART, LOGGED_OUT } + + private val _removeResult = MutableSharedFlow() + val removeResult = _removeResult.asSharedFlow() + + /** Emits the outcome of an account add (true = added) so the screen can dismiss progress. */ + private val _addResult = MutableSharedFlow() + val addResult = _addResult.asSharedFlow() + + /** Number of accounts that failed to refresh in the last refresh-all (0 = all succeeded). */ + private val _refreshResult = MutableSharedFlow() + 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 { + 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) + } + } +} diff --git a/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt index b567cdacd..cb43c0c65 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt @@ -43,7 +43,9 @@ import java.net.ConnectException import java.net.UnknownHostException import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -57,6 +59,10 @@ class AuthViewModel @Inject constructor( private val _authState: MutableStateFlow = MutableStateFlow(AuthState.Init) val authState = _authState.asStateFlow() + /** Emits the outcome of adding a Google account (non-default) so the caller can navigate back. */ + private val _accountAdded = MutableSharedFlow() + val accountAdded = _accountAdded.asSharedFlow() + init { updateAuthState() } @@ -77,6 +83,31 @@ class AuthViewModel @Inject constructor( } } + /** + * Adds a Google account WITHOUT changing the active default (used by the account screen's + * "add account" flow). Does not touch [authState], so the current session/UI is unaffected. + * Re-syncs the prefs to the real default afterwards, because the AC2DM step + * ([buildAuthData]) writes the new account's e-mail/token into the legacy prefs mid-flow. + */ + fun addGoogleAuthData(email: String, token: String) { + viewModelScope.launch(Dispatchers.IO) { + val added = runCatching { + val authData = authProvider + .buildGoogleAuthData(email, token, AuthHelper.Token.AAS) + .getOrThrow() + require(authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) + authProvider.persistAccount( + authData = authData, + accountType = AccountType.GOOGLE, + authViaMicroG = false, + makeDefault = false + ) + }.isSuccess + authProvider.syncDefaultToPrefs() + _accountAdded.emit(added) + } + } + fun buildAnonymousAuthData() { _authState.value = AuthState.Fetching viewModelScope.launch(Dispatchers.IO) { @@ -178,7 +209,7 @@ class AuthViewModel @Inject constructor( } } - private fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType) { + private suspend fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType) { _authState.value = AuthState.Verifying if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) { authProvider.saveAuthData(authData) @@ -202,10 +233,20 @@ class AuthViewModel @Inject constructor( tokenType == AuthHelper.Token.AUTH && PackageUtil.hasSupportedMicroGVariant(context) ) + authProvider.persistAccount( + authData = authData, + accountType = accountType, + authViaMicroG = Preferences.getBoolean( + context, + Preferences.PREFERENCE_AUTH_VIA_MICROG, + false + ), + makeDefault = true + ) _authState.value = AuthState.SignedIn } else { authProvider.removeAuthData(context) - AccountProvider.logout(context) + authProvider.logout() _authState.value = AuthState.Failed(context.getString(R.string.failed_to_generate_session)) } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index 2207a7178..f0b2ce318 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -27,6 +27,7 @@ import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper import com.aurora.gplayapi.network.IHttpClient import com.aurora.store.AuroraApp import com.aurora.store.BuildConfig +import com.aurora.store.data.AccountRepository import com.aurora.store.data.event.AuthEvent import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.helper.DownloadHelper @@ -81,7 +82,8 @@ class AppDetailsViewModel @Inject constructor( private val favouriteDao: FavouriteDao, private val reviewDao: ReviewDao, private val httpClient: IHttpClient, - private val json: Json + private val json: Json, + private val accountRepository: AccountRepository ) : ViewModel() { private val _app = MutableStateFlow(null) @@ -321,12 +323,19 @@ class AppDetailsViewModel @Inject constructor( } } + val accounts = accountRepository.accounts + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + fun enqueueDownload(app: App) { viewModelScope.launch(Dispatchers.IO) { downloadHelper.enqueueApp(app) } } + fun enqueueDownloadWith(app: App, accountId: String) { + viewModelScope.launch(Dispatchers.IO) { downloadHelper.enqueueApp(app, accountId) } + } + fun cancelDownload(app: App) { viewModelScope.launch { downloadHelper.cancelDownload(app.packageName) } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/sheets/AppUpdateViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/sheets/AppUpdateViewModel.kt index d54856445..e1295c62d 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/sheets/AppUpdateViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/sheets/AppUpdateViewModel.kt @@ -7,18 +7,28 @@ package com.aurora.store.viewmodel.sheets import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.aurora.store.data.AccountRepository +import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.UpdateHelper import com.aurora.store.data.providers.BlacklistProvider +import com.aurora.store.data.room.update.Update import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @HiltViewModel class AppUpdateViewModel @Inject constructor( val blacklistProvider: BlacklistProvider, - private val updateHelper: UpdateHelper + private val updateHelper: UpdateHelper, + private val downloadHelper: DownloadHelper, + accountRepository: AccountRepository ) : ViewModel() { + val accounts = accountRepository.accounts + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + fun ignoreAllUpdates(packageName: String) { viewModelScope.launch { updateHelper.ignoreAll(packageName) } } @@ -26,4 +36,9 @@ class AppUpdateViewModel @Inject constructor( fun ignoreVersion(packageName: String, versionCode: Long) { viewModelScope.launch { updateHelper.ignoreVersion(packageName, versionCode) } } + + /** Downloads this update using a chosen account, recording the per-app binding. */ + fun updateWithAccount(update: Update, accountId: String) { + viewModelScope.launch { downloadHelper.enqueueUpdate(update, accountId) } + } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt index 4a627d325..9e96c6869 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.util.Log import androidx.lifecycle.ViewModel import com.aurora.extensions.TAG +import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.NativeDeviceInfoProvider import com.aurora.store.data.providers.SpoofProvider import com.aurora.store.util.PathUtil @@ -19,9 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow @HiltViewModel class SpoofViewModel @Inject constructor( private val spoofProvider: SpoofProvider, + private val authProvider: AuthProvider, @ApplicationContext private val context: Context ) : ViewModel() { + /** Full sign-out, clearing both the account DB rows and the legacy prefs. */ + fun logout() = authProvider.logout() + val defaultLocale: Locale = Locale.getDefault() val defaultProperties = NativeDeviceInfoProvider.getNativeDeviceProperties(context) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dcc13a6a4..07af8fe81 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,25 @@ "Google" Log in using Log out from Aurora + Set as default + Default + Remove + Remove account? + Remove %1$s from Aurora Store? + 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. + Add account + Device accounts + Sign in with Google + Add anonymous account + Switch default account? + Aurora Store will restart to use the new default account. + Switching account… + Adding account… + Refresh all accounts + Refreshing accounts… + All accounts refreshed + Couldn\'t refresh that account, so the default wasn\'t changed. + Couldn\'t add that account. Please try again. "Back" "Blacklist" "Add to blacklist" @@ -79,6 +98,7 @@ "Install" "Install all" "Install microG Bundle" + Switch account "Installations" "Installing" "Join" @@ -613,6 +633,14 @@ %1$d favourite %1$d favourites + + Total %1$d account + Total %1$d accounts + + + Couldn\'t refresh %1$d account + Couldn\'t refresh %1$d accounts + %d app installed %d apps installed From 3f0205dea446d5abf62df81a9e70d7b228b405be Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sun, 7 Jun 2026 01:54:13 +0530 Subject: [PATCH 3/3] Tint category icons and reduce their size --- .../java/com/aurora/store/compose/composable/CategoryItem.kt | 2 ++ app/src/main/res/values/dimens.xml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt b/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt index 95167ad99..bfab20dc7 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt @@ -18,6 +18,7 @@ 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.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource @@ -51,6 +52,7 @@ fun CategoryItem(modifier: Modifier = Modifier, category: Category, onClick: () .build(), contentDescription = null, contentScale = ContentScale.Crop, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), modifier = Modifier .requiredSize(dimensionResource(R.dimen.icon_size_category)) .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 2cfe2648b..ea73c6667 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -25,7 +25,7 @@ 24dp 24dp - 36dp + 32dp 48dp 56dp 64dp