Merge branch 'dev' into 'master'
dev See merge request AuroraOSS/AuroraStore!585
This commit is contained in:
518
app/schemas/com.aurora.store.data.room.AuroraDatabase/10.json
Normal file
518
app/schemas/com.aurora.store.data.room.AuroraDatabase/10.json
Normal file
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 10,
|
||||
"identityHash": "3673db774ee7f10031b6460a5ac18647",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "download",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `size` INTEGER NOT NULL, `id` INTEGER NOT NULL, `downloadStatus` TEXT NOT NULL, `progress` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `timeRemaining` INTEGER NOT NULL, `totalFiles` INTEGER NOT NULL, `downloadedFiles` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `downloadedAt` INTEGER NOT NULL, `requiresGMS` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionCode",
|
||||
"columnName": "versionCode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "offerType",
|
||||
"columnName": "offerType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isInstalled",
|
||||
"columnName": "isInstalled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "downloadStatus",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progress",
|
||||
"columnName": "progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "speed",
|
||||
"columnName": "speed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeRemaining",
|
||||
"columnName": "timeRemaining",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "totalFiles",
|
||||
"columnName": "totalFiles",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "downloadedFiles",
|
||||
"columnName": "downloadedFiles",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileList",
|
||||
"columnName": "fileList",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sharedLibs",
|
||||
"columnName": "sharedLibs",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "targetSdk",
|
||||
"columnName": "targetSdk",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "downloadedAt",
|
||||
"columnName": "downloadedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "requiresGMS",
|
||||
"columnName": "requiresGMS",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "favourite",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `added` INTEGER NOT NULL, `mode` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "added",
|
||||
"columnName": "added",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mode",
|
||||
"columnName": "mode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "update",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `versionName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `changelog` TEXT NOT NULL, `id` INTEGER NOT NULL, `developerName` TEXT NOT NULL, `size` INTEGER NOT NULL, `updatedOn` TEXT NOT NULL, `hasValidCert` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `isIncompatible` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionCode",
|
||||
"columnName": "versionCode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionName",
|
||||
"columnName": "versionName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconURL",
|
||||
"columnName": "iconURL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "changelog",
|
||||
"columnName": "changelog",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "developerName",
|
||||
"columnName": "developerName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedOn",
|
||||
"columnName": "updatedOn",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasValidCert",
|
||||
"columnName": "hasValidCert",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "offerType",
|
||||
"columnName": "offerType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileList",
|
||||
"columnName": "fileList",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sharedLibs",
|
||||
"columnName": "sharedLibs",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "targetSdk",
|
||||
"columnName": "targetSdk",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isIncompatible",
|
||||
"columnName": "isIncompatible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "ignored_update",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoredVersionCode` INTEGER, PRIMARY KEY(`packageName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ignoredVersionCode",
|
||||
"columnName": "ignoredVersionCode",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "review",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `accountEmail` TEXT NOT NULL, `title` TEXT NOT NULL, `comment` TEXT NOT NULL, `rating` INTEGER NOT NULL, `commentId` TEXT NOT NULL, `userName` TEXT NOT NULL, `userPhotoUrl` TEXT NOT NULL, `appVersion` TEXT NOT NULL, `timeStamp` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`packageName`, `accountEmail`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountEmail",
|
||||
"columnName": "accountEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "comment",
|
||||
"columnName": "comment",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rating",
|
||||
"columnName": "rating",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "commentId",
|
||||
"columnName": "commentId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userName",
|
||||
"columnName": "userName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userPhotoUrl",
|
||||
"columnName": "userPhotoUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "appVersion",
|
||||
"columnName": "appVersion",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeStamp",
|
||||
"columnName": "timeStamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "synced",
|
||||
"columnName": "synced",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName",
|
||||
"accountEmail"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `profilePicUrl` TEXT, `aasToken` TEXT, `authToken` TEXT, `tokenType` TEXT NOT NULL, `authViaMicroG` INTEGER NOT NULL, `authDataJson` TEXT, `isDefault` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "profilePicUrl",
|
||||
"columnName": "profilePicUrl",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "aasToken",
|
||||
"columnName": "aasToken",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "authToken",
|
||||
"columnName": "authToken",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tokenType",
|
||||
"columnName": "tokenType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authViaMicroG",
|
||||
"columnName": "authViaMicroG",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authDataJson",
|
||||
"columnName": "authDataJson",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "addedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "app_account_binding",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `accountId` TEXT NOT NULL, PRIMARY KEY(`packageName`), FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "packageName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"packageName"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_app_account_binding_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_account_binding_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "account",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3673db774ee7f10031b6460a5ac18647')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
@@ -31,7 +31,7 @@ sealed class Destination {
|
||||
data class PermissionRationale(val permissions: Set<PermissionType>) : Destination()
|
||||
|
||||
data object Accounts : Destination()
|
||||
data object GoogleLogin : Destination()
|
||||
data class GoogleLogin(val addAccount: Boolean = false) : Destination()
|
||||
data object About : Destination()
|
||||
data object Favourite : Destination()
|
||||
data object Spoof : Destination()
|
||||
|
||||
@@ -64,8 +64,22 @@ import com.aurora.store.data.event.AuthEvent
|
||||
import com.aurora.store.data.event.InstallerEvent
|
||||
import com.aurora.store.data.model.AccountType
|
||||
import com.aurora.store.data.providers.AccountProvider
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import com.aurora.store.util.Preferences
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* Lets this VM-less navigation host reach the [AuthProvider] singleton for full sign-out.
|
||||
*/
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface NavDisplayEntryPoint {
|
||||
fun authProvider(): AuthProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation display for compose screens
|
||||
@@ -83,7 +97,11 @@ fun NavDisplay(startDestination: NavKey) {
|
||||
|
||||
fun handleMicroGRemoved() {
|
||||
context.toast(R.string.microg_removed_auth_warning)
|
||||
AccountProvider.logout(context)
|
||||
// Full sign-out: clears the account DB rows (incl. the now-invalid microG account) as
|
||||
// well as the legacy prefs, so the stale account can't linger as the DB is the source
|
||||
// of truth.
|
||||
EntryPointAccessors.fromApplication(context, NavDisplayEntryPoint::class.java)
|
||||
.authProvider().logout()
|
||||
val intent = Intent(context, ComposeActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
@@ -146,11 +164,11 @@ fun NavDisplay(startDestination: NavKey) {
|
||||
is Destination.DevProfile -> backstack.add(Screen.DevProfile(destination.devId))
|
||||
is Destination.AppUpdate -> Unit
|
||||
is Destination.StreamBrowse -> backstack.add(Screen.StreamBrowse(destination.cluster))
|
||||
is Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin(destination.addAccount))
|
||||
|
||||
Destination.Search -> backstack.add(Screen.Search)
|
||||
Destination.Downloads -> backstack.add(Screen.Downloads)
|
||||
Destination.Accounts -> backstack.add(Screen.Accounts)
|
||||
Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin)
|
||||
Destination.About -> backstack.add(Screen.About)
|
||||
Destination.Favourite -> backstack.add(Screen.Favourite)
|
||||
Destination.Spoof -> backstack.add(Screen.Spoof)
|
||||
@@ -279,11 +297,18 @@ fun NavDisplay(startDestination: NavKey) {
|
||||
onNavigateTo = ::navigate
|
||||
)
|
||||
}
|
||||
|
||||
entry<Screen.GoogleLogin> { screen ->
|
||||
GoogleLoginScreen(
|
||||
addAccount = screen.addAccount,
|
||||
onNavigateTo = ::navigate
|
||||
)
|
||||
}
|
||||
|
||||
entry<Screen.Onboarding> { OnboardingScreen() }
|
||||
entry<Screen.Blacklist> { BlacklistScreen() }
|
||||
entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) }
|
||||
entry<Screen.Accounts> { AccountsScreen(onNavigateTo = ::navigate) }
|
||||
entry<Screen.GoogleLogin> { GoogleLoginScreen(onNavigateTo = ::navigate) }
|
||||
entry<Screen.About> { AboutScreen() }
|
||||
entry<Screen.Favourite> { FavouriteScreen(onNavigateTo = ::navigate) }
|
||||
entry<Screen.Spoof> { SpoofScreen(onNavigateTo = ::navigate) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,107 +5,319 @@
|
||||
|
||||
package com.aurora.store.compose.ui.accounts
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.aurora.Constants.URL_DISCLAIMER
|
||||
import com.aurora.Constants.URL_LICENSE
|
||||
import com.aurora.Constants.URL_TOS
|
||||
import com.aurora.extensions.browse
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.compose.composable.AccountListItem
|
||||
import com.aurora.store.compose.composable.SectionHeader
|
||||
import com.aurora.store.compose.composable.TopAppBar
|
||||
import com.aurora.store.compose.navigation.Destination
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.data.providers.AccountProvider
|
||||
import com.aurora.store.compose.ui.commons.LoadingDialog
|
||||
import com.aurora.store.compose.ui.sheets.AccountActionsSheet
|
||||
import com.aurora.store.data.room.account.Account
|
||||
import com.aurora.store.util.RestartUtil
|
||||
import com.aurora.store.viewmodel.accounts.AccountsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountsScreen(
|
||||
onNavigateTo: (Destination) -> Unit,
|
||||
viewModel: AccountsViewModel = hiltViewModel()
|
||||
) {
|
||||
val userProfile = viewModel.authProvider.authData?.userProfile
|
||||
val avatar = when {
|
||||
userProfile != null -> userProfile.artwork.url
|
||||
else -> R.mipmap.ic_launcher
|
||||
}
|
||||
val name = when {
|
||||
viewModel.authProvider.isAnonymous -> stringResource(R.string.account_anonymous)
|
||||
userProfile != null -> userProfile.name
|
||||
else -> stringResource(R.string.account_anonymous)
|
||||
}
|
||||
val email = when {
|
||||
userProfile != null -> userProfile.email
|
||||
else -> stringResource(R.string.account_anonymous_email)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
var shouldShowLogoutDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val activity = LocalActivity.current
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
var showAddSheet by remember { mutableStateOf(false) }
|
||||
var selectedAccount by remember { mutableStateOf<Account?>(null) }
|
||||
var accountToRemove by remember { mutableStateOf<Account?>(null) }
|
||||
var accountToSetDefault by remember { mutableStateOf<Account?>(null) }
|
||||
var isSwitchingDefault by remember { mutableStateOf(false) }
|
||||
var isAddingAccount by remember { mutableStateOf(false) }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
|
||||
if (shouldShowLogoutDialog) {
|
||||
LogoutDialog(
|
||||
onConfirm = {
|
||||
shouldShowLogoutDialog = false
|
||||
AccountProvider.logout(context)
|
||||
onNavigateTo(Destination.Splash())
|
||||
},
|
||||
onDismiss = { shouldShowLogoutDialog = false }
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.switched.collect { ok ->
|
||||
if (ok) {
|
||||
RestartUtil.restart(context)
|
||||
} else {
|
||||
isSwitchingDefault = false
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.account_switch_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.removeResult.collect { result ->
|
||||
when (result) {
|
||||
AccountsViewModel.RemoveResult.LOGGED_OUT -> onNavigateTo(Destination.Splash())
|
||||
AccountsViewModel.RemoveResult.RESTART -> RestartUtil.restart(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.addResult.collect { added ->
|
||||
isAddingAccount = false
|
||||
if (!added) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.account_add_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.refreshResult.collect { failures ->
|
||||
isRefreshing = false
|
||||
val message = if (failures == 0) {
|
||||
context.getString(R.string.account_refresh_done)
|
||||
} else {
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.account_refresh_failed,
|
||||
failures,
|
||||
failures
|
||||
)
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
ScreenContent(
|
||||
avatar = avatar,
|
||||
name = name,
|
||||
email = email,
|
||||
onLogout = { shouldShowLogoutDialog = true }
|
||||
accounts = accounts,
|
||||
onAccountClick = { selectedAccount = it },
|
||||
onAddAccount = { showAddSheet = true },
|
||||
onRefreshAll = {
|
||||
isRefreshing = true
|
||||
viewModel.refreshAll(activity)
|
||||
}
|
||||
)
|
||||
|
||||
selectedAccount?.let { account ->
|
||||
AccountActionsSheet(
|
||||
account = account,
|
||||
onSetDefault = {
|
||||
selectedAccount = null
|
||||
accountToSetDefault = account
|
||||
},
|
||||
onRemove = {
|
||||
selectedAccount = null
|
||||
accountToRemove = account
|
||||
},
|
||||
onDismiss = { selectedAccount = null }
|
||||
)
|
||||
}
|
||||
|
||||
accountToSetDefault?.let { account ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { accountToSetDefault = null },
|
||||
title = { Text(text = stringResource(R.string.account_set_default_title)) },
|
||||
text = { Text(text = stringResource(R.string.account_set_default_message)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
accountToSetDefault = null
|
||||
isSwitchingDefault = true
|
||||
viewModel.setDefault(account)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.action_restart))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { accountToSetDefault = null }) {
|
||||
Text(text = stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isSwitchingDefault) {
|
||||
LoadingDialog(message = stringResource(R.string.account_switching))
|
||||
}
|
||||
|
||||
if (isAddingAccount) {
|
||||
LoadingDialog(message = stringResource(R.string.account_adding))
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
LoadingDialog(message = stringResource(R.string.account_refreshing))
|
||||
}
|
||||
|
||||
accountToRemove?.let { account ->
|
||||
val isLast = accounts.size <= 1
|
||||
val name = if (account.isAnonymous) {
|
||||
stringResource(R.string.account_anonymous)
|
||||
} else {
|
||||
account.displayName ?: account.email
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { accountToRemove = null },
|
||||
title = { Text(text = stringResource(R.string.account_remove_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = if (isLast) {
|
||||
stringResource(R.string.account_remove_last_message)
|
||||
} else {
|
||||
stringResource(R.string.account_remove_message, name)
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
accountToRemove = null
|
||||
viewModel.remove(account)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.account_remove))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { accountToRemove = null }) {
|
||||
Text(text = stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddSheet) {
|
||||
val deviceEmails by produceState(initialValue = emptyList()) {
|
||||
value = viewModel.discoverableEmails()
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showAddSheet = false },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = dimensionResource(R.dimen.spacing_small))
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.account_add),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_large),
|
||||
vertical = dimensionResource(R.dimen.spacing_small)
|
||||
)
|
||||
)
|
||||
|
||||
if (deviceEmails.isNotEmpty()) {
|
||||
SectionLabel(text = stringResource(R.string.account_add_device))
|
||||
deviceEmails.forEach { email ->
|
||||
AddOptionRow(
|
||||
iconRes = R.drawable.ic_account,
|
||||
label = email,
|
||||
onClick = {
|
||||
showAddSheet = false
|
||||
isAddingAccount = true
|
||||
viewModel.addSystemAccount(email, activity)
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(
|
||||
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AddOptionRow(
|
||||
iconRes = R.drawable.ic_google,
|
||||
label = stringResource(R.string.account_add_google),
|
||||
onClick = {
|
||||
showAddSheet = false
|
||||
onNavigateTo(Destination.GoogleLogin(addAccount = true))
|
||||
}
|
||||
)
|
||||
|
||||
if (accounts.none { it.isAnonymous }) {
|
||||
AddOptionRow(
|
||||
iconRes = R.drawable.ic_anonymous,
|
||||
label = stringResource(R.string.account_add_anonymous),
|
||||
onClick = {
|
||||
showAddSheet = false
|
||||
isAddingAccount = true
|
||||
viewModel.addAnonymousAccount()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ScreenContent(
|
||||
avatar: Any = R.drawable.ic_account,
|
||||
name: String = stringResource(R.string.account_anonymous),
|
||||
email: String = stringResource(R.string.account_anonymous_email),
|
||||
onLogout: () -> Unit = {}
|
||||
accounts: List<Account> = emptyList(),
|
||||
onAccountClick: (Account) -> Unit = {},
|
||||
onAddAccount: () -> Unit = {},
|
||||
onRefreshAll: () -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = stringResource(R.string.title_account_manager)
|
||||
title = stringResource(R.string.title_account_manager),
|
||||
actions = { OverflowMenu(onRefreshAll = onRefreshAll) }
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
@@ -113,64 +325,21 @@ private fun ScreenContent(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
.padding(vertical = dimensionResource(R.dimen.spacing_small)),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
AssistHeader()
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
@@ -95,6 +96,7 @@ import com.aurora.store.compose.ui.details.menu.AppDetailsMenu
|
||||
import com.aurora.store.compose.ui.details.menu.MenuItem
|
||||
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
|
||||
import com.aurora.store.compose.ui.dev.DevProfileScreen
|
||||
import com.aurora.store.compose.ui.sheets.AccountPickerSheet
|
||||
import com.aurora.store.compose.ui.sheets.InstallErrorSheet
|
||||
import com.aurora.store.data.installer.AppInstaller
|
||||
import com.aurora.store.data.model.AppState
|
||||
@@ -103,6 +105,7 @@ import com.aurora.store.data.model.Report
|
||||
import com.aurora.store.data.model.Scores
|
||||
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
|
||||
import com.aurora.store.data.providers.PermissionProvider.Companion.isPermittedToInstall
|
||||
import com.aurora.store.data.room.account.Account
|
||||
import com.aurora.store.util.FlavouredUtil
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import com.aurora.store.util.ShortcutManagerUtil
|
||||
@@ -129,6 +132,7 @@ fun AppDetailsScreen(
|
||||
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
|
||||
val installError by viewModel.installError.collectAsStateWithLifecycle()
|
||||
val suggestionsBundle by viewModel.suggestionsBundle.collectAsStateWithLifecycle()
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
|
||||
|
||||
@@ -184,7 +188,11 @@ fun AppDetailsScreen(
|
||||
exodusReport = exodusReport,
|
||||
onNavigateTo = onNavigateTo,
|
||||
onLoadMoreCluster = { cluster -> viewModel.loadMoreCluster(cluster) },
|
||||
accounts = accounts,
|
||||
onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
|
||||
onDownloadWith = { requestedApp, accountId ->
|
||||
viewModel.enqueueDownloadWith(requestedApp, accountId)
|
||||
},
|
||||
onFavorite = { viewModel.toggleFavourite(loadedApp) },
|
||||
onCancelDownload = { viewModel.cancelDownload(loadedApp) },
|
||||
onUninstall = { AppInstaller.uninstall(context, packageName) },
|
||||
@@ -274,6 +282,7 @@ private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? =
|
||||
/**
|
||||
* Composable to display app details and suggestions
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ScreenContentApp(
|
||||
app: App,
|
||||
@@ -288,7 +297,9 @@ private fun ScreenContentApp(
|
||||
exodusReport: Report? = null,
|
||||
onNavigateTo: (Destination) -> Unit = {},
|
||||
onLoadMoreCluster: (cluster: StreamCluster) -> Unit = {},
|
||||
accounts: List<Account> = emptyList(),
|
||||
onDownload: (requestedApp: App) -> Unit = {},
|
||||
onDownloadWith: (requestedApp: App, accountId: String) -> Unit = { _, _ -> },
|
||||
onFavorite: () -> Unit = {},
|
||||
onCancelDownload: () -> Unit = {},
|
||||
onUninstall: () -> Unit = {},
|
||||
@@ -322,6 +333,7 @@ private fun ScreenContentApp(
|
||||
val shouldShowMenuOnMainPane = scaffoldNavigator
|
||||
.scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden
|
||||
var showRestartDialog by remember { mutableStateOf(false) }
|
||||
var showAccountPicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showRestartDialog) {
|
||||
ForceRestartDialog(onConfirm = onForceRestart)
|
||||
@@ -339,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,
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -13,6 +13,7 @@ enum class MenuItem {
|
||||
FAVORITE,
|
||||
SHARE,
|
||||
MANUAL_DOWNLOAD,
|
||||
INSTALL_OTHER_ACCOUNT,
|
||||
APP_INFO,
|
||||
ADD_TO_HOME,
|
||||
PLAY_STORE
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.aurora.store.compose.ui.sheets
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.data.room.account.Account
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountPickerSheet(
|
||||
accounts: List<Account>,
|
||||
onSelect: (Account) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_switch_account),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(R.dimen.spacing_xsmall))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(vertical = dimensionResource(R.dimen.spacing_xsmall))
|
||||
) {
|
||||
accounts.forEachIndexed { index, account ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
val suffix = if (account.isDefault) {
|
||||
" · " + stringResource(R.string.account_default)
|
||||
} else {
|
||||
" · " + account.email
|
||||
}
|
||||
|
||||
val name = if (account.isAnonymous) {
|
||||
stringResource(R.string.account_anonymous)
|
||||
} else {
|
||||
account.displayName ?: account.email
|
||||
}
|
||||
|
||||
Text(
|
||||
text = name + suffix,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(account) }
|
||||
.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_large),
|
||||
vertical = dimensionResource(R.dimen.spacing_small)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
package com.aurora.store.compose.ui.sheets
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
@@ -20,21 +27,28 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
@@ -45,6 +59,7 @@ import com.aurora.store.R
|
||||
import com.aurora.store.compose.navigation.Destination
|
||||
import com.aurora.store.data.event.BusEvent
|
||||
import com.aurora.store.data.installer.AppInstaller
|
||||
import com.aurora.store.data.room.account.Account
|
||||
import com.aurora.store.data.room.update.Update
|
||||
import com.aurora.store.viewmodel.sheets.AppUpdateViewModel
|
||||
|
||||
@@ -58,6 +73,8 @@ fun AppUpdateSheet(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(update.packageName)
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
var showAccounts by remember { mutableStateOf(false) }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -105,6 +122,26 @@ fun AppUpdateSheet(
|
||||
}
|
||||
)
|
||||
|
||||
// Per-app update override: only meaningful with more than one account. The primary
|
||||
// update button keeps using the default (or the app's existing binding).
|
||||
if (accounts.size > 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(
|
||||
vertical = dimensionResource(R.dimen.spacing_small)
|
||||
)
|
||||
)
|
||||
|
||||
AccountAccordion(
|
||||
expanded = showAccounts,
|
||||
onToggle = { showAccounts = !showAccounts },
|
||||
accounts = accounts,
|
||||
onSelect = { account ->
|
||||
viewModel.updateWithAccount(update, account.id)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(
|
||||
vertical = dimensionResource(R.dimen.spacing_small)
|
||||
@@ -222,6 +259,90 @@ private fun Item(label: String, onClick: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable "update using another account" row. Tapping the header toggles a tinted sub-menu
|
||||
* listing the available accounts, so it reads as a nested section rather than a sibling action.
|
||||
*/
|
||||
@Composable
|
||||
private fun AccountAccordion(
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
accounts: List<Account>,
|
||||
onSelect: (Account) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(R.dimen.spacing_xsmall))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||
vertical = dimensionResource(R.dimen.spacing_small)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_switch_account),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (expanded) R.drawable.ic_keyboard_arrow_up else R.drawable.ic_keyboard_arrow_down
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(R.dimen.spacing_xsmall))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(vertical = dimensionResource(R.dimen.spacing_xsmall))
|
||||
) {
|
||||
accounts.forEachIndexed { index, account ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
val suffix = if (account.isDefault) {
|
||||
" · " + stringResource(R.string.account_default)
|
||||
} else {
|
||||
" · " + account.email
|
||||
}
|
||||
|
||||
val name = if (account.isAnonymous) {
|
||||
stringResource(R.string.account_anonymous)
|
||||
} else {
|
||||
account.displayName ?: account.email
|
||||
}
|
||||
|
||||
Text(
|
||||
text = name + suffix,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(account) }
|
||||
.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_large),
|
||||
vertical = dimensionResource(R.dimen.spacing_small)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChangelogSection(html: String) {
|
||||
Text(
|
||||
|
||||
@@ -279,7 +279,7 @@ fun SplashScreen(
|
||||
)
|
||||
)
|
||||
} else {
|
||||
onNavigateTo(Destination.GoogleLogin)
|
||||
onNavigateTo(Destination.GoogleLogin())
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -40,7 +40,6 @@ import com.aurora.store.compose.navigation.Destination
|
||||
import com.aurora.store.compose.ui.spoof.menu.MenuItem
|
||||
import com.aurora.store.compose.ui.spoof.menu.SpoofMenu
|
||||
import com.aurora.store.compose.ui.spoof.navigation.SpoofPage
|
||||
import com.aurora.store.data.providers.AccountProvider
|
||||
import com.aurora.store.viewmodel.spoof.SpoofViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -49,7 +48,8 @@ fun SpoofScreen(onNavigateTo: (Destination) -> Unit, viewModel: SpoofViewModel =
|
||||
ScreenContent(
|
||||
onNavigateTo = onNavigateTo,
|
||||
onDeviceSpoofImport = { uri -> viewModel.importDeviceSpoof(uri) },
|
||||
onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) }
|
||||
onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) },
|
||||
onLogout = { viewModel.logout() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ private fun ScreenContent(
|
||||
pages: List<SpoofPage> = listOf(SpoofPage.DEVICE, SpoofPage.LOCALE),
|
||||
onNavigateTo: (Destination) -> Unit = {},
|
||||
onDeviceSpoofImport: (uri: Uri) -> Unit = {},
|
||||
onDeviceSpoofExport: (uri: Uri) -> Unit = {}
|
||||
onDeviceSpoofExport: (uri: Uri) -> Unit = {},
|
||||
onLogout: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val pagerState = rememberPagerState { pages.size }
|
||||
@@ -95,7 +96,7 @@ private fun ScreenContent(
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> {
|
||||
AccountProvider.logout(context)
|
||||
onLogout()
|
||||
onNavigateTo(Destination.Splash())
|
||||
}
|
||||
|
||||
|
||||
79
app/src/main/java/com/aurora/store/data/AccountRepository.kt
Normal file
79
app/src/main/java/com/aurora/store/data/AccountRepository.kt
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data
|
||||
|
||||
import com.aurora.store.data.room.account.Account
|
||||
import com.aurora.store.data.room.account.AccountDao
|
||||
import com.aurora.store.data.room.account.AppAccountBinding
|
||||
import com.aurora.store.data.room.account.AppAccountBindingDao
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Singleton
|
||||
class AccountRepository @Inject constructor(
|
||||
private val accountDao: AccountDao,
|
||||
private val bindingDao: AppAccountBindingDao
|
||||
) {
|
||||
val accounts: Flow<List<Account>> = accountDao.accounts()
|
||||
val defaultAccount: Flow<Account?> = accountDao.observeDefault()
|
||||
|
||||
suspend fun getAll(): List<Account> = accountDao.getAll()
|
||||
|
||||
suspend fun getDefault(): Account? = accountDao.getDefault()
|
||||
|
||||
suspend fun getById(id: String): Account? = accountDao.getById(id)
|
||||
|
||||
/** Inserts/updates an account. Enforces the single-anonymous invariant. */
|
||||
suspend fun upsertAccount(account: Account) {
|
||||
if (account.isAnonymous) {
|
||||
accountDao.upsert(account.copy(id = Account.ANONYMOUS_ID))
|
||||
} else {
|
||||
accountDao.upsert(account)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefault(id: String) = accountDao.replaceDefault(id)
|
||||
|
||||
/**
|
||||
* Removes an account and its bindings. If it was the default, promotes a new default
|
||||
* (anonymous preferred, else the oldest remaining). The default promotion is atomic; the
|
||||
* binding cleanup runs first as a separate statement (FK cascade is not enabled).
|
||||
*/
|
||||
suspend fun removeAccount(id: String) {
|
||||
// SQLite only cascades FK deletes when `PRAGMA foreign_keys = ON`, which this project
|
||||
// does not enable, so this explicit delete is the actual enforcer of the binding cleanup.
|
||||
bindingDao.deleteByAccountId(id)
|
||||
accountDao.deleteAndEnsureDefault(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The account id to use for [packageName]: its binding if one exists, otherwise the current
|
||||
* default account's id. Falls back to [Account.ANONYMOUS_ID] when no default is set yet
|
||||
* (e.g. immediately after migration before login) — callers must treat this as "use the
|
||||
* anonymous account", and the anonymous account is expected to exist by the time a download
|
||||
* runs.
|
||||
*/
|
||||
suspend fun resolveAccountId(packageName: String): String {
|
||||
bindingDao.getForPackage(packageName)?.let { return it.accountId }
|
||||
return accountDao.getDefault()?.id ?: Account.ANONYMOUS_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the account an app was installed with. Binding to the current default clears any
|
||||
* existing binding (default-account installs are never tagged).
|
||||
*/
|
||||
suspend fun bindApp(packageName: String, accountId: String) {
|
||||
val defaultId = accountDao.getDefault()?.id
|
||||
if (accountId == defaultId) {
|
||||
bindingDao.delete(packageName)
|
||||
} else {
|
||||
bindingDao.upsert(AppAccountBinding(packageName, accountId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unbindApp(packageName: String) = bindingDao.delete(packageName)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,16 +19,21 @@
|
||||
|
||||
package com.aurora.store.data.providers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.aurora.Constants
|
||||
import com.aurora.extensions.TAG
|
||||
import com.aurora.gplayapi.data.models.AuthData
|
||||
import com.aurora.gplayapi.data.models.PlayResponse
|
||||
import com.aurora.gplayapi.helpers.AuthHelper
|
||||
import com.aurora.gplayapi.network.IHttpClient
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.data.AccountRepository
|
||||
import com.aurora.store.data.model.AccountType
|
||||
import com.aurora.store.data.model.Auth
|
||||
import com.aurora.store.data.room.account.Account
|
||||
import com.aurora.store.util.Preferences
|
||||
import com.aurora.store.util.Preferences.PREFERENCE_AUTH_DATA
|
||||
import com.aurora.store.util.Preferences.PREFERENCE_DISPENSER_URLS
|
||||
@@ -36,6 +41,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -44,8 +51,25 @@ class AuthProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val json: Json,
|
||||
private val spoofProvider: SpoofProvider,
|
||||
private val httpClient: IHttpClient
|
||||
private val httpClient: IHttpClient,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val tokenProvider: GoogleAccountTokenProvider
|
||||
) {
|
||||
@Volatile
|
||||
private var cachedDefault: Account? = null
|
||||
|
||||
@Volatile
|
||||
private var cacheWarm = false
|
||||
|
||||
init {
|
||||
AuroraApp.scope.launch {
|
||||
importLegacyAccountIfNeeded()
|
||||
accountRepository.defaultAccount.collect { account ->
|
||||
cachedDefault = account
|
||||
cacheWarm = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dispenserURL: String?
|
||||
get() {
|
||||
@@ -53,19 +77,32 @@ class AuthProvider @Inject constructor(
|
||||
return if (dispensers.isNotEmpty()) dispensers.random() else null
|
||||
}
|
||||
|
||||
/** Default account's AuthData (or BOGUS). Served from the cache when warm (see [init]). */
|
||||
val authData: AuthData?
|
||||
get() {
|
||||
Log.i(TAG, "Loading saved AuthData")
|
||||
val rawAuth: String = Preferences.getString(context, PREFERENCE_AUTH_DATA)
|
||||
return if (rawAuth.isNotBlank()) {
|
||||
json.decodeFromString<AuthData>(rawAuth)
|
||||
} 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<AuthData>(it) }
|
||||
?: AuthData("BOGUS")
|
||||
|
||||
/**
|
||||
* Checks whether saved AuthData is valid or not
|
||||
@@ -134,6 +171,128 @@ class AuthProvider @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [AuthData] for the given [accountId], or null if not found / not yet cached.
|
||||
*/
|
||||
suspend fun getAuthData(accountId: String): AuthData? {
|
||||
val account = accountRepository.getById(accountId) ?: return null
|
||||
return account.authDataJson?.takeIf { it.isNotBlank() }
|
||||
?.let { json.decodeFromString<AuthData>(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds and persists [accountId]'s AuthData. Pass an [activity] for user-initiated
|
||||
* refreshes so a device (microG) account can show any consent UI it needs; background
|
||||
* callers omit it and rely on a non-interactive token mint.
|
||||
*/
|
||||
suspend fun refresh(accountId: String, activity: Activity? = null): Result<AuthData> {
|
||||
val account = accountRepository.getById(accountId)
|
||||
?: return Result.failure(IllegalStateException("No account $accountId"))
|
||||
return runCatching {
|
||||
val authData = when (account.type) {
|
||||
AccountType.ANONYMOUS -> buildAnonymousAuthData().getOrThrow()
|
||||
AccountType.GOOGLE -> when (account.tokenType) {
|
||||
AuthHelper.Token.AAS -> {
|
||||
val aasToken = account.aasToken
|
||||
?: throw IllegalStateException(
|
||||
"AAS account ${account.id} has no aasToken"
|
||||
)
|
||||
buildGoogleAuthData(account.email, aasToken, AuthHelper.Token.AAS)
|
||||
.getOrThrow()
|
||||
}
|
||||
AuthHelper.Token.AUTH -> {
|
||||
val token = tokenProvider.fetchAuthToken(
|
||||
account.email,
|
||||
account.authToken,
|
||||
activity
|
||||
)
|
||||
buildGoogleAuthData(account.email, token, AuthHelper.Token.AAS).getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
accountRepository.upsertAccount(
|
||||
account.copy(
|
||||
email = authData.email,
|
||||
authDataJson = json.encodeToString(authData),
|
||||
displayName = authData.userProfile?.name ?: account.displayName,
|
||||
profilePicUrl = authData.userProfile?.artwork?.url ?: account.profilePicUrl
|
||||
)
|
||||
)
|
||||
authData
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes the target account, then marks it default. Leaves the old default if it fails. */
|
||||
suspend fun setDefaultAccount(accountId: String): Result<Unit> = refresh(accountId).map {
|
||||
accountRepository.setDefault(accountId)
|
||||
syncDefaultToPrefs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the current DB default account into the legacy single-account prefs. The login gate
|
||||
* (`AccountProvider.isLoggedIn`) and the session-refresh paths still read those prefs, so this
|
||||
* must be called after every change of *which* account is the default (login, set-default,
|
||||
* account removal) to keep them in sync with the DB. Clears the prefs when no default remains.
|
||||
*/
|
||||
suspend fun syncDefaultToPrefs() {
|
||||
val account = accountRepository.getDefault()
|
||||
if (account == null) {
|
||||
AccountProvider.logout(context)
|
||||
return
|
||||
}
|
||||
val token = account.aasToken ?: account.authToken.orEmpty()
|
||||
AccountProvider.login(context, account.email, token, account.tokenType, account.type)
|
||||
account.authDataJson?.let { Preferences.putString(context, PREFERENCE_AUTH_DATA, it) }
|
||||
Preferences.putBoolean(
|
||||
context,
|
||||
Preferences.PREFERENCE_AUTH_VIA_MICROG,
|
||||
account.authViaMicroG
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists [authData] for the given account into the account table so the DB-backed
|
||||
* [authData] stays current. Upserts the row (id derived from type+email) and, when
|
||||
* [makeDefault] is true, marks it the default.
|
||||
*/
|
||||
suspend fun persistAccount(
|
||||
authData: AuthData,
|
||||
accountType: AccountType,
|
||||
authViaMicroG: Boolean,
|
||||
makeDefault: Boolean = true
|
||||
) {
|
||||
val tokenType =
|
||||
if (authData.aasToken.isBlank()) AuthHelper.Token.AUTH else AuthHelper.Token.AAS
|
||||
val account = Account(
|
||||
id = Account.idFor(accountType, authData.email),
|
||||
type = accountType,
|
||||
email = authData.email,
|
||||
displayName = authData.userProfile?.name,
|
||||
profilePicUrl = authData.userProfile?.artwork?.url,
|
||||
aasToken = authData.aasToken.ifBlank { null },
|
||||
authToken = authData.authToken.ifBlank { null },
|
||||
tokenType = tokenType,
|
||||
authViaMicroG = authViaMicroG,
|
||||
authDataJson = json.encodeToString(authData),
|
||||
isDefault = false
|
||||
)
|
||||
accountRepository.upsertAccount(account)
|
||||
if (makeDefault) {
|
||||
accountRepository.setDefault(account.id)
|
||||
syncDefaultToPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sign-out: removes every account from the DB and clears the legacy single-account
|
||||
* prefs. Used by the auth-failure / reset paths so a stale account row can't linger as the
|
||||
* DB is now the source of truth. Uses `runBlocking` (see [authData]); call sparingly.
|
||||
*/
|
||||
fun logout() = runBlocking {
|
||||
accountRepository.getAll().forEach { accountRepository.removeAccount(it.id) }
|
||||
AccountProvider.logout(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves given [AuthData]
|
||||
*/
|
||||
@@ -148,6 +307,39 @@ class AuthProvider @Inject constructor(
|
||||
Preferences.remove(context, PREFERENCE_AUTH_DATA)
|
||||
}
|
||||
|
||||
private suspend fun importLegacyAccountIfNeeded() {
|
||||
if (accountRepository.getAll().isNotEmpty()) return
|
||||
if (!Preferences.getBoolean(context, Constants.ACCOUNT_SIGNED_IN, false)) return
|
||||
|
||||
val type = if (Preferences.getString(context, Constants.ACCOUNT_TYPE) == "GOOGLE") {
|
||||
AccountType.GOOGLE
|
||||
} else {
|
||||
AccountType.ANONYMOUS
|
||||
}
|
||||
val email = Preferences.getString(context, Constants.ACCOUNT_EMAIL_PLAIN)
|
||||
val aas = Preferences.getString(context, Constants.ACCOUNT_AAS_PLAIN).ifBlank { null }
|
||||
val auth = Preferences.getString(context, Constants.ACCOUNT_AUTH_PLAIN).ifBlank { null }
|
||||
val rawAuth = Preferences.getString(context, PREFERENCE_AUTH_DATA).ifBlank { null }
|
||||
|
||||
accountRepository.upsertAccount(
|
||||
Account(
|
||||
id = Account.idFor(type, email),
|
||||
type = type,
|
||||
email = email.ifBlank { "anonymous@aurora" },
|
||||
aasToken = aas,
|
||||
authToken = auth,
|
||||
tokenType = if (aas != null) AuthHelper.Token.AAS else AuthHelper.Token.AUTH,
|
||||
authViaMicroG = Preferences.getBoolean(
|
||||
context,
|
||||
Preferences.PREFERENCE_AUTH_VIA_MICROG,
|
||||
false
|
||||
),
|
||||
authDataJson = rawAuth,
|
||||
isDefault = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun throwError(playResponse: PlayResponse, context: Context) {
|
||||
when (playResponse.code) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data.providers
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.AccountManagerCallback
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE
|
||||
import com.aurora.extensions.TAG
|
||||
import com.aurora.store.util.CertUtil.GOOGLE_ACCOUNT_TYPE
|
||||
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_AUTH_TOKEN_TYPE
|
||||
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_CERT
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
/**
|
||||
* Wraps the system [AccountManager] for Google/microG accounts: enumerating the accounts present
|
||||
* on-device and minting Play Store AUTH tokens for them.
|
||||
*/
|
||||
@Singleton
|
||||
class GoogleAccountTokenProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
|
||||
/** E-mails of Google accounts present on-device (e.g. provided by microG). */
|
||||
fun systemGoogleAccountEmails(): List<String> = try {
|
||||
AccountManager.get(context).getAccountsByType(GOOGLE_ACCOUNT_TYPE)
|
||||
.map { it.name }
|
||||
.distinct()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to enumerate system Google accounts", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a Play Store AUTH token for [email]. Pass an [activity] for interactive requests
|
||||
* (e.g. adding an account) so microG can show any consent UI it needs; omit it for
|
||||
* background refreshes, which fall back to the non-interactive overload.
|
||||
*/
|
||||
suspend fun fetchAuthToken(
|
||||
email: String,
|
||||
oldToken: String? = null,
|
||||
activity: Activity? = null
|
||||
): String = suspendCancellableCoroutine { continuation ->
|
||||
fetchAuthToken(email, oldToken, activity) { future ->
|
||||
try {
|
||||
val token = future.result.getString(AccountManager.KEY_AUTHTOKEN)
|
||||
if (token != null) {
|
||||
continuation.resume(token)
|
||||
} else {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException("Auth token is null")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAuthToken(
|
||||
email: String,
|
||||
oldToken: String?,
|
||||
activity: Activity?,
|
||||
callback: AccountManagerCallback<Bundle>
|
||||
) {
|
||||
try {
|
||||
val accountManager = AccountManager.get(context)
|
||||
if (oldToken != null) {
|
||||
accountManager.invalidateAuthToken(GOOGLE_ACCOUNT_TYPE, oldToken)
|
||||
}
|
||||
val account = Account(email, GOOGLE_ACCOUNT_TYPE)
|
||||
val options = Bundle().apply {
|
||||
putString("overridePackage", PACKAGE_NAME_PLAY_STORE)
|
||||
putByteArray(
|
||||
"overrideCertificate",
|
||||
Base64.decode(GOOGLE_PLAY_CERT, Base64.DEFAULT)
|
||||
)
|
||||
}
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
if (activity != null) {
|
||||
accountManager.getAuthToken(
|
||||
account,
|
||||
GOOGLE_PLAY_AUTH_TOKEN_TYPE,
|
||||
options,
|
||||
activity,
|
||||
callback,
|
||||
handler
|
||||
)
|
||||
} else {
|
||||
accountManager.getAuthToken(
|
||||
account,
|
||||
GOOGLE_PLAY_AUTH_TOKEN_TYPE,
|
||||
options,
|
||||
true,
|
||||
callback,
|
||||
handler
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch auth token", e)
|
||||
callback.run(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data.room.account
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.aurora.store.data.model.AccountType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AccountDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(account: Account)
|
||||
|
||||
@Query("SELECT * FROM account ORDER BY addedAt ASC")
|
||||
fun accounts(): Flow<List<Account>>
|
||||
|
||||
@Query("SELECT * FROM account ORDER BY addedAt ASC")
|
||||
suspend fun getAll(): List<Account>
|
||||
|
||||
@Query("SELECT * FROM account WHERE id = :id")
|
||||
suspend fun getById(id: String): Account?
|
||||
|
||||
@Query("SELECT * FROM account WHERE isDefault = 1 LIMIT 1")
|
||||
suspend fun getDefault(): Account?
|
||||
|
||||
@Query("SELECT * FROM account WHERE isDefault = 1 LIMIT 1")
|
||||
fun observeDefault(): Flow<Account?>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM account WHERE type = :type")
|
||||
suspend fun countByType(type: AccountType): Int
|
||||
|
||||
@Query("DELETE FROM account WHERE id = :id")
|
||||
suspend fun delete(id: String)
|
||||
|
||||
@Query("UPDATE account SET isDefault = (id = :id)")
|
||||
suspend fun setDefaultOnly(id: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceDefault(id: String) = setDefaultOnly(id)
|
||||
|
||||
/**
|
||||
* Deletes [id] and, only if it was the default, promotes a new default (anonymous preferred,
|
||||
* otherwise the oldest remaining account). Runs in one transaction so observers never see a
|
||||
* transient null default.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun deleteAndEnsureDefault(id: String) {
|
||||
val wasDefault = getById(id)?.isDefault == true
|
||||
delete(id)
|
||||
if (wasDefault) {
|
||||
val remaining = getAll()
|
||||
val next = remaining.firstOrNull { it.isAnonymous } ?: remaining.firstOrNull()
|
||||
next?.let { setDefaultOnly(it.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,32 +1,20 @@
|
||||
package com.aurora.store.data.work
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.AccountManagerCallback
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE
|
||||
import com.aurora.extensions.TAG
|
||||
import com.aurora.gplayapi.data.models.AuthData
|
||||
import com.aurora.gplayapi.helpers.AuthHelper
|
||||
import com.aurora.store.data.model.AccountType
|
||||
import com.aurora.store.data.providers.AccountProvider
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.util.CertUtil.GOOGLE_ACCOUNT_TYPE
|
||||
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_AUTH_TOKEN_TYPE
|
||||
import com.aurora.store.util.CertUtil.GOOGLE_PLAY_CERT
|
||||
import com.aurora.store.data.providers.GoogleAccountTokenProvider
|
||||
import com.aurora.store.util.Preferences
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Worker to refresh [AuthData] in background
|
||||
@@ -35,6 +23,7 @@ import kotlin.coroutines.suspendCoroutine
|
||||
@HiltWorker
|
||||
open class AuthWorker @AssistedInject constructor(
|
||||
private val authProvider: AuthProvider,
|
||||
private val tokenProvider: GoogleAccountTokenProvider,
|
||||
@Assisted private val context: Context,
|
||||
@Assisted workerParams: WorkerParameters
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
@@ -73,11 +62,8 @@ open class AuthWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
AuthHelper.Token.AUTH -> {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Refreshing AuthData for personal account using AccountManager"
|
||||
)
|
||||
val newToken = fetchAuthToken(email, tokenPair.first)
|
||||
Log.i(TAG, "Refreshing AuthData via AccountManager")
|
||||
val newToken = tokenProvider.fetchAuthToken(email, tokenPair.first)
|
||||
authProvider.buildGoogleAuthData(email, newToken, AuthHelper.Token.AAS)
|
||||
.getOrThrow()
|
||||
}
|
||||
@@ -91,6 +77,16 @@ open class AuthWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
require(verifyAndSaveAuth(authData, accountType) != null)
|
||||
authProvider.persistAccount(
|
||||
authData = authData,
|
||||
accountType = accountType,
|
||||
authViaMicroG = Preferences.getBoolean(
|
||||
context,
|
||||
Preferences.PREFERENCE_AUTH_VIA_MICROG,
|
||||
false
|
||||
),
|
||||
makeDefault = true
|
||||
)
|
||||
Log.i(TAG, "Refreshed AuthData successfully")
|
||||
return Result.success()
|
||||
} catch (exception: Exception) {
|
||||
@@ -99,62 +95,6 @@ open class AuthWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAuthToken(email: String, oldToken: String? = null): String =
|
||||
suspendCoroutine { continuation ->
|
||||
fetchAuthToken(email, oldToken) { future ->
|
||||
try {
|
||||
val bundle = future.result
|
||||
val token = bundle.getString(AccountManager.KEY_AUTHTOKEN)
|
||||
|
||||
if (token != null) {
|
||||
continuation.resume(token)
|
||||
} else {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException("Auth token is null")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAuthToken(
|
||||
email: String,
|
||||
oldToken: String? = null,
|
||||
callback: AccountManagerCallback<Bundle>
|
||||
) {
|
||||
try {
|
||||
if (oldToken != null) {
|
||||
// Invalidate the old token before requesting a new one
|
||||
AccountManager.get(context)
|
||||
.invalidateAuthToken(
|
||||
GOOGLE_ACCOUNT_TYPE,
|
||||
oldToken
|
||||
)
|
||||
}
|
||||
|
||||
AccountManager.get(context)
|
||||
.getAuthToken(
|
||||
Account(email, GOOGLE_ACCOUNT_TYPE),
|
||||
GOOGLE_PLAY_AUTH_TOKEN_TYPE,
|
||||
Bundle().apply {
|
||||
putString("overridePackage", PACKAGE_NAME_PLAY_STORE)
|
||||
putByteArray(
|
||||
"overrideCertificate",
|
||||
Base64.decode(GOOGLE_PLAY_CERT, Base64.DEFAULT)
|
||||
)
|
||||
},
|
||||
true,
|
||||
callback,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch auth token", e)
|
||||
callback.run(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType): AuthData? =
|
||||
if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) {
|
||||
authProvider.saveAuthData(authData)
|
||||
@@ -168,7 +108,7 @@ open class AuthWorker @AssistedInject constructor(
|
||||
authData
|
||||
} else {
|
||||
authProvider.removeAuthData(context)
|
||||
AccountProvider.logout(context)
|
||||
authProvider.logout()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,12 @@ import com.aurora.extensions.isQAndAbove
|
||||
import com.aurora.extensions.isSAndAbove
|
||||
import com.aurora.extensions.requiresObbDir
|
||||
import com.aurora.gplayapi.data.models.PlayFile
|
||||
import com.aurora.gplayapi.helpers.AuthHelper
|
||||
import com.aurora.gplayapi.helpers.PurchaseHelper
|
||||
import com.aurora.gplayapi.network.IHttpClient
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.data.AccountRepository
|
||||
import com.aurora.store.data.event.InstallerEvent
|
||||
import com.aurora.store.data.helper.DownloadHelper
|
||||
import com.aurora.store.data.installer.AppInstaller
|
||||
@@ -40,6 +42,7 @@ import com.aurora.store.data.model.DownloadInfo
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
import com.aurora.store.data.network.HttpClient
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.data.providers.GoogleAccountTokenProvider
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.download.DownloadDao
|
||||
import com.aurora.store.util.CertUtil
|
||||
@@ -74,14 +77,15 @@ import kotlinx.coroutines.withContext
|
||||
*/
|
||||
@HiltWorker
|
||||
class DownloadWorker @AssistedInject constructor(
|
||||
authProvider: AuthProvider,
|
||||
private val authProvider: AuthProvider,
|
||||
tokenProvider: GoogleAccountTokenProvider,
|
||||
private val downloadDao: DownloadDao,
|
||||
private val appInstaller: AppInstaller,
|
||||
private val httpClient: IHttpClient,
|
||||
private val purchaseHelper: PurchaseHelper,
|
||||
private val accountRepository: AccountRepository,
|
||||
@Assisted private val context: Context,
|
||||
@Assisted workerParams: WorkerParameters
|
||||
) : AuthWorker(authProvider, context, workerParams) {
|
||||
) : AuthWorker(authProvider, tokenProvider, context, workerParams) {
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID: Int = 200
|
||||
@@ -92,6 +96,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private lateinit var download: Download
|
||||
private lateinit var purchaseHelper: PurchaseHelper
|
||||
|
||||
private val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||
|
||||
@@ -130,6 +135,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
// Fetch required data for download
|
||||
try {
|
||||
download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!)
|
||||
purchaseHelper = resolvePurchaseHelper(download.packageName)
|
||||
} catch (exception: Exception) {
|
||||
return onFailure(exception)
|
||||
}
|
||||
@@ -366,6 +372,27 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [PurchaseHelper] bound to the account this download should use: the app's
|
||||
* per-app binding if any, otherwise the default account. Refreshes that account's session
|
||||
* first if it is missing/expired so the purchase isn't rejected.
|
||||
*/
|
||||
private suspend fun resolvePurchaseHelper(packageName: String): PurchaseHelper {
|
||||
val accountId = accountRepository.resolveAccountId(packageName)
|
||||
var authData = authProvider.getAuthData(accountId)
|
||||
if (authData == null || !AuthHelper.isValid(authData)) {
|
||||
// Refresh the resolved account; propagate failure instead of silently falling back to
|
||||
// a different account, which would purchase a bound app under the wrong identity.
|
||||
authData = authProvider.refresh(accountId).getOrElse { error ->
|
||||
throw IllegalStateException(
|
||||
"Could not refresh session for account $accountId; re-authentication required",
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return PurchaseHelper(authData).using(httpClient)
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchases the app to get the download URL of the required files
|
||||
* @param packageName The packageName of the app
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
20
app/src/main/java/com/aurora/store/util/RestartUtil.kt
Normal file
20
app/src/main/java/com/aurora/store/util/RestartUtil.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.util
|
||||
|
||||
import android.content.Context
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
|
||||
object RestartUtil {
|
||||
/**
|
||||
* Restarts the whole process so all Hilt @Singletons (incl. the gplayapi helpers, which are
|
||||
* bound to the previous default account's AuthData at construction) are rebuilt against the
|
||||
* newly selected default account. Uses ProcessPhoenix, matching the app's other restart sites.
|
||||
*/
|
||||
fun restart(context: Context) {
|
||||
ProcessPhoenix.triggerRebirth(context)
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,124 @@
|
||||
|
||||
package com.aurora.store.viewmodel.accounts
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aurora.gplayapi.helpers.AuthHelper
|
||||
import com.aurora.store.data.AccountRepository
|
||||
import com.aurora.store.data.model.AccountType
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.data.providers.GoogleAccountTokenProvider
|
||||
import com.aurora.store.data.room.account.Account
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class AccountsViewModel @Inject constructor(val authProvider: AuthProvider) : ViewModel()
|
||||
class AccountsViewModel @Inject constructor(
|
||||
val authProvider: AuthProvider,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val tokenProvider: GoogleAccountTokenProvider
|
||||
) : ViewModel() {
|
||||
|
||||
val accounts = accountRepository.accounts
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/** Emits true when the default switched (caller should restart), false on failure. */
|
||||
private val _switched = MutableSharedFlow<Boolean>()
|
||||
val switched = _switched.asSharedFlow()
|
||||
|
||||
/** Post-removal navigation signal for the screen. */
|
||||
enum class RemoveResult { RESTART, LOGGED_OUT }
|
||||
|
||||
private val _removeResult = MutableSharedFlow<RemoveResult>()
|
||||
val removeResult = _removeResult.asSharedFlow()
|
||||
|
||||
/** Emits the outcome of an account add (true = added) so the screen can dismiss progress. */
|
||||
private val _addResult = MutableSharedFlow<Boolean>()
|
||||
val addResult = _addResult.asSharedFlow()
|
||||
|
||||
/** Number of accounts that failed to refresh in the last refresh-all (0 = all succeeded). */
|
||||
private val _refreshResult = MutableSharedFlow<Int>()
|
||||
val refreshResult = _refreshResult.asSharedFlow()
|
||||
|
||||
/** Refreshes the session/token for every account; the default's prefs mirror is re-synced. */
|
||||
fun refreshAll(activity: Activity?) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val all = accountRepository.getAll()
|
||||
val failures = all.count { authProvider.refresh(it.id, activity).isFailure }
|
||||
authProvider.syncDefaultToPrefs()
|
||||
_refreshResult.emit(failures)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefault(account: Account) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val ok = authProvider.setDefaultAccount(account.id).isSuccess
|
||||
_switched.emit(ok)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(account: Account) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val wasDefault = account.isDefault
|
||||
accountRepository.removeAccount(account.id)
|
||||
// Keep the legacy login gate / refresh prefs in sync with the new DB default (or clear
|
||||
// them when nothing is left) — otherwise removing the active account doesn't log out.
|
||||
authProvider.syncDefaultToPrefs()
|
||||
when {
|
||||
accountRepository.getDefault() == null -> _removeResult.emit(
|
||||
RemoveResult.LOGGED_OUT
|
||||
)
|
||||
wasDefault -> _removeResult.emit(RemoveResult.RESTART)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds an anonymous account (non-default) so it can be used without making it active. */
|
||||
fun addAnonymousAccount() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val added = runCatching {
|
||||
val authData = authProvider.buildAnonymousAuthData().getOrThrow()
|
||||
authProvider.persistAccount(
|
||||
authData = authData,
|
||||
accountType = AccountType.ANONYMOUS,
|
||||
authViaMicroG = false,
|
||||
makeDefault = false
|
||||
)
|
||||
}.isSuccess
|
||||
_addResult.emit(added)
|
||||
}
|
||||
}
|
||||
|
||||
/** Google accounts present on-device (via microG) that aren't already added. */
|
||||
suspend fun discoverableEmails(): List<String> {
|
||||
val existing = accountRepository.getAll().map { it.email.lowercase() }.toSet()
|
||||
return tokenProvider.systemGoogleAccountEmails().filter { it.lowercase() !in existing }
|
||||
}
|
||||
|
||||
/** Adds a device (microG) Google account by minting an AUTH token and building AuthData. */
|
||||
fun addSystemAccount(email: String, activity: Activity?) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val added = runCatching {
|
||||
val token = tokenProvider.fetchAuthToken(email = email, activity = activity)
|
||||
val authData = authProvider
|
||||
.buildGoogleAuthData(email, token, AuthHelper.Token.AUTH)
|
||||
.getOrThrow()
|
||||
authProvider.persistAccount(
|
||||
authData = authData,
|
||||
accountType = AccountType.GOOGLE,
|
||||
authViaMicroG = true,
|
||||
makeDefault = false
|
||||
)
|
||||
}.isSuccess
|
||||
_addResult.emit(added)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -57,6 +59,10 @@ class AuthViewModel @Inject constructor(
|
||||
private val _authState: MutableStateFlow<AuthState> = MutableStateFlow(AuthState.Init)
|
||||
val authState = _authState.asStateFlow()
|
||||
|
||||
/** Emits the outcome of adding a Google account (non-default) so the caller can navigate back. */
|
||||
private val _accountAdded = MutableSharedFlow<Boolean>()
|
||||
val accountAdded = _accountAdded.asSharedFlow()
|
||||
|
||||
init {
|
||||
updateAuthState()
|
||||
}
|
||||
@@ -77,6 +83,31 @@ class AuthViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a Google account WITHOUT changing the active default (used by the account screen's
|
||||
* "add account" flow). Does not touch [authState], so the current session/UI is unaffected.
|
||||
* Re-syncs the prefs to the real default afterwards, because the AC2DM step
|
||||
* ([buildAuthData]) writes the new account's e-mail/token into the legacy prefs mid-flow.
|
||||
*/
|
||||
fun addGoogleAuthData(email: String, token: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val added = runCatching {
|
||||
val authData = authProvider
|
||||
.buildGoogleAuthData(email, token, AuthHelper.Token.AAS)
|
||||
.getOrThrow()
|
||||
require(authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty())
|
||||
authProvider.persistAccount(
|
||||
authData = authData,
|
||||
accountType = AccountType.GOOGLE,
|
||||
authViaMicroG = false,
|
||||
makeDefault = false
|
||||
)
|
||||
}.isSuccess
|
||||
authProvider.syncDefaultToPrefs()
|
||||
_accountAdded.emit(added)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildAnonymousAuthData() {
|
||||
_authState.value = AuthState.Fetching
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -178,7 +209,7 @@ class AuthViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType) {
|
||||
private suspend fun verifyAndSaveAuth(authData: AuthData, accountType: AccountType) {
|
||||
_authState.value = AuthState.Verifying
|
||||
if (authData.authToken.isNotEmpty() && authData.deviceConfigToken.isNotEmpty()) {
|
||||
authProvider.saveAuthData(authData)
|
||||
@@ -202,10 +233,20 @@ class AuthViewModel @Inject constructor(
|
||||
tokenType == AuthHelper.Token.AUTH &&
|
||||
PackageUtil.hasSupportedMicroGVariant(context)
|
||||
)
|
||||
authProvider.persistAccount(
|
||||
authData = authData,
|
||||
accountType = accountType,
|
||||
authViaMicroG = Preferences.getBoolean(
|
||||
context,
|
||||
Preferences.PREFERENCE_AUTH_VIA_MICROG,
|
||||
false
|
||||
),
|
||||
makeDefault = true
|
||||
)
|
||||
_authState.value = AuthState.SignedIn
|
||||
} else {
|
||||
authProvider.removeAuthData(context)
|
||||
AccountProvider.logout(context)
|
||||
authProvider.logout()
|
||||
_authState.value =
|
||||
AuthState.Failed(context.getString(R.string.failed_to_generate_session))
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
|
||||
import com.aurora.gplayapi.network.IHttpClient
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.BuildConfig
|
||||
import com.aurora.store.data.AccountRepository
|
||||
import com.aurora.store.data.event.AuthEvent
|
||||
import com.aurora.store.data.event.InstallerEvent
|
||||
import com.aurora.store.data.helper.DownloadHelper
|
||||
@@ -81,7 +82,8 @@ class AppDetailsViewModel @Inject constructor(
|
||||
private val favouriteDao: FavouriteDao,
|
||||
private val reviewDao: ReviewDao,
|
||||
private val httpClient: IHttpClient,
|
||||
private val json: Json
|
||||
private val json: Json,
|
||||
private val accountRepository: AccountRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _app = MutableStateFlow<App?>(null)
|
||||
@@ -321,12 +323,19 @@ class AppDetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val accounts = accountRepository.accounts
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun enqueueDownload(app: App) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
downloadHelper.enqueueApp(app)
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueueDownloadWith(app: App, accountId: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) { downloadHelper.enqueueApp(app, accountId) }
|
||||
}
|
||||
|
||||
fun cancelDownload(app: App) {
|
||||
viewModelScope.launch { downloadHelper.cancelDownload(app.packageName) }
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<dimen name="spacing_xlarge">24dp</dimen>
|
||||
|
||||
<dimen name="icon_size_default">24dp</dimen>
|
||||
<dimen name="icon_size_category">36dp</dimen>
|
||||
<dimen name="icon_size_category">32dp</dimen>
|
||||
<dimen name="icon_size_small">48dp</dimen>
|
||||
<dimen name="icon_size_medium">56dp</dimen>
|
||||
<dimen name="icon_size">64dp</dimen>
|
||||
|
||||
@@ -49,6 +49,25 @@
|
||||
<string name="account_google">"Google"</string>
|
||||
<string name="account_login_using">Log in using</string>
|
||||
<string name="account_logout">Log out from Aurora</string>
|
||||
<string name="account_set_default">Set as default</string>
|
||||
<string name="account_default">Default</string>
|
||||
<string name="account_remove">Remove</string>
|
||||
<string name="account_remove_title">Remove account?</string>
|
||||
<string name="account_remove_message">Remove %1$s from Aurora Store?</string>
|
||||
<string name="account_remove_last_message">This is your only account. Removing it will log you out, and you won\'t be able to use Aurora Store until you sign in again.</string>
|
||||
<string name="account_add">Add account</string>
|
||||
<string name="account_add_device">Device accounts</string>
|
||||
<string name="account_add_google">Sign in with Google</string>
|
||||
<string name="account_add_anonymous">Add anonymous account</string>
|
||||
<string name="account_set_default_title">Switch default account?</string>
|
||||
<string name="account_set_default_message">Aurora Store will restart to use the new default account.</string>
|
||||
<string name="account_switching">Switching account…</string>
|
||||
<string name="account_adding">Adding account…</string>
|
||||
<string name="account_refresh_all">Refresh all accounts</string>
|
||||
<string name="account_refreshing">Refreshing accounts…</string>
|
||||
<string name="account_refresh_done">All accounts refreshed</string>
|
||||
<string name="account_switch_failed">Couldn\'t refresh that account, so the default wasn\'t changed.</string>
|
||||
<string name="account_add_failed">Couldn\'t add that account. Please try again.</string>
|
||||
<string name="action_back">"Back"</string>
|
||||
<string name="action_blacklist">"Blacklist"</string>
|
||||
<string name="action_blacklist_add">"Add to blacklist"</string>
|
||||
@@ -79,6 +98,7 @@
|
||||
<string name="action_install">"Install"</string>
|
||||
<string name="action_install_all">"Install all"</string>
|
||||
<string name="action_install_microG">"Install microG Bundle"</string>
|
||||
<string name="action_switch_account">Switch account</string>
|
||||
<string name="action_installations">"Installations"</string>
|
||||
<string name="action_installing">"Installing"</string>
|
||||
<string name="action_join">"Join"</string>
|
||||
@@ -469,6 +489,9 @@
|
||||
<string name="microg_removed_auth_warning">microG is no longer installed. Run setup again to keep things working smoothly.</string>
|
||||
<string name="microg_installer_subtitle">Requires microG companion app to be installed</string>
|
||||
<string name="microg_installer_desc">Helps you bypass App Integrity (installer only) check</string>
|
||||
<string name="microg_installer_prerequisite_title" translatable="false">Enable microG installer</string>
|
||||
<string name="microg_installer_prerequisite_desc" translatable="false">Before using the microG installer, please ensure you have enabled it.\n\nPlay Store services → ⋮ → App Installer Settings → Allow App Installation</string>
|
||||
<string name="microg_installer_open_settings" translatable="false">Open microG settings</string>
|
||||
<string name="am_installer_subtitle">Full-featured open source package manager</string>
|
||||
<string name="am_installer_desc">Requires App Manager, need adb/root mode to install when miui optimization is on</string>
|
||||
<string name="shizuku_installer_subtitle">Using system APIs directly with adb/root privileges</string>
|
||||
@@ -610,6 +633,14 @@
|
||||
<item quantity="one">%1$d favourite</item>
|
||||
<item quantity="other">%1$d favourites</item>
|
||||
</plurals>
|
||||
<plurals name="account_count">
|
||||
<item quantity="one">Total %1$d account</item>
|
||||
<item quantity="other">Total %1$d accounts</item>
|
||||
</plurals>
|
||||
<plurals name="account_refresh_failed">
|
||||
<item quantity="one">Couldn\'t refresh %1$d account</item>
|
||||
<item quantity="other">Couldn\'t refresh %1$d accounts</item>
|
||||
</plurals>
|
||||
<plurals name="notification_installed_summary">
|
||||
<item quantity="one">%d app installed</item>
|
||||
<item quantity="other">%d apps installed</item>
|
||||
|
||||
Reference in New Issue
Block a user