diff --git a/app/schemas/com.aurora.store.data.room.AuroraDatabase/9.json b/app/schemas/com.aurora.store.data.room.AuroraDatabase/9.json
new file mode 100644
index 000000000..71413f8f3
--- /dev/null
+++ b/app/schemas/com.aurora.store.data.room.AuroraDatabase/9.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0bf3e078d..5010bff81 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -103,6 +103,16 @@
+
+
+
+
@@ -121,7 +131,6 @@
-
diff --git a/app/src/main/java/com/aurora/extensions/Platform.kt b/app/src/main/java/com/aurora/extensions/Platform.kt
index abedc307b..547c530d1 100644
--- a/app/src/main/java/com/aurora/extensions/Platform.kt
+++ b/app/src/main/java/com/aurora/extensions/Platform.kt
@@ -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() {
diff --git a/app/src/main/java/com/aurora/store/AuroraApp.kt b/app/src/main/java/com/aurora/store/AuroraApp.kt
index 650dd515d..5c5aff140 100644
--- a/app/src/main/java/com/aurora/store/AuroraApp.kt
+++ b/app/src/main/java/com/aurora/store/AuroraApp.kt
@@ -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")
diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt
index b98e6b6af..bca4849a8 100644
--- a/app/src/main/java/com/aurora/store/ComposeActivity.kt
+++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt
@@ -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) }
diff --git a/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt b/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt
new file mode 100644
index 000000000..d7543fce5
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/DeepLinkConfirmActivity.kt
@@ -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 -> ""
+ }
+}
diff --git a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt
index d7475849a..0e5399077 100644
--- a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt
+++ b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt
@@ -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
+ )
+ )
+}
diff --git a/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt b/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt
index 680015acb..495a8d2bf 100644
--- a/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt
+++ b/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt
@@ -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(
diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt
index 4316054fe..685e4605d 100644
--- a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt
+++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt
@@ -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))
}
diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt
index 857461033..46ede4929 100644
--- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt
+++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt
@@ -216,6 +216,13 @@ fun NavDisplay(startDestination: NavKey) {
)
}
+ entry { screen ->
+ DevProfileScreen(
+ publisherId = screen.publisherId,
+ onNavigateTo = ::navigate
+ )
+ }
+
entry { screen ->
PermissionRationaleScreen(
requiredPermissions = screen.requiredPermissions
diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt
index 8cebacce6..3e8dda653 100644
--- a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt
+++ b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt
@@ -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()
diff --git a/app/src/main/java/com/aurora/store/compose/theme/Color.kt b/app/src/main/java/com/aurora/store/compose/theme/Color.kt
index 8818eebb2..3995533e9 100644
--- a/app/src/main/java/com/aurora/store/compose/theme/Color.kt
+++ b/app/src/main/java/com/aurora/store/compose/theme/Color.kt
@@ -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)
+)
diff --git a/app/src/main/java/com/aurora/store/compose/theme/Theme.kt b/app/src/main/java/com/aurora/store/compose/theme/Theme.kt
index 1b5d8cbb7..2740ee724 100644
--- a/app/src/main/java/com/aurora/store/compose/theme/Theme.kt
+++ b/app/src/main/java/com/aurora/store/compose/theme/Theme.kt
@@ -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
diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/InstallFavouritesDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/InstallFavouritesDialog.kt
new file mode 100644
index 000000000..2873a29da
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/compose/ui/commons/InstallFavouritesDialog.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt
index aa1d148cf..658fd1395 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt
@@ -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 = 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(
diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/UserReview.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/UserReview.kt
new file mode 100644
index 000000000..b46bb0fd0
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/UserReview.kt
@@ -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
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt b/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt
index 07f909a3d..afd70198a 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/details/menu/AppDetailsMenu.kt
@@ -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)) },
diff --git a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt
index ef68ca797..ec976565e 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt
@@ -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
+ )
+ }
+ }
}
/**
diff --git a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt
index e59ab371c..e8494929e 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt
@@ -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 = emptyPagingItems(),
+ downloads: Map = 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)
diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/UIPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/UIPreferenceScreen.kt
index 872400775..f77dc6c0e 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/preferences/UIPreferenceScreen.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/UIPreferenceScreen.kt
@@ -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)) })
diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt
index 134fff43a..77fa2ac5c 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt
@@ -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) }
+ )
+ }
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt
new file mode 100644
index 000000000..f8d91b2d0
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/DeepLinkConfirmSheet.kt
@@ -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())
+ }
+ }
+}
diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt
index 34a4dd87f..c93ef203c 100644
--- a/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt
+++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt
@@ -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
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt
index af3aaffeb..8c54d7560 100644
--- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt
+++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt
@@ -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) {
+ 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:
diff --git a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt
index a4f92ed5b..8e6ed1ca9 100644
--- a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt
+++ b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt
@@ -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)
}
diff --git a/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt b/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt
index 4a589214f..19760fb4b 100644
--- a/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt
+++ b/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt
@@ -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"
- }
}
diff --git a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt
index 5bd70fda5..69b35446c 100644
--- a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt
+++ b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt
@@ -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
}
diff --git a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt
index 67524dfd5..65d437cae 100644
--- a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt
+++ b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt
@@ -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()
+ }
+ }
}
diff --git a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt
index a73329630..88c2b1722 100644
--- a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt
+++ b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt
@@ -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()
}
diff --git a/app/src/main/java/com/aurora/store/data/room/review/LocalReview.kt b/app/src/main/java/com/aurora/store/data/room/review/LocalReview.kt
new file mode 100644
index 000000000..56e1a9305
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/room/review/LocalReview.kt
@@ -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
+ )
+ }
+}
diff --git a/app/src/main/java/com/aurora/store/data/room/review/ReviewDao.kt b/app/src/main/java/com/aurora/store/data/room/review/ReviewDao.kt
new file mode 100644
index 000000000..666c109eb
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/room/review/ReviewDao.kt
@@ -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
+
+ @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()
+}
diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
index b1a3934f8..d9b07b676 100644
--- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
+++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
@@ -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
diff --git a/app/src/main/java/com/aurora/store/util/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt
index e01de6560..b3396683f 100644
--- a/app/src/main/java/com/aurora/store/util/Preferences.kt
+++ b/app/src/main/java/com/aurora/store/util/Preferences.kt
@@ -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) {
diff --git a/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt
index 0cd75c046..f9a966132 100644
--- a/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt
+++ b/app/src/main/java/com/aurora/store/viewmodel/all/FavouriteViewModel.kt
@@ -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.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()
+ 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()
diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt
index 1ac90d4a3..2207a7178 100644
--- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt
+++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt
@@ -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>(emptyList())
val featuredReviews = _featuredReviews.asStateFlow()
- private val _userReview = MutableStateFlow(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 = 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(extraBufferCapacity = 1)
+ val reviewPosted = _reviewPosted.asSharedFlow()
private val _dataSafetyReport = MutableStateFlow(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)
}
}
}
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 7bbd3f99c..6e503530a 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -4,5 +4,7 @@
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 87f9d60cb..bf7280757 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -52,6 +52,7 @@
"Back"
"Blacklist"
"Add to blacklist"
+ "Buy @ %1$s"
"Cancel"
"Clear"
"Close"
@@ -76,6 +77,7 @@
"Granted"
"Ignore"
"Install"
+ "Install all"
"Install microG Bundle"
"Installations"
"Installing"
@@ -139,6 +141,12 @@
"Privacy"
"Rating and reviews"
"Review title"
+ "Describe your experience (optional)"
+ "Edit your review"
+ "Delete"
+ "Delete review?"
+ "Your rating and review will be removed from Google Play."
+ "Your review"
"What do you think about this app?"
"Cancel all"
"Cancelled"
@@ -189,6 +197,11 @@
Unlock
Unlock Aurora Store
Enter phone screen lock pattern, PIN, password or fingerprint
+ Confirm external links
+ Ask before opening an app listing from a link, preventing ads from launching Aurora Store without your consent.
+ Continue to Aurora Store?
+ An external app wants to open Aurora Store
+ Opened from %1$s
"Aurora Services is available and ready to install."
Install Aurora Services 1.0.9 or above, or change the installer.
Set up Aurora Services and grant all permissions first.
@@ -295,6 +308,9 @@
"Installation"
"Installed"
"App installer"
+ "Install favourites?"
+ "This will download and install %1$d apps."
+ "You may see a separate installation prompt for each app."
"Language"
"Library"
"No network"
@@ -398,6 +414,8 @@
Follow system
Light
Dark
+ Dynamic colors
+ Use colors from your wallpaper. Turn off for Aurora\'s brand palette.
Layout
Select default tab
App links
@@ -507,6 +525,9 @@
Failed to export favourites!
Favourites imported!
Favourites exported!
+ "Installing %1$d apps"
+ "Nothing to install"
+ "Failed to fetch app details"
Failed to import blacklist!
@@ -585,6 +606,10 @@
- Permission required
- Permissions required
+
+ - %1$d favourite
+ - %1$d favourites
+
- %d app installed
- %d apps installed
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index cc9850a12..0171d8f97 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -23,6 +23,8 @@
- @style/Chip.Filter
- @style/AppTheme.BottomSheetStyle
- @android:color/transparent
+
+ - @color/colorAccent
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e191bf108..ae7bb403d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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"