diff --git a/InAppReview/README.md b/InAppReview/README.md index c64d4e893..98196369b 100644 --- a/InAppReview/README.md +++ b/InAppReview/README.md @@ -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) @@ -45,7 +45,6 @@ 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" ) @@ -53,8 +52,8 @@ class MainActivity : ComponentActivity() { } ``` -> **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. --- @@ -63,9 +62,11 @@ class MainActivity : ComponentActivity() { ### 3.1 Observe `shouldDisplayReviewDialog` `shouldDisplayReviewDialog` is a `Flow` 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 @@ -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") }, diff --git a/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/AppReviewSettingsRepository.kt b/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/AppReviewSettingsRepository.kt index 09dd5a76c..76000f7a3 100644 --- a/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/AppReviewSettingsRepository.kt +++ b/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/AppReviewSettingsRepository.kt @@ -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 @@ -31,17 +32,33 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +private val migrateAlreadyAskReviewKey = object : DataMigration { + 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 { 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 flowFor(key: Preferences.Key) = context.dataStore.data .map { it[key] ?: (getInitialValue(key) as T) } @@ -56,7 +73,7 @@ class AppReviewSettingsRepository(private val context: Context) { private fun getInitialValue(key: Preferences.Key) = 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") } @@ -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 } diff --git a/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/BaseInAppReviewManager.kt b/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/BaseInAppReviewManager.kt index 386c022eb..c28339393 100644 --- a/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/BaseInAppReviewManager.kt +++ b/InAppReview/src/main/kotlin/com/infomaniak/core/inappreview/BaseInAppReviewManager.kt @@ -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 ) = Unit @@ -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, diff --git a/InAppReview/src/standard/kotlin/com/infomaniak/core/inappreview/reviewmanagers/InAppReviewManager.kt b/InAppReview/src/standard/kotlin/com/infomaniak/core/inappreview/reviewmanagers/InAppReviewManager.kt index a96aac561..d8bf06e3a 100644 --- a/InAppReview/src/standard/kotlin/com/infomaniak/core/inappreview/reviewmanagers/InAppReviewManager.kt +++ b/InAppReview/src/standard/kotlin/com/infomaniak/core/inappreview/reviewmanagers/InAppReviewManager.kt @@ -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 @@ -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)? ) { @@ -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) { @@ -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() - 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 set(key: Preferences.Key, value: T) = activity.lifecycleScope.launch(Dispatchers.IO) {