feat(overview): Ask happy users for a Play review

Adds a review prompt card to the overview. On Google Play it uses the
in-app review flow, gated on the user having been Pro for a while, not
having dismissed it recently and not having reviewed yet. FOSS gets a
no-op implementation.

The card is the lowest priority item on the overview and stays hidden
while a permission, troubleshooter, background-monitoring-off or
no-profiles card is on screen.
This commit is contained in:
darken
2026-08-05 16:27:13 +02:00
committed by Matthias Urhahn
parent e8b7f73c6b
commit 26707dc0e5
11 changed files with 498 additions and 3 deletions
+3
View File
@@ -198,6 +198,9 @@ dependencies {
"gplayImplementation"("com.android.billingclient:billing:8.3.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:8.3.0")
"gplayImplementation"("com.google.android.play:review:2.0.2")
"gplayImplementation"("com.google.android.play:review-ktx:2.0.2")
// Robolectric-backed Compose UI tests (run as regular unit tests via the vintage engine).
testImplementation(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
testImplementation("androidx.compose.ui:ui-test-junit4")
@@ -0,0 +1,31 @@
package eu.darken.capod.common.review
import android.app.Activity
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FossReviewTool @Inject constructor() : ReviewTool {
override val state: Flow<ReviewTool.State> = flowOf(ReviewTool.State())
override suspend fun dismiss() {
log(TAG, INFO) { "dismiss()" }
// NOOP
}
override suspend fun reviewNow(activity: Activity) {
log(TAG, INFO) { "reviewNow($activity)" }
// NOOP
}
companion object {
private val TAG = logTag("Review", "Tool", "FOSS")
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.common.review
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ReviewModule {
@Binds
@Singleton
abstract fun reviewTool(tool: FossReviewTool): ReviewTool
}
@@ -0,0 +1,194 @@
package eu.darken.capod.common.review
import android.app.Activity
import com.google.android.play.core.ktx.launchReview
import com.google.android.play.core.ktx.requestReview
import com.google.android.play.core.review.ReviewInfo
import com.google.android.play.core.review.ReviewManager
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.system.measureTimeMillis
@Singleton
class GplayReviewTool @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val settings: ReviewSettings,
private val manager: ReviewManager,
upgradeRepo: UpgradeRepo,
) : ReviewTool {
// Test seam: the probe backoff runs on AppScope (a real dispatcher), so a virtual-time test
// cannot advance the production bound. Same pattern as UpgradeRepoGplay.launchTimeoutMs.
internal var probeRetryDelay: Duration = PROBE_RETRY_DELAY
// Local bookkeeping only: decided without talking to Play, so an ineligible user never
// triggers a Play round-trip.
private val isLocallyEligible: Flow<Boolean> = combine(
settings.lastDismissed.flow,
settings.reviewedAt.flow,
upgradeRepo.upgradeInfo,
) { lastDismissed, reviewedAt, upgradeInfo ->
val now = Instant.now()
// Free trial is 14 days, only ask for review after the user has paid something
val hasPaidForPro = Duration.between(upgradeInfo.upgradedAt ?: now, now) > Duration.ofDays(21)
val isSnoozed = Duration.between(lastDismissed ?: Instant.EPOCH, now) < Duration.ofDays(14)
val hasReviewed = reviewedAt != null
log(TAG) { "Eligibility: hasPaidForPro=$hasPaidForPro (${upgradeInfo.upgradedAt})" }
log(TAG) { "Eligibility: isSnoozed=$isSnoozed ($lastDismissed), hasReviewed=$hasReviewed ($reviewedAt)" }
hasPaidForPro && !isSnoozed && !hasReviewed
}
.distinctUntilChanged()
// Only probed once the user is eligible: Play counts requests against the app's quota, and an
// `isNoOp` answer is Play's deliberate verdict, i.e. an answer and not a failure to retry.
private val isReviewAvailable: Flow<Boolean> = isLocallyEligible
.flatMapLatest { eligible ->
if (!eligible) return@flatMapLatest flowOf(false)
flow {
for (attempt in 1..PROBE_ATTEMPTS) {
val info = try {
manager.requestReview()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Probe $attempt/$PROBE_ATTEMPTS failed: ${e.asLog()}" }
if (attempt < PROBE_ATTEMPTS) delay(probeRetryDelay.toMillis())
continue
}
log(TAG) { "Probe $attempt/$PROBE_ATTEMPTS returned ${info.desc()}" }
emit(info.canShow)
return@flow
}
// Re-probed when eligibility changes or on the next process start
log(TAG, WARN) { "Probe gave up after $PROBE_ATTEMPTS attempts" }
emit(false)
}
}
.replayingShare(appScope)
override val state: Flow<ReviewTool.State> = combine(
isLocallyEligible,
isReviewAvailable,
settings.reviewedAt.flow,
) { eligible, available, reviewedAt ->
log(TAG) { "State: eligible=$eligible, available=$available, reviewedAt=$reviewedAt" }
ReviewTool.State(
shouldAskForReview = eligible && available,
hasReviewed = reviewedAt != null,
)
}
.throttleLatest(500)
.onStart { emit(ReviewTool.State()) }
.replayingShare(appScope)
// Single-flight: a second tap must not queue up behind the first, or Play's flow would be
// launched again the moment the user returns from it.
private val reviewLock = Mutex()
override suspend fun dismiss() {
log(TAG, INFO) { "dismiss()" }
settings.lastDismissed.value(Instant.now())
}
override suspend fun reviewNow(activity: Activity) {
log(TAG, INFO) { "reviewNow($activity)" }
if (!reviewLock.tryLock()) {
log(TAG, WARN) { "reviewNow(...) is already in progress, skipping" }
return
}
try {
// ReviewInfo is short lived, Google wants it requested shortly before the launch,
// a token cached at process start is likely stale by the time the user taps.
val reviewInfo = try {
manager.requestReview()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// A transient failure is not user intent: don't snooze the card, the next tap retries
log(TAG, ERROR) { "Failed to get a fresh ReviewInfo: ${e.asLog()}" }
return
}
log(TAG) { "reviewNow(...): Fresh ${reviewInfo.desc()}" }
if (!reviewInfo.canShow) {
// Play's quota verdict, asking again right away would be pointless
log(TAG, WARN) { "Play says we can't show the prompt, snoozing" }
settings.lastDismissed.value(Instant.now())
return
}
if (activity.isFinishing || activity.isDestroyed) {
log(TAG, WARN) { "Activity is gone, aborting: $activity" }
return
}
val reviewTime = measureTimeMillis {
try {
manager.launchReview(activity, reviewInfo)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to launch review flow: ${e.asLog()}" }
return
}
}
log(TAG) { "Review completed after ${reviewTime}ms" }
if (Duration.ofMillis(reviewTime) >= Duration.ofSeconds(2)) {
log(TAG, INFO) { "Marking review as completed" }
settings.reviewedAt.value(Instant.now())
} else {
log(TAG, INFO) { "Review was too quick, counting as dismiss" }
settings.lastDismissed.value(Instant.now())
}
} finally {
reviewLock.unlock()
}
}
private val ReviewInfo.canShow: Boolean
get() = when {
toString().contains("isNoOp=true") -> false
else -> true
}
private fun ReviewInfo.desc(): String {
return "ReviewInfo(canShow=$canShow, ${toString()})"
}
companion object {
private val TAG = logTag("Review", "Tool", "Gplay")
private const val PROBE_ATTEMPTS = 3
private val PROBE_RETRY_DELAY = Duration.ofSeconds(30)
}
}
@@ -0,0 +1,27 @@
package eu.darken.capod.common.review
import android.content.Context
import com.google.android.play.core.review.ReviewManager
import com.google.android.play.core.review.ReviewManagerFactory
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ReviewModule {
@Binds
@Singleton
abstract fun reviewTool(tool: GplayReviewTool): ReviewTool
companion object {
@Provides
@Singleton
fun reviewManager(@ApplicationContext context: Context): ReviewManager = ReviewManagerFactory.create(context)
}
}
@@ -0,0 +1,47 @@
package eu.darken.capod.common.review
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.common.serialization.SerializationCapod
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.json.Json
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ReviewSettings @Inject constructor(
@ApplicationContext private val context: Context,
@SerializationCapod json: Json,
) {
private val Context.dataStore by preferencesDataStore(name = "settings_review_gplay")
val dataStore: DataStore<Preferences>
get() = context.dataStore
// Explicit serializer: `java.time.Instant` has no `@Serializable` companion, so the reified
// `serializer<T>()` the inline overload uses cannot resolve one for it.
val lastDismissed = dataStore.createValue(
key = "review.dismissedAt",
defaultValue = null as Instant?,
json = json,
serializer = InstantEpochMillisSerializer.nullable,
)
val reviewedAt = dataStore.createValue(
key = "review.reviewedAt",
defaultValue = null as Instant?,
json = json,
serializer = InstantEpochMillisSerializer.nullable,
)
companion object {
internal val TAG = logTag("Review", "Settings", "Gplay")
}
}
@@ -0,0 +1,18 @@
package eu.darken.capod.common.review
import android.app.Activity
import kotlinx.coroutines.flow.Flow
interface ReviewTool {
val state: Flow<State>
data class State(
val shouldAskForReview: Boolean = false,
val hasReviewed: Boolean = false,
)
suspend fun dismiss()
suspend fun reviewNow(activity: Activity)
}
@@ -1,5 +1,6 @@
package eu.darken.capod.main.ui.overview
import android.app.Activity
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -62,6 +63,7 @@ import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
import eu.darken.capod.main.ui.overview.cards.ReviewCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
@@ -163,6 +165,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return
// Play's in-app review flow needs a hosting Activity. Without one the card still renders, but
// with its review action disabled instead of silently doing nothing.
val activity = context as? Activity
OverviewScreen(
state = currentState,
snackbarHostState = snackbarHostState,
@@ -191,6 +197,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onSetupPairedDevice = {
currentState.soleProfileId?.let { vm.goToEditProfile(it) } ?: vm.goToDeviceManager()
},
onReviewNow = activity?.let { host -> { vm.reviewNow(host) } },
onReviewDismiss = { vm.reviewDismiss() },
)
}
@@ -210,6 +218,8 @@ fun OverviewScreen(
onEditProfile: (PodDevice) -> Unit = {},
onToggleDeviceExpansion: (PodDevice) -> Unit = {},
onSetupPairedDevice: () -> Unit = {},
onReviewNow: (() -> Unit)? = null,
onReviewDismiss: () -> Unit = {},
) {
Scaffold(
topBar = {
@@ -312,6 +322,16 @@ fun OverviewScreen(
}
}
// 3b. Review prompt, only shown while no higher priority card is on screen
if (state.showReviewCard) {
item(key = "review") {
ReviewCard(
onReview = onReviewNow,
onDismiss = onReviewDismiss,
)
}
}
// 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) {
if (state.monitoringStatus == OverviewViewModel.MonitoringStatus.BACKGROUND_OFF) {
@@ -1,5 +1,6 @@
package eu.darken.capod.main.ui.overview
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothManager2
@@ -15,6 +16,7 @@ import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.review.ReviewTool
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
@@ -65,6 +67,7 @@ class OverviewViewModel @Inject constructor(
private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource,
private val reviewTool: ReviewTool,
) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>()
@@ -92,6 +95,7 @@ class OverviewViewModel @Inject constructor(
val showTroubleshootSuggestion: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
val effectiveMode: MonitorMode,
val reviewState: ReviewTool.State,
)
/**
@@ -123,19 +127,23 @@ class OverviewViewModel @Inject constructor(
.onStart { emit(false) }
.distinctUntilChanged()
private val overviewUiSettings = combineFlows(
private val overviewUiSettings = combine(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
batteryEstimator.estimates,
monitorModeResolver.effectiveMode,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates, effectiveMode ->
// The review prompt is a nice-to-have: a failing review backend must never take the whole
// overview down with it, so it falls back to "don't ask".
reviewTool.state.catch { emit(ReviewTool.State()) },
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates, effectiveMode, reviewState ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
batteryEstimates = batteryEstimates,
effectiveMode = effectiveMode,
reviewState = reviewState,
)
}
@@ -212,7 +220,7 @@ class OverviewViewModel @Inject constructor(
val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
State(
val state = State(
now = timeSource.now(),
permissions = permissions,
devices = devices,
@@ -228,6 +236,15 @@ class OverviewViewModel @Inject constructor(
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
batteryEstimates = uiSettings.batteryEstimates,
)
// Asking for a review is the lowest priority thing the overview can say: it only appears on
// an otherwise quiet screen, never stacked on top of something the user has to act on.
val showReviewCard = uiSettings.reviewState.shouldAskForReview && !state.hasHigherPriorityCard
if (uiSettings.reviewState.shouldAskForReview && !showReviewCard) {
log(TAG) { "Could show review card but higher priority cards are currently being shown" }
}
state.copy(showReviewCard = showReviewCard)
}.asLiveState()
enum class BluetoothIconState { HIDDEN, DISABLED, NEARBY, CONNECTED }
@@ -249,6 +266,7 @@ class OverviewViewModel @Inject constructor(
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
val showReviewCard: Boolean = false,
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
@@ -312,6 +330,18 @@ class OverviewViewModel @Inject constructor(
else -> MonitoringStatus.HIDDEN
}
/**
* Whether the overview is currently showing a card that outranks the review prompt: a
* missing permission, the troubleshooter hint, the background-monitoring-off notice or the
* no-profiles setup card. All of those ask the user to do something, so the review prompt
* stays hidden while any of them is on screen.
*/
val hasHigherPriorityCard: Boolean
get() = permissions.isNotEmpty() ||
showTroubleshootSuggestion ||
monitoringStatus == MonitoringStatus.BACKGROUND_OFF ||
(profiles.isEmpty() && !isScanBlocked && isBluetoothEnabled)
val soleProfileId: ProfileId? get() = profiles.singleOrNull()?.id
fun isPinned(device: PodDevice, index: Int): Boolean =
@@ -392,6 +422,16 @@ class OverviewViewModel @Inject constructor(
}
}
fun reviewNow(activity: Activity) {
log(TAG, INFO) { "reviewNow($activity)" }
launch { reviewTool.reviewNow(activity) }
}
fun reviewDismiss() {
log(TAG, INFO) { "reviewDismiss()" }
launch { reviewTool.dismiss() }
}
fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(permission)
@@ -0,0 +1,96 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
/**
* Asks the user to leave a review. [onReview] is null when no hosting Activity is available, Play's
* in-app review flow can't be launched without one, so the action is shown disabled instead.
*/
@Composable
fun ReviewCard(
onReview: (() -> Unit)?,
onDismiss: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Stars,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.review_app_review_action),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.review_app_body),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.review_app_dismiss_action))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { onReview?.invoke() },
enabled = onReview != null,
) {
Text(text = stringResource(R.string.review_app_review_action))
}
}
}
}
}
@Preview2
@Composable
private fun ReviewCardPreview() = PreviewWrapper {
ReviewCard(
onReview = {},
onDismiss = {},
)
}
+3
View File
@@ -534,6 +534,9 @@
<string name="overview_card_missing_paired_device_consequence">Without one, device settings and auto-connect aren\'t available.</string>
<string name="overview_reactions_hint_title">Reactions are per device</string>
<string name="overview_reactions_hint_body">Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected.</string>
<string name="review_app_review_action">Review</string>
<string name="review_app_dismiss_action">Maybe later</string>
<string name="review_app_body">Would you like to leave CAPod a review? ❤️</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">General</string>