Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions InAppReview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ It supports two distribution flavors:
- **`standard`** — Google Play Review API (`ReviewManagerFactory`)
- **`fdroid`** — No-op manager (no Play dependency). If you still want to prompt users, you can show `ReviewAlertDialog` yourself.

The review prompt is only shown after a configurable number of app launches ("countdown"), and is not shown again after the user has tapped "Review" (i.e., once the review flow has been triggered).
The review prompt is only shown after a configurable number of app launches ("countdown"), and is never shown again once it has been displayed to the user.

## 1. Integration (Gradle)

Expand Down Expand Up @@ -45,16 +45,15 @@ class MainActivity : ComponentActivity() {
// Manual → you call decrementAppReviewCountdown() yourself
countdownBehavior = BaseInAppReviewManager.Behavior.LifecycleBased,
appReviewThreshold = 50, // Optional: initial countdown value (default 50)
maxAppReviewThreshold = 500, // Optional: value reset to after user interacts (default threshold × 10)
onUserWantToReview = { }, // Optional: called when user taps "Review"
onUserWantToGiveFeedback = { }, // Optional: called when user taps "Give feedback"
)
}
}
```

> **Defaults:** `appReviewThreshold = 50`, `maxAppReviewThreshold = appReviewThreshold * 10`.
> After any user interaction (review, feedback, or dismiss), the countdown is reset to `maxAppReviewThreshold` so the prompt is not shown again too soon.
> **Default:** `appReviewThreshold = 50`.
> Once the prompt has been displayed, call `onReviewDialogShown()` so it is never shown again.

---

Expand All @@ -63,9 +62,11 @@ class MainActivity : ComponentActivity() {
### 3.1 Observe `shouldDisplayReviewDialog`

`shouldDisplayReviewDialog` is a `Flow<Boolean>` that emits `true` when:
- the user has **not** already submitted a review, **and**
- the user has **not** already been asked for a review, **and**
- the countdown has reached **0 or below**.

Once the prompt is shown, call `onReviewDialogShown()` to mark it as asked; the flow will never emit `true` again.

Collect it in your Compose UI and show a dialog or bottom sheet accordingly:

```kotlin
Expand All @@ -74,6 +75,7 @@ fun ReviewPromptObserver(reviewManager: InAppReviewManager) {
val shouldDisplay by reviewManager.shouldDisplayReviewDialog.collectAsState(initial = false)

if (shouldDisplay) {
reviewManager.onReviewDialogShown()
ReviewDialog(
onReview = { reviewManager.onUserWantsToReview() },
onFeedback = { reviewManager.onUserWantsToGiveFeedback("https://yourapp.example.com/feedback") },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package com.infomaniak.core.inappreview

import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
Expand All @@ -31,17 +32,33 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map

private val migrateAlreadyAskReviewKey = object : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean =
currentData[AppReviewSettingsRepository.ALREADY_ASK_REVIEW_KEY] == null &&
currentData[AppReviewSettingsRepository.LEGACY_ALREADY_ASK_REVIEW_KEY] != null

override suspend fun migrate(currentData: Preferences): Preferences {
val legacyValue = currentData[AppReviewSettingsRepository.LEGACY_ALREADY_ASK_REVIEW_KEY] ?: return currentData
return currentData.toMutablePreferences().apply {
this[AppReviewSettingsRepository.ALREADY_ASK_REVIEW_KEY] = legacyValue
remove(AppReviewSettingsRepository.LEGACY_ALREADY_ASK_REVIEW_KEY)
}.toPreferences()
}

override suspend fun cleanUp() = Unit
}

private val Context.dataStore by preferencesDataStore(
name = AppReviewSettingsRepository.DATA_STORE_NAME,
// In case we have a CorruptionException, we want to clear all DataStore preferences
corruptionHandler = ReplaceFileCorruptionHandler<Preferences> { emptyPreferences() },
produceMigrations = { listOf(migrateAlreadyAskReviewKey) },
)

@Suppress("UNCHECKED_CAST")
class AppReviewSettingsRepository(private val context: Context) {

internal var appReviewThreshold = DEFAULT_APP_REVIEW_THRESHOLD
internal var maxAppReviewThreshold = appReviewThreshold * 10

fun <T> flowFor(key: Preferences.Key<T>) = context.dataStore.data
.map { it[key] ?: (getInitialValue(key) as T) }
Expand All @@ -56,7 +73,7 @@ class AppReviewSettingsRepository(private val context: Context) {

private fun <T> getInitialValue(key: Preferences.Key<T>) = when (key) {
APP_REVIEW_THRESHOLD_KEY -> appReviewThreshold
ALREADY_GAVE_REVIEW_KEY -> DEFAULT_ALREADY_GAVE_REVIEW
ALREADY_ASK_REVIEW_KEY -> DEFAULT_ALREADY_ASK_REVIEW
else -> throw IllegalArgumentException("Unknown Preferences.Key")
}

Expand All @@ -72,20 +89,17 @@ class AppReviewSettingsRepository(private val context: Context) {
context.dataStore.edit(MutablePreferences::clear)
}

suspend fun resetReviewSettings() {
setValue(APP_REVIEW_THRESHOLD_KEY, maxAppReviewThreshold)
}

companion object {

private const val TAG = "AppReviewSettingsRepository"

val APP_REVIEW_THRESHOLD_KEY = intPreferencesKey("appReviewThresholdKey")
val ALREADY_GAVE_REVIEW_KEY = booleanPreferencesKey("alreadyGaveReview")
val ALREADY_ASK_REVIEW_KEY = booleanPreferencesKey("alreadyAskReviewKey")
val LEGACY_ALREADY_ASK_REVIEW_KEY = booleanPreferencesKey("alreadyGaveReview")

internal const val DATA_STORE_NAME = "AppReviewSettingsDataStore"

private const val DEFAULT_ALREADY_GAVE_REVIEW = false
private const val DEFAULT_ALREADY_ASK_REVIEW = false

private const val DEFAULT_APP_REVIEW_THRESHOLD = 50
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ abstract class BaseInAppReviewManager(private val activity: ComponentActivity) :
open fun init(
countdownBehavior: Behavior = Behavior.LifecycleBased,
appReviewThreshold: Int? = null,
maxAppReviewThreshold: Int? = null,
onUserWantToReview: (() -> Unit)? = null,
onUserWantToGiveFeedback: (() -> Unit)? = null
Comment thread
Elouan1411 marked this conversation as resolved.
) = Unit
Expand All @@ -40,6 +39,8 @@ abstract class BaseInAppReviewManager(private val activity: ComponentActivity) :

open fun decrementAppReviewCountdown() = Unit

open fun onReviewDialogShown() = Unit

enum class Behavior {
/** This behavior uses the activity's lifecycle observer to automatically update the countdown */
LifecycleBased,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.play.core.review.ReviewManagerFactory
import com.infomaniak.core.inappreview.AppReviewSettingsRepository
import com.infomaniak.core.inappreview.AppReviewSettingsRepository.Companion.ALREADY_GAVE_REVIEW_KEY
import com.infomaniak.core.inappreview.AppReviewSettingsRepository.Companion.ALREADY_ASK_REVIEW_KEY
import com.infomaniak.core.inappreview.AppReviewSettingsRepository.Companion.APP_REVIEW_THRESHOLD_KEY
import com.infomaniak.core.inappreview.BaseInAppReviewManager
import com.infomaniak.core.webview.ui.WebViewActivity
Expand All @@ -38,20 +38,18 @@ class InAppReviewManager(private val activity: ComponentActivity) : BaseInAppRev
private val appReviewSettingsRepository = AppReviewSettingsRepository(activity)

private val appReviewCountdown = appReviewSettingsRepository.flowFor(APP_REVIEW_THRESHOLD_KEY)
private val alreadyGaveReview = appReviewSettingsRepository.flowFor(ALREADY_GAVE_REVIEW_KEY)

private val alreadyAskReview = appReviewSettingsRepository.flowFor(ALREADY_ASK_REVIEW_KEY)
private var onUserWantsToReview: (() -> Unit)? = null
private var onUserWantsToGiveFeedback: (() -> Unit)? = null

override val shouldDisplayReviewDialog =
combine(alreadyGaveReview, appReviewCountdown) { alreadyGaveReview, countdown ->
combine(alreadyAskReview, appReviewCountdown) { alreadyGaveReview, countdown ->
!alreadyGaveReview && countdown <= 0
}.distinctUntilChanged()

override fun init(
countdownBehavior: Behavior,
appReviewThreshold: Int?,
maxAppReviewThreshold: Int?,
onUserWantToReview: (() -> Unit)?,
onUserWantToGiveFeedback: (() -> Unit)?
) {
Expand All @@ -60,7 +58,6 @@ class InAppReviewManager(private val activity: ComponentActivity) : BaseInAppRev

if (countdownBehavior == Behavior.LifecycleBased) activity.lifecycle.addObserver(observer = this)
appReviewThreshold?.let { appReviewSettingsRepository.appReviewThreshold = it }
maxAppReviewThreshold?.let { appReviewSettingsRepository.maxAppReviewThreshold = it }
}

override fun onResume(owner: LifecycleOwner) {
Expand All @@ -71,37 +68,30 @@ class InAppReviewManager(private val activity: ComponentActivity) : BaseInAppRev
//region public interface
override fun onUserWantsToReview() {
onUserWantsToReview?.invoke()
resetAppReviewSettings()
setAppReviewedStatus()
launchInAppReview()
}

override fun onUserWantsToGiveFeedback(feedbackUrl: String) {
onUserWantsToGiveFeedback?.invoke()
Comment thread
Elouan1411 marked this conversation as resolved.
resetAppReviewSettings()
WebViewActivity.startActivity(activity, feedbackUrl)
}

override fun onUserWantsToDismiss() {
resetAppReviewSettings()
}

override fun decrementAppReviewCountdown() {
activity.lifecycleScope.launch(Dispatchers.IO) {
val appReviewCountdown = appReviewSettingsRepository.getValue(APP_REVIEW_THRESHOLD_KEY)
alreadyGaveReview.collectLatest { hasGivenReview ->
if (!hasGivenReview) set(APP_REVIEW_THRESHOLD_KEY, appReviewCountdown - 1)
alreadyAskReview.collectLatest { hasAskedReview ->
if (!hasAskedReview) set(APP_REVIEW_THRESHOLD_KEY, appReviewCountdown - 1)
}
}
}
//endregion

private fun resetAppReviewSettings() = activity.lifecycleScope.launch(Dispatchers.IO) {
appReviewSettingsRepository.resetReviewSettings()
override fun onReviewDialogShown() {
setAppReviewedStatus()
}
//endregion

private fun setAppReviewedStatus() = activity.lifecycleScope.launch(Dispatchers.IO) {
set(ALREADY_GAVE_REVIEW_KEY, true)
set(ALREADY_ASK_REVIEW_KEY, true)
}

private fun <T> set(key: Preferences.Key<T>, value: T) = activity.lifecycleScope.launch(Dispatchers.IO) {
Expand Down
Loading