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 + + +