Merge branch 'dev' into 'master'

dev

Closes #1494 and #1499

See merge request AuroraOSS/AuroraStore!581
This commit is contained in:
Rahul Patel
2026-06-03 00:50:46 +05:30
39 changed files with 1930 additions and 115 deletions

View File

@@ -0,0 +1,391 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "61af7af3b2ff3f720722a2dca3c6d616",
"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"
]
}
}
],
"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, '61af7af3b2ff3f720722a2dca3c6d616')"
]
}
}

View File

@@ -103,6 +103,16 @@
<intent-filter>
<action android:name="android.intent.action.SHOW_APP_INFO" />
</intent-filter>
</activity>
<!-- Confirmation gate for external app-listing deep links (market:// & play.google.com) -->
<activity
android:name=".DeepLinkConfirmActivity"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/AppTheme.Translucent.Transparent">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -121,7 +131,6 @@
<data android:scheme="https" />
<data android:host="market.android.com" />
<data android:host="play.google.com" />
<data android:host="play.google.com" />
</intent-filter>
</activity>

View File

@@ -68,6 +68,10 @@ val isHuawei: Boolean
Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") ||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("hi3")
val isOneUI: Boolean
get() = !getSystemProperty("ro.build.version.oneui").isNullOrBlank() ||
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
@get:SuppressLint("PrivateApi")
val isMiuiOptimizationDisabled: Boolean
get() {

View File

@@ -85,8 +85,13 @@ class AuroraApp : Application(), Configuration.Provider, SingletonImageLoader.Fa
val themeStyle = Preferences.getInteger(this, Preferences.PREFERENCE_THEME_STYLE)
setAppTheme(themeStyle)
// Apply dynamic colors to activities
DynamicColors.applyToActivitiesIfAvailable(this)
// Apply dynamic colors to activities, unless disabled (opt-out, off by default on One UI)
val dynamicColors = Preferences.getBoolean(
this,
Preferences.PREFERENCE_DYNAMIC_COLORS,
Preferences.dynamicColorsDefault
)
if (dynamicColors) DynamicColors.applyToActivitiesIfAvailable(this)
// Required for Shizuku installer
if (isPAndAbove) HiddenApiBypass.addHiddenApiExemptions("I", "L")

View File

@@ -6,7 +6,6 @@
package com.aurora.store
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
@@ -152,22 +151,11 @@ class ComposeActivity : FragmentActivity() {
}
private fun resolveStartDestination(): Screen {
// Parcel-based navigation (e.g. from NotificationUtil)
// Parcel-based navigation (e.g. from NotificationUtil or DeepLinkConfirmActivity, which
// owns the external ACTION_VIEW market:// and play.google.com deep links)
IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java)
?.let { return it }
// Deep links via ACTION_VIEW
if (intent.action == Intent.ACTION_VIEW) {
val data = intent.data
val path = data?.path.orEmpty()
val id = data?.getQueryParameter("id")
return when {
id != null && path.contains("/apps/dev") -> Screen.DevProfile(id)
id != null -> Screen.AppDetails(id)
else -> defaultStart()
}
}
// SEND / SHOW_APP_INFO — getPackageName() handles both
intent.getPackageName()?.let { return Screen.AppDetails(it) }

View File

@@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.FragmentActivity
import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.theme.AuroraTheme
import com.aurora.store.compose.ui.sheets.DeepLinkConfirmSheet
import com.aurora.store.util.Preferences
/**
* Translucent trampoline that gates external [Intent.ACTION_VIEW] app/developer listing deep links
* (market:// and play.google.com links). These are the vector ads exploit to launch Aurora into a
* listing without intent, so a Play Store-style confirmation sheet is shown floating over the
* launching app before forwarding to [ComposeActivity]. When the user has opted out, or the intent
* doesn't resolve to a listing, it forwards immediately without prompting.
*/
class DeepLinkConfirmActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val target = resolveDeepLink()
val shouldConfirm = target != null &&
Preferences.getBoolean(this, Preferences.PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, true)
if (!shouldConfirm) {
forwardToAurora(target)
return
}
val referrerLabel = resolveReferrerLabel()
setContent {
AuroraTheme {
DeepLinkConfirmSheet(
targetLabel = target.deepLinkLabel(),
sourceLabel = referrerLabel,
onOpen = { forwardToAurora(target) },
onDismiss = { finish() }
)
}
}
}
/**
* Resolves the listing requested by the incoming ACTION_VIEW intent, or null when the intent
* carries no id. The action keyword ("details", "dev" or "developer") is the last path segment
* for play.google.com links and the host for market:// links. Both "dev" and "developer" links
* may carry either a numeric developer id (curated developer stream) or a developer name
* (publisher search), so the id is parsed to decide which one to open.
*/
private fun resolveDeepLink(): Screen? {
if (intent.action != Intent.ACTION_VIEW) return null
val data = intent.data ?: return null
val id = data.getQueryParameter("id") ?: return null
return when (data.lastPathSegment ?: data.host) {
"dev", "developer" -> when {
id.toLongOrNull() != null -> Screen.DevProfile(id)
else -> Screen.PublisherProfile(id)
}
else -> Screen.AppDetails(id)
}
}
/**
* Best-effort human-readable name of the app that fired the intent, derived from the activity
* referrer. Resolves an android-app:// referrer to its app label, falling back to the raw host.
* Returns null when no referrer is available.
*/
private fun resolveReferrerLabel(): String? {
val ref = referrer ?: return null
val pkg = if (ref.scheme == "android-app") ref.host else null
if (pkg != null) {
return runCatching {
packageManager.getApplicationLabel(
packageManager.getApplicationInfo(pkg, 0)
).toString()
}.getOrDefault(pkg)
}
return ref.host ?: ref.toString()
}
private fun forwardToAurora(target: Screen?) {
startActivity(
Intent(this, ComposeActivity::class.java).apply {
target?.let { putExtra(Screen.PARCEL_KEY, it) }
// Start ComposeActivity fresh so the parcel is honoured even when Aurora is already
// running; without this a reused instance keeps its current screen. Mirrors the
// deep-link PendingIntents in NotificationUtil.
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
)
finish()
}
private fun Screen.deepLinkLabel(): String = when (this) {
is Screen.AppDetails -> packageName
is Screen.DevProfile -> developerId
is Screen.PublisherProfile -> publisherId
else -> ""
}
}

View File

@@ -14,7 +14,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -30,49 +34,94 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.gplayapi.data.models.App
import com.aurora.store.AuroraApp
import com.aurora.store.R
import com.aurora.store.compose.composable.app.AnimatedAppIcon
import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.theme.colorGreen
import com.aurora.store.compose.theme.colorRed
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.util.PackageUtil
import kotlinx.coroutines.flow.filter
@Composable
fun FavouriteListItem(
modifier: Modifier = Modifier,
favourite: Favourite,
download: Download? = null,
onClick: () -> Unit = {},
onClear: () -> Unit = {}
) {
val context = LocalContext.current
val isInstalled = remember(favourite.packageName) {
PackageUtil.isInstalled(context, favourite.packageName)
// Seed from a one-shot check, then keep it live so the installed tick reflects the app
// being installed or removed without leaving the screen.
var isInstalled by remember(favourite.packageName) {
mutableStateOf(PackageUtil.isInstalled(context, favourite.packageName))
}
LaunchedEffect(favourite.packageName) {
AuroraApp.events.installerEvent
.filter { it.packageName == favourite.packageName }
.collect { event ->
when (event) {
is InstallerEvent.Installed -> isInstalled = true
is InstallerEvent.Uninstalled -> isInstalled = false
else -> {}
}
}
}
// Show download/install progress on the row; only non-finished statuses count as in
// progress, so a downloaded-but-not-installed (COMPLETED) app stays in its idle state.
val inProgress = download != null && !download.isFinished
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
download.progress.toFloat()
} else {
0f
}
val statusText = when {
download?.status == DownloadStatus.DOWNLOADING ->
"${stringResource(R.string.status_downloading)}${download.progress}%"
inProgress && download != null -> stringResource(download.status.localized)
else -> DateUtils.formatDateTime(context, favourite.added, DateUtils.FORMAT_SHOW_DATE)
}
RemovableListItem(onRemove = onClear) { triggerRemove ->
AuroraListItem(
modifier = modifier,
headline = favourite.displayName,
supporting = favourite.packageName,
tertiary = DateUtils.formatDateTime(
context,
favourite.added,
DateUtils.FORMAT_SHOW_DATE
),
tertiary = statusText,
headlineStyle = MaterialTheme.typography.bodyMedium,
onClick = onClick,
leading = {
AsyncImage(
model = ImageRequest.Builder(context)
.data(favourite.iconURL)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
)
if (inProgress) {
AnimatedAppIcon(
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
iconUrl = favourite.iconURL,
inProgress = true,
progress = progress
)
} else {
AsyncImage(
model = ImageRequest.Builder(context)
.data(favourite.iconURL)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
)
}
},
trailing = {
Row(
@@ -81,7 +130,7 @@ fun FavouriteListItem(
dimensionResource(R.dimen.spacing_xsmall)
)
) {
if (isInstalled) {
if (isInstalled && !inProgress) {
Icon(
painter = painterResource(R.drawable.ic_check),
contentDescription = stringResource(R.string.title_installed),
@@ -107,3 +156,18 @@ fun FavouriteListItem(
private fun FavouriteListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
FavouriteListItem(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL))
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun FavouriteListItemDownloadingPreview(
@PreviewParameter(AppPreviewProvider::class) app: App
) {
FavouriteListItem(
favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL),
download = Download.fromApp(app).copy(
status = DownloadStatus.DOWNLOADING,
progress = 45
)
)
}

View File

@@ -15,12 +15,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
@@ -47,15 +47,13 @@ fun PageIndicator(modifier: Modifier = Modifier, totalPages: Int, currentPage: I
Alignment.CenterHorizontally
)
) {
val selectedColor = MaterialTheme.colorScheme.primary
val unselectedColor = MaterialTheme.colorScheme.outlineVariant
repeat(totalPages) { iteration ->
val page = stringResource(R.string.page, iteration)
val isSelected = currentPage == iteration
val color by animateColorAsState(
targetValue = if (isSelected) {
Color.DarkGray
} else {
Color.LightGray
},
targetValue = if (isSelected) selectedColor else unselectedColor,
animationSpec = tween()
)
val size by animateDpAsState(

View File

@@ -43,8 +43,10 @@ fun AppUpdateItem(
onCancel: () -> Unit = {},
onUnignore: (() -> Unit)? = null
) {
// Only the INSTALLING status shows as "Installing"; a downloaded-but-not-installed
// (COMPLETED) app falls back to the "Update" action.
val inProgress = download != null && !download.isFinished
val pendingInstall = download?.status == DownloadStatus.COMPLETED
val installing = download?.status == DownloadStatus.INSTALLING
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
download.progress.toFloat()
} else {
@@ -65,7 +67,7 @@ fun AppUpdateItem(
AnimatedAppIcon(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
iconUrl = update.iconURL,
inProgress = inProgress || pendingInstall,
inProgress = inProgress,
progress = progress
)
}
@@ -100,7 +102,7 @@ fun AppUpdateItem(
}
}
pendingInstall -> {
installing -> {
OutlinedButton(onClick = {}, enabled = false) {
Text(stringResource(R.string.action_installing))
}

View File

@@ -216,6 +216,13 @@ fun NavDisplay(startDestination: NavKey) {
)
}
entry<Screen.PublisherProfile> { screen ->
DevProfileScreen(
publisherId = screen.publisherId,
onNavigateTo = ::navigate
)
}
entry<Screen.PermissionRationale> { screen ->
PermissionRationaleScreen(
requiredPermissions = screen.requiredPermissions

View File

@@ -29,6 +29,9 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
data class DevProfile(val developerId: String) : Screen()
@Serializable
data class PublisherProfile(val publisherId: String) : Screen()
@Serializable
data class AppDetails(val packageName: String) : Screen()

View File

@@ -5,10 +5,101 @@
package com.aurora.store.compose.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
val warningColor = Color(0xFFFF7600) // Amber
val successColor = Color(0xFF1B8738) // Green
/**
* Whether the active [MaterialTheme] is dark.
*
* Derived from the resolved color scheme rather than [androidx.compose.foundation.isSystemInDarkTheme]
* so it stays correct when the user forces a light/dark theme that differs from the system setting.
*/
@Composable
@ReadOnlyComposable
private fun isAppInDarkTheme(): Boolean = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val colorGreen = Color(0xFF4CAF50)
val colorRed = Color(0xFFF44336)
/**
* Amber used to flag warnings/caveats. Lightened in dark theme for adequate contrast.
*/
val warningColor: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFFF7600)
/**
* Green used to flag positive/success states. Lightened in dark theme for adequate contrast.
*/
val successColor: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFF5BD27A) else Color(0xFF1B8738)
val colorGreen: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFF81C784) else Color(0xFF388E3C)
val colorRed: Color
@Composable @ReadOnlyComposable
get() = if (isAppInDarkTheme()) Color(0xFFE57373) else Color(0xFFD32F2F)
/**
* Brand color schemes seeded from Aurora's accent (#6C63FF), used on devices that don't support
* dynamic color (Android 11 and below) so the full palette stays on-brand instead of falling back
* to Material's default purple baseline.
*/
val BrandLightColorScheme = lightColorScheme(
primary = Color(0xFF6C63FF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFE4DFFF),
onPrimaryContainer = Color(0xFF1A0066),
secondary = Color(0xFF5C5D72),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE1E0F9),
onSecondaryContainer = Color(0xFF191A2C),
tertiary = Color(0xFF78536B),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFFD8EE),
onTertiaryContainer = Color(0xFF2E1126),
background = Color(0xFFFCF8FF),
onBackground = Color(0xFF1B1B21),
surface = Color(0xFFFCF8FF),
onSurface = Color(0xFF1B1B21),
surfaceVariant = Color(0xFFE4E1EC),
onSurfaceVariant = Color(0xFF47464F),
outline = Color(0xFF777680),
outlineVariant = Color(0xFFC8C5D0),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002)
)
val BrandDarkColorScheme = darkColorScheme(
primary = Color(0xFFC8BFFF),
onPrimary = Color(0xFF31149C),
primaryContainer = Color(0xFF534BD6),
onPrimaryContainer = Color(0xFFE4DFFF),
secondary = Color(0xFFC5C4DD),
onSecondary = Color(0xFF2E2F42),
secondaryContainer = Color(0xFF444559),
onSecondaryContainer = Color(0xFFE1E0F9),
tertiary = Color(0xFFE8B9D5),
onTertiary = Color(0xFF46263B),
tertiaryContainer = Color(0xFF5E3C52),
onTertiaryContainer = Color(0xFFFFD8EE),
background = Color(0xFF131318),
onBackground = Color(0xFFE4E1E9),
surface = Color(0xFF131318),
onSurface = Color(0xFFE4E1E9),
surfaceVariant = Color(0xFF47464F),
onSurfaceVariant = Color(0xFFC8C5D0),
outline = Color(0xFF918F9A),
outlineVariant = Color(0xFF47464F),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6)
)

View File

@@ -11,27 +11,23 @@ import android.os.Build
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.colorResource
import androidx.core.view.WindowCompat
import com.aurora.store.R
import com.aurora.store.util.Preferences
/**
* App theme for Aurora Store based on [MaterialTheme]
* App theme for Aurora Store based on [MaterialExpressiveTheme]
*/
@Composable
fun AuroraTheme(content: @Composable () -> Unit) {
@@ -40,28 +36,42 @@ fun AuroraTheme(content: @Composable () -> Unit) {
var themeStyle by remember {
mutableIntStateOf(Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE))
}
var dynamicColor by remember {
mutableStateOf(
Preferences.getBoolean(
context,
Preferences.PREFERENCE_DYNAMIC_COLORS,
Preferences.dynamicColorsDefault
)
)
}
DisposableEffect(Unit) {
val prefs = Preferences.getPrefs(context)
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == Preferences.PREFERENCE_THEME_STYLE) {
themeStyle = Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE)
when (key) {
Preferences.PREFERENCE_THEME_STYLE ->
themeStyle = Preferences.getInteger(context, key)
Preferences.PREFERENCE_DYNAMIC_COLORS ->
dynamicColor =
Preferences.getBoolean(context, key, Preferences.dynamicColorsDefault)
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
}
val isDynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val useDynamicColor = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val lightScheme = if (isDynamicColorSupported) {
val lightScheme = if (useDynamicColor) {
dynamicLightColorScheme(context)
} else {
lightColorScheme(primary = colorResource(id = R.color.colorAccent))
BrandLightColorScheme
}
val darkScheme = if (isDynamicColorSupported) {
val darkScheme = if (useDynamicColor) {
dynamicDarkColorScheme(context)
} else {
darkColorScheme(primary = colorResource(id = R.color.colorAccent))
BrandDarkColorScheme
}
val colorScheme = when (themeStyle) {
@@ -77,21 +87,22 @@ fun AuroraTheme(content: @Composable () -> Unit) {
}
/**
* Side effect to update the system bars to be transparent and match the theme's light/dark mode.
* This is necessary on OEM devices that don't properly support dynamic theming and may have issues with light/dark status bar icons.
* Keep the status/navigation bar icon appearance in sync with the theme's light/dark mode.
*
* The bars themselves are kept transparent and edge-to-edge by [androidx.activity.enableEdgeToEdge]
* (called once in the host activity); we deliberately don't touch `window.statusBarColor` /
* `navigationBarColor` here since those setters are deprecated no-ops on Android 15+ and would
* otherwise strip the system contrast scrim. Re-applying the appearance is still required because
* the user can force a light/dark theme that differs from the system, and that isn't known when
* edge-to-edge is first configured.
*/
val view = LocalView.current
val activity = LocalActivity.current
if (!view.isInEditMode) {
SideEffect {
val currentActivity = activity ?: return@SideEffect
val window = currentActivity.window
window.statusBarColor = android.graphics.Color.TRANSPARENT
window.navigationBarColor = android.graphics.Color.TRANSPARENT
WindowCompat
.getInsetsController(window, view)
.getInsetsController(currentActivity.window, view)
.apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme

View File

@@ -0,0 +1,64 @@
/*
* 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.Column
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.dimensionResource
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
/**
* Confirmation dialog for bulk-installing all favourite apps.
* @param count Number of favourites that will be processed
* @param onConfirm Callback on confirmation
* @param onDismiss Callback on dismissal
*/
@Composable
fun InstallFavouritesDialog(
count: Int = 0,
onConfirm: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
AlertDialog(
title = { Text(text = stringResource(R.string.title_install_favourites)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_small)
)
) {
Text(text = stringResource(R.string.install_favourites_summary, count))
Text(text = stringResource(R.string.install_favourites_warning))
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(R.string.action_install))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.action_cancel))
}
}
)
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun InstallFavouritesDialogPreview() {
InstallFavouritesDialog(count = 5)
}

View File

@@ -7,6 +7,7 @@
package com.aurora.store.compose.ui.details
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
@@ -89,6 +90,7 @@ import com.aurora.store.compose.ui.details.composable.RatingAndReviews
import com.aurora.store.compose.ui.details.composable.Screenshots
import com.aurora.store.compose.ui.details.composable.Tags
import com.aurora.store.compose.ui.details.composable.Testing
import com.aurora.store.compose.ui.details.composable.UserReview
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
@@ -120,6 +122,7 @@ fun AppDetailsScreen(
val app by viewModel.app.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val featuredReviews by viewModel.featuredReviews.collectAsStateWithLifecycle()
val userReview by viewModel.userReview.collectAsStateWithLifecycle()
val favorite by viewModel.favourite.collectAsStateWithLifecycle()
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
@@ -129,12 +132,22 @@ fun AppDetailsScreen(
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
LaunchedEffect(Unit) {
viewModel.reviewPosted.collect { success ->
context.toast(
if (success) R.string.toast_rated_success else R.string.toast_rated_failed
)
}
}
app?.let { loadedApp ->
installError?.let { err ->
InstallErrorSheet(
app = loadedApp,
error = err.error,
extra = err.extra,
isAnonymous = viewModel.authProvider.isAnonymous,
onBuy = { openPlayStore(context, loadedApp.packageName) },
onDismiss = viewModel::dismissInstallError
)
}
@@ -161,6 +174,7 @@ fun AppDetailsScreen(
ScreenContentApp(
app = loadedApp,
featuredReviews = featuredReviews,
userReview = userReview,
suggestionsBundle = suggestionsBundle,
isFavorite = favorite,
isAnonymous = viewModel.authProvider.isAnonymous,
@@ -186,6 +200,14 @@ fun AppDetailsScreen(
onTestingSubscriptionChange = { subscribe ->
viewModel.updateTestingProgramStatus(packageName, subscribe)
},
onSubmitReview = { rating, title, comment ->
viewModel.postAppReview(
loadedApp.packageName,
Review(title = title, comment = comment, rating = rating),
loadedApp.testingProgram?.isSubscribed ?: false
)
},
onDeleteReview = { viewModel.deleteAppReview(loadedApp) },
forceSinglePane = forceSinglePane,
onForceRestart = {
val intent = Intent(context, ComposeActivity::class.java)
@@ -198,6 +220,21 @@ fun AppDetailsScreen(
}
}
/**
* Opens the given app's Play Store listing, preferring the Play Store app when available and
* falling back to whichever activity can handle the web listing (e.g. a browser).
*/
private fun openPlayStore(context: Context, packageName: String) {
val uri = "${Constants.SHARE_URL}$packageName".toUri()
val intent = Intent(Intent.ACTION_VIEW).apply { data = uri }
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent.apply { setPackage(Constants.PACKAGE_NAME_PLAY_STORE) })
} else {
context.startActivity(intent)
}
}
private fun stateKey(state: AppState, app: App?): String = when {
state is AppState.Error -> "error"
state is AppState.Loading || app == null -> "loading"
@@ -241,6 +278,7 @@ private fun ScreenContentError(message: String? = null, onRetry: (() -> Unit)? =
private fun ScreenContentApp(
app: App,
featuredReviews: List<Review> = emptyList(),
userReview: Review? = null,
suggestionsBundle: StreamBundle? = StreamBundle.EMPTY,
isFavorite: Boolean = false,
isAnonymous: Boolean = true,
@@ -256,11 +294,18 @@ private fun ScreenContentApp(
onUninstall: () -> Unit = {},
onOpen: () -> Unit = {},
onTestingSubscriptionChange: (subscribe: Boolean) -> Unit = {},
onSubmitReview: (rating: Int, title: String, comment: String) -> Unit = { _, _, _ -> },
onDeleteReview: () -> Unit = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2(),
forceSinglePane: Boolean = false,
onForceRestart: () -> Unit = {}
) {
val context = LocalContext.current
// Anonymous accounts can't purchase, so a paid app can neither be installed nor manually
// downloaded (any version) by them. Free apps are always acquirable.
val canAcquire = app.isFree || !isAnonymous
var scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo)
if (forceSinglePane) {
@@ -317,7 +362,11 @@ private fun ScreenContentApp(
@Composable
fun SetupMenu() {
AppDetailsMenu(isFavorite = isFavorite, state = state) { menuItem ->
AppDetailsMenu(
isFavorite = isFavorite,
state = state,
canManualDownload = canAcquire
) { menuItem ->
when (menuItem) {
MenuItem.FAVORITE -> onFavorite()
@@ -333,20 +382,7 @@ private fun ScreenContentApp(
ShortcutManagerUtil.requestPinShortcut(context, app.packageName)
}
MenuItem.PLAY_STORE -> {
val uri = "${Constants.SHARE_URL}${app.packageName}".toUri()
val intent = Intent(Intent.ACTION_VIEW).apply { data = uri }
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(
intent.apply {
setPackage(Constants.PACKAGE_NAME_PLAY_STORE)
}
)
} else {
context.startActivity(intent)
}
}
MenuItem.PLAY_STORE -> openPlayStore(context, app.packageName)
}
}
}
@@ -413,6 +449,8 @@ private fun ScreenContentApp(
Actions(
primaryActionDisplayName = primaryActionName,
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
isPrimaryActionEnabled = canAcquire,
isSecondaryActionEnabled = canAcquire,
onPrimaryAction = ::onInstall,
onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
)
@@ -487,6 +525,17 @@ private fun ScreenContentApp(
)
}
item {
// Reviews can only be submitted by personal accounts for installed apps.
if (!isAnonymous && app.isInstalled) {
UserReview(
review = userReview,
onSubmit = onSubmitReview,
onDelete = onDeleteReview
)
}
}
item {
if (!isAnonymous && app.testingProgram?.isAvailable == true) {
Testing(

View File

@@ -0,0 +1,287 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.composable
import android.text.format.DateUtils
import android.widget.RatingBar
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.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
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.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.viewinterop.AndroidView
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.gplayapi.data.models.Review
import com.aurora.store.R
import com.aurora.store.compose.composable.SectionHeader
import com.aurora.store.compose.preview.ThemePreviewProvider
/**
* Composable that lets a signed-in user rate and review an app. Once a review exists it shows a
* read-only summary with an "Edit your review" action; otherwise (or while editing) it shows the
* rating form. Meant to be used as a part of the Column with proper vertical arrangement spacing
* in the AppDetailsScreen. Callers are responsible for only showing this to non-anonymous accounts
* for installed apps.
* @param review The user's existing review, used to pre-fill the form and drive the summary
* @param onSubmit Callback invoked with the rating, title and comment when the user posts
* @param onDelete Callback invoked when the user confirms deletion of their review
*/
@Composable
fun UserReview(
review: Review? = null,
onSubmit: (rating: Int, title: String, comment: String) -> Unit = { _, _, _ -> },
onDelete: () -> Unit = {}
) {
// Default to editing only when there is no review yet. Keying on whether a review exists flips
// back to the summary once one is posted, while still letting "Edit" toggle within that state.
var isEditing by rememberSaveable(review == null) { mutableStateOf(review == null) }
if (review != null && !isEditing) {
ReviewSummary(review = review, onEdit = { isEditing = true }, onDelete = onDelete)
} else {
ReviewForm(
review = review,
onSubmit = { rating, title, comment ->
onSubmit(rating, title, comment)
isEditing = false
}
)
}
}
/**
* Read-only view of the user's posted review with actions to edit or delete it.
*/
@Composable
private fun ReviewSummary(review: Review, onEdit: () -> Unit, onDelete: () -> Unit) {
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
if (showDeleteDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.details_review_delete_title)) },
text = { Text(text = stringResource(R.string.details_review_delete_message)) },
onDismissRequest = { showDeleteDialog = false },
confirmButton = {
TextButton(
onClick = {
showDeleteDialog = false
onDelete()
}
) {
Text(text = stringResource(R.string.details_review_delete))
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text(text = stringResource(android.R.string.cancel))
}
}
)
}
SectionHeader(title = stringResource(R.string.details_your_review))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.spacing_medium)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
) {
Row(
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(review.userPhotoUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_small))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
)
Column {
Text(
text = review.userName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_xsmall)
),
verticalAlignment = Alignment.CenterVertically
) {
AndroidView(
factory = { context ->
RatingBar(
context,
null,
android.R.attr.ratingBarStyleIndicator
).apply {
numStars = 5
stepSize = 1F
setIsIndicator(true)
}
},
update = { it.rating = review.rating.toFloat() }
)
if (review.timeStamp > 0L) {
Text(text = "·", style = MaterialTheme.typography.bodySmall)
Text(
text = DateUtils.formatDateTime(
LocalContext.current,
review.timeStamp,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR
),
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
if (review.title.isNotBlank()) {
Text(text = review.title, style = MaterialTheme.typography.titleSmall)
}
if (review.comment.isNotBlank()) {
Text(text = review.comment, style = MaterialTheme.typography.bodyMedium)
}
Row(
modifier = Modifier.align(Alignment.End),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
) {
TextButton(onClick = { showDeleteDialog = true }) {
Text(text = stringResource(R.string.details_review_delete))
}
TextButton(onClick = onEdit) {
Text(text = stringResource(R.string.details_review_edit))
}
}
}
}
/**
* Editable form for rating an app and writing an optional review.
*/
@Composable
private fun ReviewForm(
review: Review?,
onSubmit: (rating: Int, title: String, comment: String) -> Unit
) {
// Key on the field values rather than the Review instance: Review.equals only compares
// commentId, so keying on the instance would miss content changes synced from the Play Store.
var rating by rememberSaveable(review?.rating) { mutableIntStateOf(review?.rating ?: 0) }
var title by rememberSaveable(review?.title) { mutableStateOf(review?.title.orEmpty()) }
var comment by rememberSaveable(review?.comment) { mutableStateOf(review?.comment.orEmpty()) }
SectionHeader(title = stringResource(R.string.details_think_this_app))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.spacing_medium)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
) {
AndroidView(
factory = { context ->
RatingBar(context, null, android.R.attr.ratingBarStyle).apply {
numStars = 5
stepSize = 1F
setOnRatingBarChangeListener { _, value, fromUser ->
if (fromUser) rating = value.toInt()
}
}
},
update = { it.rating = rating.toFloat() }
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = title,
onValueChange = { title = it },
placeholder = { Text(text = stringResource(R.string.details_ratings_title_hint)) },
singleLine = true,
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium))
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = comment,
onValueChange = { comment = it },
placeholder = { Text(text = stringResource(R.string.details_review_comment_hint)) },
minLines = 3,
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium))
)
Button(
modifier = Modifier.align(Alignment.End),
onClick = { onSubmit(rating, title, comment) },
enabled = rating > 0
) {
Text(text = stringResource(R.string.action_post))
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun UserReviewFormPreview() {
Column(
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium))
) {
UserReview()
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun UserReviewSummaryPreview() {
Column(
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium))
) {
UserReview(
review = Review(
title = "Great app",
comment = "Works flawlessly without Google Play Services.",
rating = 5
)
)
}
}

View File

@@ -42,6 +42,7 @@ fun AppDetailsMenu(
state: AppState = AppState.Unavailable,
isFavorite: Boolean = false,
isExpanded: Boolean = false,
canManualDownload: Boolean = true,
onMenuItemClicked: (menuItem: MenuItem) -> Unit = {}
) {
val context = LocalContext.current
@@ -80,7 +81,7 @@ fun AppDetailsMenu(
DropdownMenuItem(
text = { Text(text = stringResource(R.string.title_manual_download)) },
onClick = { onClick(MenuItem.MANUAL_DOWNLOAD) },
enabled = !state.inProgress()
enabled = canManualDownload && !state.inProgress()
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_info)) },

View File

@@ -15,6 +15,8 @@ import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -30,14 +32,19 @@ import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.aurora.extensions.adaptiveNavigationIcon
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.data.models.details.DevStream
import com.aurora.store.R
import com.aurora.store.compose.composable.ContainedLoadingIndicator
import com.aurora.store.compose.composable.Placeholder
import com.aurora.store.compose.composable.StreamCarousel
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.composable.app.LargeAppListItem
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.ViewState
import com.aurora.store.viewmodel.details.DevProfileViewModel
import com.aurora.store.viewmodel.search.SearchViewModel
import kotlin.random.Random
@@ -53,7 +60,61 @@ fun DevProfileScreen(
onNavigateTo: (Destination) -> Unit,
viewModel: DevProfileViewModel = hiltViewModel()
) {
// TODO: Implement when migrating logic for current DevProfileFragment
val state by viewModel.liveData.observeAsState()
LaunchedEffect(developerId) { viewModel.getStreamBundle(developerId) }
val devStream = (state as? ViewState.Success<*>)?.data as? DevStream
DevStreamContent(
title = devStream?.title?.takeIf { it.isNotBlank() } ?: developerId,
streamBundle = devStream?.streamBundle,
isError = state is ViewState.Error,
onRetry = { viewModel.getStreamBundle(developerId) },
onClusterScrolled = viewModel::observeCluster,
onNavigateTo = onNavigateTo
)
}
@Composable
private fun DevStreamContent(
title: String,
streamBundle: StreamBundle?,
isError: Boolean,
onRetry: () -> Unit = {},
onClusterScrolled: (StreamCluster) -> Unit = {},
onNavigateTo: (Destination) -> Unit = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2()
) {
Scaffold(
topBar = {
TopAppBar(
title = title,
navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon
)
}
) { paddingValues ->
if (isError) {
Placeholder(
modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_refresh),
message = stringResource(R.string.error),
actionLabel = stringResource(R.string.action_retry),
onAction = onRetry
)
} else {
// A null bundle renders the loading shimmer until the stream resolves
StreamCarousel(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
streamBundle = streamBundle,
onHeaderClick = { onNavigateTo(Destination.StreamBrowse(it)) },
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
onClusterScrolled = onClusterScrolled
)
}
}
}
/**

View File

@@ -15,9 +15,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -25,11 +29,13 @@ import androidx.compose.ui.Modifier
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
@@ -43,12 +49,15 @@ import com.aurora.store.compose.composable.ContainedLoadingIndicator
import com.aurora.store.compose.composable.FavouriteListItem
import com.aurora.store.compose.composable.Placeholder
import com.aurora.store.compose.composable.ScrollHint
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.FavouritePreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.commons.InstallFavouritesDialog
import com.aurora.store.compose.ui.favourite.menu.FavouriteMenu
import com.aurora.store.compose.ui.favourite.menu.MenuItem
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.viewmodel.all.FavouriteViewModel
import java.util.Calendar
@@ -62,6 +71,24 @@ fun FavouriteScreen(
) {
val context = LocalContext.current
val favourites = viewModel.favourites.collectAsLazyPagingItems()
val isEnqueuing by viewModel.isEnqueuing.collectAsStateWithLifecycle()
val hasInstallableFavourites by viewModel.hasInstallableFavourites
.collectAsStateWithLifecycle()
val downloads by viewModel.downloadsList.collectAsStateWithLifecycle()
val downloadMap = remember(downloads) { downloads.associateBy { it.packageName } }
LaunchedEffect(Unit) {
viewModel.enqueueResult.collect { count ->
when {
count > 0 -> context.toast(
context.getString(R.string.toast_fav_install_enqueued, count)
)
count == 0 -> context.toast(R.string.toast_fav_install_none)
else -> context.toast(R.string.toast_fav_install_failed)
}
}
}
val documentOpenLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
@@ -89,7 +116,11 @@ fun FavouriteScreen(
ScreenContent(
favourites = favourites,
downloads = downloadMap,
isEnqueuing = isEnqueuing,
showInstallAll = hasInstallableFavourites,
onNavigateTo = onNavigateTo,
onInstallAll = { viewModel.installAll() },
onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) },
onImportFavourites = {
documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE))
@@ -105,7 +136,11 @@ fun FavouriteScreen(
@Composable
private fun ScreenContent(
favourites: LazyPagingItems<Favourite> = emptyPagingItems(),
downloads: Map<String, Download> = emptyMap(),
isEnqueuing: Boolean = false,
showInstallAll: Boolean = true,
onNavigateTo: (Destination) -> Unit = {},
onInstallAll: () -> Unit = {},
onRemoveFavourite: (packageName: String) -> Unit = {},
onImportFavourites: () -> Unit = {},
onExportFavourites: () -> Unit = {}
@@ -117,6 +152,18 @@ private fun ScreenContent(
* Save the initial loading state to make sure we don't replay the loading animation again.
*/
var initialLoad by rememberSaveable { mutableStateOf(true) }
var showInstallDialog by remember { mutableStateOf(false) }
if (showInstallDialog) {
InstallFavouritesDialog(
count = favourites.itemCount,
onConfirm = {
showInstallDialog = false
onInstallAll()
},
onDismiss = { showInstallDialog = false }
)
}
@Composable
fun SetupMenu() {
@@ -162,6 +209,29 @@ private fun ScreenContent(
LazyColumn(
state = listState
) {
item(key = "header") {
SectionHeader(
title = pluralStringResource(
R.plurals.favourites_count,
favourites.itemCount,
favourites.itemCount
),
trailing = if (showInstallAll) {
{
TextButton(
onClick = { showInstallDialog = true },
enabled = !isEnqueuing
) {
Text(
stringResource(R.string.action_install_all)
)
}
}
} else {
null
}
)
}
items(
count = favourites.itemCount,
key = favourites.itemKey { it.packageName }
@@ -170,6 +240,7 @@ private fun ScreenContent(
FavouriteListItem(
modifier = Modifier.animateItem(),
favourite = favourite,
download = downloads[favourite.packageName],
onClick = {
onNavigateTo(
Destination.AppDetails(favourite.packageName)

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.core.net.toUri
import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.isTAndAbove
import com.aurora.extensions.setAppTheme
import com.aurora.store.R
@@ -38,6 +39,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_DEFAULT_SELECTED_TAB
import com.aurora.store.util.Preferences.PREFERENCE_DYNAMIC_COLORS
import com.aurora.store.util.Preferences.PREFERENCE_FOR_YOU
import com.aurora.store.util.Preferences.PREFERENCE_THEME_STYLE
import com.aurora.store.util.save
@@ -62,6 +64,15 @@ private fun ScreenContent() {
var forYou by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_FOR_YOU, true))
}
var dynamicColors by remember {
mutableStateOf(
Preferences.getBoolean(
context,
PREFERENCE_DYNAMIC_COLORS,
Preferences.dynamicColorsDefault
)
)
}
var showThemeDialog by remember { mutableStateOf(false) }
var showTabDialog by remember { mutableStateOf(false) }
@@ -132,6 +143,29 @@ private fun ScreenContent() {
supportingContent = { Text(themeEntries.getOrElse(themeStyle) { "" }) }
)
}
if (isSAndAbove) {
item {
ListItem(
modifier = Modifier.clickable {
dynamicColors = !dynamicColors
context.save(PREFERENCE_DYNAMIC_COLORS, dynamicColors)
},
headlineContent = { Text(stringResource(R.string.pref_ui_dynamic_color)) },
supportingContent = {
Text(stringResource(R.string.pref_ui_dynamic_color_desc))
},
trailingContent = {
Switch(
checked = dynamicColors,
onCheckedChange = { checked ->
dynamicColors = checked
context.save(PREFERENCE_DYNAMIC_COLORS, checked)
}
)
}
)
}
}
item { HorizontalDivider() }
item {
ListItem(headlineContent = { Text(stringResource(R.string.pref_ui_layout)) })

View File

@@ -30,6 +30,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.util.AppLockAuthenticator
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
import com.aurora.store.util.Preferences.PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK
import com.aurora.store.util.save
@Composable
@@ -43,6 +44,11 @@ private fun ScreenContent() {
var appLockEnabled by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false))
}
var confirmDeepLink by remember {
mutableStateOf(
Preferences.getBoolean(context, PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, true)
)
}
fun setAppLock(enabled: Boolean) {
// Refuse to enable the lock when the device has no biometric or screen-lock to fall back on
@@ -54,6 +60,11 @@ private fun ScreenContent() {
context.save(PREFERENCE_APP_LOCK_ENABLED, enabled)
}
fun setConfirmDeepLink(enabled: Boolean) {
confirmDeepLink = enabled
context.save(PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK, enabled)
}
Scaffold(
topBar = { TopAppBar(title = stringResource(R.string.title_security)) }
) { paddingValues ->
@@ -75,6 +86,20 @@ private fun ScreenContent() {
}
)
}
item {
ListItem(
modifier = Modifier.clickable { setConfirmDeepLink(!confirmDeepLink) },
headlineContent = { Text(stringResource(R.string.confirm_deeplink_title)) },
supportingContent = { Text(stringResource(R.string.confirm_deeplink_summary)) },
trailingContent = {
Switch(
checked = confirmDeepLink,
onCheckedChange = { setConfirmDeepLink(it) }
)
}
)
}
}
}
}

View File

@@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.sheets
import androidx.compose.foundation.Image
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.foundation.layout.requiredSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.aurora.store.R
/**
* Play Store-style bottom sheet shown before opening an app/developer listing from an external
* deep link. Requires an explicit tap so ads cannot silently launch Aurora into a listing.
*
* @param targetLabel Package name or developer id the link points to
* @param sourceLabel Human-readable name of the app that fired the link, or null if unknown
* @param onOpen Invoked when the user confirms opening the listing
* @param onDismiss Invoked when the user cancels or dismisses the sheet
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeepLinkConfirmSheet(
targetLabel: String,
sourceLabel: String?,
onOpen: () -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_medium)
)
) {
Image(
painter = painterResource(R.drawable.ic_logo_alt),
contentDescription = null,
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))
)
Text(
text = stringResource(R.string.confirm_deeplink_sheet_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall))
)
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
),
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.spacing_xsmall)
)
) {
Text(
text = stringResource(R.string.confirm_deeplink_sheet_message),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = targetLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!sourceLabel.isNullOrBlank()) {
Text(
text = stringResource(R.string.confirm_deeplink_sheet_source, sourceLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.spacing_xsmall),
vertical = dimensionResource(R.dimen.spacing_xsmall)
),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.action_cancel))
}
TextButton(onClick = onOpen) {
Text(text = stringResource(R.string.action_open))
}
}
Spacer(Modifier.navigationBarsPadding())
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -41,9 +42,20 @@ import com.aurora.store.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () -> Unit) {
fun InstallErrorSheet(
app: App,
error: String?,
extra: String?,
isAnonymous: Boolean = true,
onBuy: () -> Unit = {},
onDismiss: () -> Unit
) {
val context = LocalContext.current
// A paid app that failed to install for a signed-in (non-anonymous) account is almost
// always one that hasn't been purchased yet; offer a shortcut to buy it on the Play Store.
val canBuy = !app.isFree && !isAnonymous
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -53,7 +65,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () ->
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Header(app = app)
Header(app = app, showBuy = canBuy, onBuy = onBuy)
HorizontalDivider(
modifier = Modifier.padding(
@@ -101,7 +113,7 @@ fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () ->
}
@Composable
private fun Header(app: App) {
private fun Header(app: App, showBuy: Boolean = false, onBuy: () -> Unit = {}) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -137,5 +149,14 @@ private fun Header(app: App) {
overflow = TextOverflow.Ellipsis
)
}
if (showBuy) {
Button(onClick = onBuy) {
Text(
text = stringResource(R.string.action_buy, app.price),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -73,13 +73,31 @@ class DownloadHelper @Inject constructor(
*/
fun init() {
AuroraApp.scope.launch {
cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList())
val downloads = downloadDao.downloads().firstOrNull() ?: emptyList()
cancelFailedDownloads(downloads)
finalizeStaleSelfUpdate(downloads)
}.invokeOnCompletion {
observeDownloads()
observeInstalls()
}
}
/**
* Finalizes a self-update download left dangling in [DownloadStatus.INSTALLING]. Replacing
* the app's own APK kills the process before the installer's "installed" event can advance
* the row, so on the next launch it would otherwise show as installing forever. Reaching
* INSTALLING means the install was already committed, so mark it installed; if it actually
* failed, the periodic update check re-offers and re-enqueues it.
*/
private suspend fun finalizeStaleSelfUpdate(downloads: List<Download>) {
downloads.firstOrNull {
it.packageName == context.packageName && it.status == DownloadStatus.INSTALLING
}?.let {
Log.i(TAG, "Finalizing stale self-update install for ${it.packageName}")
downloadDao.updateStatus(it.packageName, DownloadStatus.INSTALLED)
}
}
/**
* Advances a download row through the installer phase so its history reflects whether the
* app actually installed, not just that the bytes finished downloading:

View File

@@ -24,6 +24,7 @@ import com.aurora.store.data.room.update.IgnoredUpdateDao
import com.aurora.store.data.room.update.Update
import com.aurora.store.data.room.update.UpdateDao
import com.aurora.store.data.work.UpdateWorker
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE
@@ -253,13 +254,23 @@ class UpdateHelper @Inject constructor(
private suspend fun deleteInvalidUpdates() {
updateDao.updates().firstOrNull()?.forEach { update ->
// Nightly builds share a static version code, so the generic "up to date"
// check would wrongly drop a freshly found self-update. The worker owns the
// self-update lifecycle (re-checked by build timestamp); cleanup happens via
// the install event instead.
if (update.isSelfUpdate(context) && BuildType.CURRENT == BuildType.NIGHTLY) {
if (update.isSelfUpdate(context)) {
// Self-updates need a flavor-aware staleness check. Release/preload bump the
// version code, so the normal up-to-date check works. Nightly reuses a static
// version code (isUpToDate is always true there), so fall back to the
// commit-tagged version name. Without this the row lingers until the next
// update check, since the install event isn't delivered when the app replaces
// itself.
val alreadyInstalled = if (BuildType.CURRENT == BuildType.NIGHTLY) {
PackageUtil.getInstalledVersionName(context, update.packageName) ==
update.versionName
} else {
update.isUpToDate(context)
}
if (alreadyInstalled) deleteUpdate(update.packageName)
return@forEach
}
if (!update.isInstalled(context) || update.isUpToDate(context)) {
deleteUpdate(update.packageName)
}

View File

@@ -31,6 +31,12 @@ data class SelfUpdate(
val versionCodeRaw: String = "0",
@SerialName("download_url")
val downloadUrl: String = "",
@SerialName("icon_url")
val iconUrl: String = "",
@SerialName("sha1")
val sha1: String = "",
@SerialName("sha256")
val sha256: String = "",
val changelog: String = "",
@SerialName("size")
val sizeRaw: String = "0",
@@ -61,12 +67,14 @@ data class SelfUpdate(
updatedOn = updatedOn,
displayName = context.getString(R.string.app_name),
developerName = "Rahul Kumar Patel",
iconArtwork = Artwork(url = ICON_URL),
iconArtwork = Artwork(url = iconUrl),
fileList = mutableListOf(
PlayFile(
name = "${context.packageName}.apk",
url = downloadUrl,
size = size
size = size,
sha1 = sha1,
sha256 = sha256
)
),
isFree = true,
@@ -78,11 +86,4 @@ data class SelfUpdate(
EncodedCertificateSet(certificateSet = it, sha256 = String())
}.toMutableList()
)
companion object {
// Kept in sync with the fastlane metadata on the project.
private const val ICON_URL =
"https://gitlab.com/AuroraOSS/AuroraStore/-/raw/master/" +
"fastlane/metadata/android/en-US/images/icon.png"
}
}

View File

@@ -8,14 +8,22 @@ import com.aurora.store.data.room.download.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.data.room.review.LocalReview
import com.aurora.store.data.room.review.ReviewDao
import com.aurora.store.data.room.update.IgnoredUpdate
import com.aurora.store.data.room.update.IgnoredUpdateDao
import com.aurora.store.data.room.update.Update
import com.aurora.store.data.room.update.UpdateDao
@Database(
entities = [Download::class, Favourite::class, Update::class, IgnoredUpdate::class],
version = 8,
entities = [
Download::class,
Favourite::class,
Update::class,
IgnoredUpdate::class,
LocalReview::class
],
version = 9,
exportSchema = true
)
@TypeConverters(DownloadConverter::class)
@@ -24,4 +32,5 @@ abstract class AuroraDatabase : RoomDatabase() {
abstract fun favouriteDao(): FavouriteDao
abstract fun updateDao(): UpdateDao
abstract fun ignoredUpdateDao(): IgnoredUpdateDao
abstract fun reviewDao(): ReviewDao
}

View File

@@ -38,6 +38,10 @@ object MigrationHelper {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom7To8(db)
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom8To9(db)
}
private const val TAG = "MigrationHelper"
private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
@@ -159,4 +163,34 @@ object MigrationHelper {
database.endTransaction()
}
}
/**
* Add review table for caching the user's own submitted reviews locally so they can be shown
* immediately while Google takes time to publish them. Scoped by account e-mail.
*/
private fun migrateFrom8To9(database: SupportSQLiteDatabase) {
database.beginTransaction()
try {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `review` (" +
"`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`))"
)
database.setTransactionSuccessful()
} catch (exception: Exception) {
Log.e(TAG, "Failed while migrating from database version 8 to 9", exception)
} finally {
database.endTransaction()
}
}
}

View File

@@ -9,9 +9,11 @@ import com.aurora.store.data.room.MigrationHelper.MIGRATION_4_5
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.download.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.data.room.review.ReviewDao
import com.aurora.store.data.room.update.IgnoredUpdateDao
import com.aurora.store.data.room.update.UpdateDao
import dagger.Module
@@ -40,7 +42,8 @@ object RoomModule {
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8
MIGRATION_7_8,
MIGRATION_8_9
)
.addTypeConverter(downloadConverter)
.build()
@@ -59,4 +62,7 @@ object RoomModule {
@Provides
fun providesIgnoredUpdateDao(auroraDatabase: AuroraDatabase): IgnoredUpdateDao =
auroraDatabase.ignoredUpdateDao()
@Provides
fun providesReviewDao(auroraDatabase: AuroraDatabase): ReviewDao = auroraDatabase.reviewDao()
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.review
import androidx.room.Entity
import com.aurora.gplayapi.data.models.Review
/**
* A user-authored review cached locally so it can be shown immediately after submission,
* even while Google takes time to publish it. It is kept in sync with the Play API: a review
* is [synced] once the API has confirmed it exists, after which a subsequent disappearance from
* the API is treated as a deletion on Play Store and the local copy is dropped.
*
* Scoped by [accountEmail] so a previous account's reviews never leak into a different session.
*/
@Entity(tableName = "review", primaryKeys = ["packageName", "accountEmail"])
data class LocalReview(
val packageName: String,
val accountEmail: String,
val title: String,
val comment: String,
val rating: Int,
val commentId: String = String(),
val userName: String = String(),
val userPhotoUrl: String = String(),
val appVersion: String = String(),
val timeStamp: Long = 0L,
val synced: Boolean = false
) {
companion object {
fun fromReview(
review: Review,
packageName: String,
accountEmail: String,
synced: Boolean
): LocalReview = LocalReview(
packageName = packageName,
accountEmail = accountEmail,
title = review.title,
comment = review.comment,
rating = review.rating,
commentId = review.commentId,
userName = review.userName,
userPhotoUrl = review.userPhotoUrl,
appVersion = review.appVersion,
timeStamp = review.timeStamp,
synced = synced
)
fun LocalReview.toReview(): Review = Review(
title = title,
comment = comment,
commentId = commentId,
userName = userName,
userPhotoUrl = userPhotoUrl,
appVersion = appVersion,
rating = rating,
timeStamp = timeStamp
)
}
}

View File

@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.review
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ReviewDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(review: LocalReview)
@Query("SELECT * FROM review WHERE packageName = :packageName AND accountEmail = :accountEmail")
fun review(packageName: String, accountEmail: String): Flow<LocalReview?>
@Query("SELECT * FROM review WHERE packageName = :packageName AND accountEmail = :accountEmail")
suspend fun get(packageName: String, accountEmail: String): LocalReview?
@Query("DELETE FROM review WHERE packageName = :packageName AND accountEmail = :accountEmail")
suspend fun delete(packageName: String, accountEmail: String)
@Query("DELETE FROM review")
suspend fun deleteAll()
}

View File

@@ -130,14 +130,21 @@ class DownloadWorker @AssistedInject constructor(
// Fetch required data for download
try {
download = downloadDao.getDownload(inputData.getString(DownloadHelper.PACKAGE_NAME)!!)
} catch (exception: Exception) {
return onFailure(exception)
}
// The icon only decorates the progress notification. Fetching it is best-effort: a
// failure (unreachable/pinned host, non-HTTP url, undecodable image) must never abort
// the download itself.
try {
val response = (httpClient as HttpClient).call(download.iconURL).body
val bitmap = BitmapFactory.decodeStream(
withContext(Dispatchers.IO) { response.byteStream() }
)
icon = bitmap.scale(96, 96)
icon = bitmap?.scale(96, 96)
} catch (exception: Exception) {
return onFailure(exception)
Log.w(TAG, "Failed to fetch icon for ${download.packageName}", exception)
}
// Set work/service to foreground on < Android 12.0

View File

@@ -24,15 +24,24 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.aurora.extensions.isOneUI
import com.aurora.store.BuildConfig
object Preferences {
const val PREFERENCE_DEFAULT = "PREFERENCE_DEFAULT"
/**
* Default for [PREFERENCE_DYNAMIC_COLORS]. Dynamic color is opt-out everywhere except One UI,
* where Samsung's palette extraction tends to look off, so it defaults off and users can opt in.
*/
val dynamicColorsDefault: Boolean
get() = !isOneUI
const val PREFERENCE_AUTH_DATA = "PREFERENCE_AUTH_DATA"
const val PREFERENCE_INSTALLER_ID = "PREFERENCE_INSTALLER_ID"
const val PREFERENCE_THEME_STYLE = "PREFERENCE_THEME_STYLE"
const val PREFERENCE_DYNAMIC_COLORS = "PREFERENCE_DYNAMIC_COLORS"
const val PREFERENCE_FOR_YOU = "PREFERENCE_FOR_YOU"
const val PREFERENCE_DEFAULT_SELECTED_TAB = "PREFERENCE_DEFAULT_SELECTED_TAB"
const val PREFERENCE_INTRO = "PREFERENCE_INTRO"
@@ -81,6 +90,8 @@ object Preferences {
const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED"
const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT"
const val PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK = "PREFERENCE_CONFIRM_EXTERNAL_DEEPLINK"
private var prefs: SharedPreferences? = null
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {

View File

@@ -14,20 +14,35 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aurora.extensions.TAG
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.store.AuroraApp
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.paging.GenericPagingSource.Companion.pager
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.data.room.favourite.ImportExport
import com.aurora.store.util.PackageUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@@ -35,12 +50,40 @@ import kotlinx.serialization.json.Json
class FavouriteViewModel @Inject constructor(
private val favouriteDao: FavouriteDao,
private val json: Json,
private val appDetailsHelper: AppDetailsHelper,
private val downloadHelper: DownloadHelper,
private val authProvider: AuthProvider,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _favourites = MutableStateFlow<PagingData<Favourite>>(PagingData.empty())
val favourites = _favourites.asStateFlow()
val downloadsList get() = downloadHelper.downloadsList
private val _isEnqueuing = MutableStateFlow(false)
val isEnqueuing = _isEnqueuing.asStateFlow()
// Emits the number of favourites actually enqueued for install (0 when nothing was
// applicable, -1 when fetching details failed) so the screen can show accurate feedback.
private val _enqueueResult = MutableSharedFlow<Int>()
val enqueueResult = _enqueueResult.asSharedFlow()
// Whether at least one favourite is still not installed, recomputed whenever the favourites
// list changes or any app is installed/removed. Drives visibility of the "Install all"
// action so it hides once every favourite is already installed. Defaults to true to avoid
// briefly hiding the action before the first check completes.
val hasInstallableFavourites = combine(
favouriteDao.favourites(),
AuroraApp.events.installerEvent
.filter { it is InstallerEvent.Installed || it is InstallerEvent.Uninstalled }
.map { }
.onStart { emit(Unit) }
) { favourites, _ ->
favourites.any { !PackageUtil.isInstalled(context, it.packageName) }
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
init {
getPagedFavourites()
}
@@ -86,6 +129,34 @@ class FavouriteViewModel @Inject constructor(
}
}
/**
* Fetches details for all favourites and enqueues the installable ones for download &
* install. Already installed (and up-to-date) apps are skipped, as are paid apps that an
* anonymous account can't acquire. The actual count enqueued is emitted via [enqueueResult].
*/
fun installAll() {
viewModelScope.launch(Dispatchers.IO) {
_isEnqueuing.value = true
try {
val packageNames = favouriteDao.favourites().first().map { it.packageName }
val installable = appDetailsHelper.getAppByPackageName(packageNames).filter { app ->
val needsInstall = !PackageUtil.isInstalled(context, app.packageName) ||
PackageUtil.isUpdatable(context, app.packageName, app.versionCode)
val acquirable = app.isFree || !authProvider.isAnonymous
needsInstall && acquirable
}
installable.forEach { downloadHelper.enqueueApp(it) }
_enqueueResult.emit(installable.size)
} catch (exception: Exception) {
Log.e(TAG, "Failed to enqueue favourites for install", exception)
_enqueueResult.emit(-1)
} finally {
_isEnqueuing.value = false
}
}
}
private fun getPagedFavourites() {
pager { favouriteDao.pagedFavourites() }.flow
.distinctUntilChanged()

View File

@@ -40,6 +40,9 @@ import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.data.room.review.LocalReview
import com.aurora.store.data.room.review.LocalReview.Companion.toReview
import com.aurora.store.data.room.review.ReviewDao
import com.aurora.store.util.CertUtil
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
@@ -48,14 +51,19 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -71,6 +79,7 @@ class AppDetailsViewModel @Inject constructor(
private val webDataSafetyHelper: WebDataSafetyHelper,
private val downloadHelper: DownloadHelper,
private val favouriteDao: FavouriteDao,
private val reviewDao: ReviewDao,
private val httpClient: IHttpClient,
private val json: Json
) : ViewModel() {
@@ -89,8 +98,19 @@ class AppDetailsViewModel @Inject constructor(
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
val featuredReviews = _featuredReviews.asStateFlow()
private val _userReview = MutableStateFlow<Review?>(null)
val userReview = _userReview.asStateFlow()
// The user's own review for the loaded app, backed by Room so it is shown instantly (even
// offline) and survives restarts while Google takes time to publish it. Scoped by account.
@OptIn(ExperimentalCoroutinesApi::class)
val userReview: StateFlow<Review?> = app
.filterNotNull()
.flatMapLatest { loadedApp ->
reviewDao.review(loadedApp.packageName, accountEmail).map { it?.toReview() }
}
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
// One-shot signal carrying whether the latest review submission succeeded
private val _reviewPosted = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
val reviewPosted = _reviewPosted.asSharedFlow()
private val _dataSafetyReport = MutableStateFlow<DataSafetyReport?>(null)
val dataSafetyReport = _dataSafetyReport.asStateFlow()
@@ -117,6 +137,10 @@ class AppDetailsViewModel @Inject constructor(
list.find { d -> d.packageName == a.packageName }
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
// E-mail of the signed-in account; blank for anonymous sessions. Used to scope cached reviews.
private val accountEmail: String
get() = authProvider.authData?.email.orEmpty()
private val isInstalled: Boolean
get() = PackageUtil.isInstalled(context, app.value!!.packageName)
@@ -200,6 +224,11 @@ class AppDetailsViewModel @Inject constructor(
fetchFavourite(packageName)
fetchFeaturedReviews(packageName)
// Reviews can only be submitted for installed apps with a personal account, so the
// user's existing review is only relevant (and fetchable) in that case.
if (!authProvider.isAnonymous && app.value!!.isInstalled) {
fetchUserAppReview(app.value!!)
}
fetchDataSafetyReport(packageName)
fetchSuggestions()
fetchExodusPrivacyReport(packageName)
@@ -218,11 +247,32 @@ class AppDetailsViewModel @Inject constructor(
}
}
/**
* Reconciles the locally cached review with the authoritative Play API:
* - If Play returns a review, it is mirrored locally and marked synced (this also picks up
* edits made directly on the Play Store).
* - If Play returns nothing but we had previously confirmed a review, it was deleted on the
* Play Store, so the local copy is dropped.
* - If Play returns nothing for a review that was never confirmed, it is still being published,
* so the local (pending) copy is kept so the user keeps seeing what they submitted.
*/
fun fetchUserAppReview(app: App) {
viewModelScope.launch(Dispatchers.IO) {
val email = accountEmail
if (email.isBlank()) return@launch
try {
val isTesting = app.testingProgram?.isSubscribed ?: false
_userReview.value = reviewsHelper.getUserReview(app.packageName, isTesting)
val apiReview = reviewsHelper.getUserReview(app.packageName, isTesting)
if (apiReview != null) {
reviewDao.upsert(
LocalReview.fromReview(apiReview, app.packageName, email, synced = true)
)
} else {
val cached = reviewDao.get(app.packageName, email)
if (cached != null && cached.synced) {
reviewDao.delete(app.packageName, email)
}
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch user review", exception)
}
@@ -231,16 +281,42 @@ class AppDetailsViewModel @Inject constructor(
fun postAppReview(packageName: String, review: Review, isBeta: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
val email = accountEmail
try {
_userReview.value = reviewsHelper.addOrEditReview(
val posted = reviewsHelper.addOrEditReview(
packageName,
review.title,
review.comment,
review.rating,
isBeta
)
if (posted != null) {
// Cache as pending (not yet synced): Play accepted it but getUserReview may
// not return it until publishing finishes. Marking it synced prematurely would
// let the next reconcile mistake the publishing delay for a deletion.
reviewDao.upsert(
LocalReview.fromReview(posted, packageName, email, synced = false)
)
}
_reviewPosted.tryEmit(posted != null)
} catch (exception: Exception) {
Log.e(TAG, "Failed to post review", exception)
_reviewPosted.tryEmit(false)
}
}
}
fun deleteAppReview(app: App) {
viewModelScope.launch(Dispatchers.IO) {
val email = accountEmail
try {
val isTesting = app.testingProgram?.isSubscribed ?: false
reviewsHelper.deleteReview(app.packageName, isTesting)
// Only drop the local copy once Play has accepted the deletion, so a failed
// network call leaves the review visible rather than silently disappearing.
if (email.isNotBlank()) reviewDao.delete(app.packageName, email)
} catch (exception: Exception) {
Log.e(TAG, "Failed to delete review", exception)
}
}
}

View File

@@ -4,5 +4,7 @@
<style name="AppTheme" parent="Theme.Material3.DynamicColors.Dark.NoActionBar">
<item name="chipStyle">@style/Chip.Filter</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Brand accent for devices without dynamic color (Android 11 and below) -->
<item name="colorPrimary">@color/colorAccent</item>
</style>
</resources>

View File

@@ -52,6 +52,7 @@
<string name="action_back">"Back"</string>
<string name="action_blacklist">"Blacklist"</string>
<string name="action_blacklist_add">"Add to blacklist"</string>
<string name="action_buy">"Buy @ %1$s"</string>
<string name="action_cancel">"Cancel"</string>
<string name="action_clear">"Clear"</string>
<string name="action_close">"Close"</string>
@@ -76,6 +77,7 @@
<string name="action_granted">"Granted"</string>
<string name="action_ignore">"Ignore"</string>
<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_installations">"Installations"</string>
<string name="action_installing">"Installing"</string>
@@ -139,6 +141,12 @@
<string name="details_privacy">"Privacy"</string>
<string name="details_ratings">"Rating and reviews"</string>
<string name="details_ratings_title_hint">"Review title"</string>
<string name="details_review_comment_hint">"Describe your experience (optional)"</string>
<string name="details_review_edit">"Edit your review"</string>
<string name="details_review_delete">"Delete"</string>
<string name="details_review_delete_title">"Delete review?"</string>
<string name="details_review_delete_message">"Your rating and review will be removed from Google Play."</string>
<string name="details_your_review">"Your review"</string>
<string name="details_think_this_app">"What do you think about this app?"</string>
<string name="download_cancel_all">"Cancel all"</string>
<string name="download_canceled">"Cancelled"</string>
@@ -189,6 +197,11 @@
<string name="app_lock_unlock">Unlock</string>
<string name="app_lock_prompt_title">Unlock Aurora Store</string>
<string name="app_lock_prompt_subtitle">Enter phone screen lock pattern, PIN, password or fingerprint</string>
<string name="confirm_deeplink_title">Confirm external links</string>
<string name="confirm_deeplink_summary">Ask before opening an app listing from a link, preventing ads from launching Aurora Store without your consent.</string>
<string name="confirm_deeplink_sheet_title">Continue to Aurora Store?</string>
<string name="confirm_deeplink_sheet_message">An external app wants to open Aurora Store</string>
<string name="confirm_deeplink_sheet_source">Opened from %1$s</string>
<string name="installer_service_available">"Aurora Services is available and ready to install."</string>
<string name="installer_service_unavailable">Install Aurora Services 1.0.9 or above, or change the installer.</string>
<string name="installer_service_misconfigured">Set up Aurora Services and grant all permissions first.</string>
@@ -295,6 +308,9 @@
<string name="title_installation">"Installation"</string>
<string name="title_installed">"Installed"</string>
<string name="title_installer">"App installer"</string>
<string name="title_install_favourites">"Install favourites?"</string>
<string name="install_favourites_summary">"This will download and install %1$d apps."</string>
<string name="install_favourites_warning">"You may see a separate installation prompt for each app."</string>
<string name="title_language">"Language"</string>
<string name="title_library">"Library"</string>
<string name="title_no_network">"No network"</string>
@@ -398,6 +414,8 @@
<string name="pref_theme_style_system">Follow system</string>
<string name="pref_theme_style_light">Light</string>
<string name="pref_theme_style_dark">Dark</string>
<string name="pref_ui_dynamic_color">Dynamic colors</string>
<string name="pref_ui_dynamic_color_desc">Use colors from your wallpaper. Turn off for Aurora\'s brand palette.</string>
<string name="pref_ui_layout">Layout</string>
<string name="pref_ui_layout_tab">Select default tab</string>
<string name="app_links_title">App links</string>
@@ -507,6 +525,9 @@
<string name="toast_fav_export_failed">Failed to export favourites!</string>
<string name="toast_fav_import_success">Favourites imported!</string>
<string name="toast_fav_export_success">Favourites exported!</string>
<string name="toast_fav_install_enqueued">"Installing %1$d apps"</string>
<string name="toast_fav_install_none">"Nothing to install"</string>
<string name="toast_fav_install_failed">"Failed to fetch app details"</string>
<!-- BlacklistFragment-->
<string name="toast_black_import_failed">Failed to import blacklist!</string>
@@ -585,6 +606,10 @@
<item quantity="one">Permission required</item>
<item quantity="other">Permissions required</item>
</plurals>
<plurals name="favourites_count">
<item quantity="one">%1$d favourite</item>
<item quantity="other">%1$d favourites</item>
</plurals>
<plurals name="notification_installed_summary">
<item quantity="one">%d app installed</item>
<item quantity="other">%d apps installed</item>

View File

@@ -23,6 +23,8 @@
<item name="chipStyle">@style/Chip.Filter</item>
<item name="bottomSheetStyle">@style/AppTheme.BottomSheetStyle</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Brand accent for devices without dynamic color (Android 11 and below) -->
<item name="colorPrimary">@color/colorAccent</item>
</style>
<style name="AppTheme.Translucent" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
@@ -35,6 +37,14 @@
<item name="android:windowTranslucentNavigation">true</item>
</style>
<!-- Translucent variant with edge-to-edge transparent system bars (no status bar scrim) -->
<style name="AppTheme.Translucent.Transparent">
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="AppTheme.BottomSheetStyle" parent="Widget.Material3.BottomSheet" />
<style name="Chip.Filter" parent="@style/Widget.Material3.Chip.Filter" />

View File

@@ -18,7 +18,7 @@ composeBom = "2026.05.01"
composeMaterial = "1.5.0-alpha20"
core = "1.18.0"
espresso = "3.7.0"
gplayapi = "3.6.2"
gplayapi = "3.6.3"
hiddenapibypass = "6.1"
hilt = "2.59.2"
junit = "4.13.2"