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"