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