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:
Rahul Patel
2026-06-02 23:19:24 +05:30
parent 4a0c1101f4
commit 753c7fa06a
11 changed files with 946 additions and 8 deletions

View File

@@ -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(

View File

@@ -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
)
)
}
}

View File

@@ -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
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

@@ -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
)
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

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