From 753c7fa06a407aef285ddae89381bcef306c9405 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Tue, 2 Jun 2026 23:19:24 +0530 Subject: [PATCH] Add user reviews to AppDetailsScreen Resolves #1494 Allow signed-in users to rate, review, edit, and delete an installed app's review directly from its details screen. The form is hidden from anonymous accounts and only shown for installed apps. Submitted reviews are cached locally in a new Room "review" table (db v9) so they appear immediately while Google publishes them, and are reconciled with the Play API: edits made on the Play Store are mirrored, and a review deleted there (or via the new delete action) is removed locally. Deletion relies on gplayapi 3.6.3. --- .../9.json | 391 ++++++++++++++++++ .../compose/ui/details/AppDetailsScreen.kt | 33 ++ .../ui/details/composable/UserReview.kt | 287 +++++++++++++ .../aurora/store/data/room/AuroraDatabase.kt | 13 +- .../aurora/store/data/room/MigrationHelper.kt | 34 ++ .../com/aurora/store/data/room/RoomModule.kt | 8 +- .../store/data/room/review/LocalReview.kt | 65 +++ .../store/data/room/review/ReviewDao.kt | 31 ++ .../viewmodel/details/AppDetailsViewModel.kt | 84 +++- app/src/main/res/values/strings.xml | 6 + gradle/libs.versions.toml | 2 +- 11 files changed, 946 insertions(+), 8 deletions(-) create mode 100644 app/schemas/com.aurora.store.data.room.AuroraDatabase/9.json create mode 100644 app/src/main/java/com/aurora/store/compose/ui/details/composable/UserReview.kt create mode 100644 app/src/main/java/com/aurora/store/data/room/review/LocalReview.kt create mode 100644 app/src/main/java/com/aurora/store/data/room/review/ReviewDao.kt 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/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index fae82eb6b..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 @@ -90,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 @@ -121,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() @@ -130,6 +132,14 @@ 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( @@ -164,6 +174,7 @@ fun AppDetailsScreen( ScreenContentApp( app = loadedApp, featuredReviews = featuredReviews, + userReview = userReview, suggestionsBundle = suggestionsBundle, isFavorite = favorite, isAnonymous = viewModel.authProvider.isAnonymous, @@ -189,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) @@ -259,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, @@ -274,6 +294,8 @@ 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 = {} @@ -503,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/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/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/strings.xml b/app/src/main/res/values/strings.xml index 571357842..afc965525 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -141,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" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e191bf108..ae7bb403d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ composeBom = "2026.05.01" composeMaterial = "1.5.0-alpha20" core = "1.18.0" espresso = "3.7.0" -gplayapi = "3.6.2" +gplayapi = "3.6.3" hiddenapibypass = "6.1" hilt = "2.59.2" junit = "4.13.2"