fenix/app/src/main/java/org/mozilla/fenix/components/ReviewPromptController.kt

152 lines
5.5 KiB
Kotlin

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.components
import android.app.Activity
import androidx.annotation.VisibleForTesting
import com.google.android.play.core.review.ReviewInfo
import com.google.android.play.core.review.ReviewManager
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.withContext
import org.mozilla.fenix.GleanMetrics.ReviewPrompt
import org.mozilla.fenix.utils.Settings
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Interface that describes the settings needed to track the Review Prompt.
*/
interface ReviewSettings {
var numberOfAppLaunches: Int
val isDefaultBrowser: Boolean
var lastReviewPromptTimeInMillis: Long
}
/**
* Wraps `Settings` to conform to `ReviewSettings`.
*/
class FenixReviewSettings(
val settings: Settings,
) : ReviewSettings {
override var numberOfAppLaunches: Int
get() = settings.numberOfAppLaunches
set(value) { settings.numberOfAppLaunches = value }
override val isDefaultBrowser: Boolean
get() = settings.isDefaultBrowserBlocking()
override var lastReviewPromptTimeInMillis: Long
get() = settings.lastReviewPromptTimeInMillis
set(value) { settings.lastReviewPromptTimeInMillis = value }
}
/**
* Controls the Review Prompt behavior.
*/
class ReviewPromptController(
private val manager: ReviewManager,
private val reviewSettings: ReviewSettings,
private val timeNowInMillis: () -> Long = { System.currentTimeMillis() },
private val tryPromptReview: suspend (Activity) -> Unit = { activity ->
val flow = manager.requestReviewFlow()
withContext(Main) {
flow.addOnCompleteListener {
if (it.isSuccessful) {
manager.launchReviewFlow(activity, it.result)
recordReviewPromptEvent(
it.result.toString(),
reviewSettings.numberOfAppLaunches,
Date(),
)
}
}
}
},
) {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Volatile
var reviewPromptIsReady = false
suspend fun promptReview(activity: Activity) {
if (shouldShowPrompt()) {
tryPromptReview(activity)
reviewSettings.lastReviewPromptTimeInMillis = timeNowInMillis()
}
}
fun trackApplicationLaunch() {
reviewSettings.numberOfAppLaunches = reviewSettings.numberOfAppLaunches + 1
// We only want to show the the prompt after we've finished "launching" the application.
reviewPromptIsReady = true
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun shouldShowPrompt(): Boolean {
if (!reviewPromptIsReady) {
return false
} else {
// We only want to try to show it once to avoid unnecessary disk reads
reviewPromptIsReady = false
}
if (!reviewSettings.isDefaultBrowser) { return false }
val hasOpenedFiveTimes = reviewSettings.numberOfAppLaunches >= NUMBER_OF_LAUNCHES_REQUIRED
val now = timeNowInMillis()
val apprxFourMonthsAgo = now - (APPRX_MONTH_IN_MILLIS * NUMBER_OF_MONTHS_TO_PASS)
val lastPrompt = reviewSettings.lastReviewPromptTimeInMillis
val hasNotBeenPromptedLastFourMonths = lastPrompt == 0L || lastPrompt <= apprxFourMonthsAgo
return hasOpenedFiveTimes && hasNotBeenPromptedLastFourMonths
}
companion object {
private const val APPRX_MONTH_IN_MILLIS: Long = 1000L * 60L * 60L * 24L * 30L
private const val NUMBER_OF_LAUNCHES_REQUIRED = 5
private const val NUMBER_OF_MONTHS_TO_PASS = 4
}
}
/**
* Records a [ReviewPrompt] with the required data.
*
* **Note:** The docs for [ReviewManager.launchReviewFlow] state 'In some circumstances the review
* flow will not be shown to the user, e.g. they have already seen it recently, so do not assume that
* calling this method will always display the review dialog.'
* However, investigation has shown that a [ReviewInfo] instance with the flag:
* - 'isNoOp=true' indicates that the prompt has NOT been displayed.
* - 'isNoOp=false' indicates that a prompt has been displayed.
* [ReviewManager.launchReviewFlow] will modify the ReviewInfo instance which can be used to determine
* which of these flags is present.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun recordReviewPromptEvent(
reviewInfoAsString: String,
numberOfAppLaunches: Int,
now: Date,
) {
val formattedLocalDatetime =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()).format(now)
// The internals of ReviewInfo cannot be accessed directly or cast nicely, so lets simply use
// the object as a string.
// ReviewInfo is susceptible to changes outside of our control hence the catch-all 'else' statement.
val promptWasDisplayed = if (reviewInfoAsString.contains("isNoOp=true")) {
"false"
} else if (reviewInfoAsString.contains("isNoOp=false")) {
"true"
} else {
"error"
}
ReviewPrompt.promptAttempt.record(
ReviewPrompt.PromptAttemptExtra(
promptWasDisplayed = promptWasDisplayed,
localDatetime = formattedLocalDatetime,
numberOfAppLaunches = numberOfAppLaunches,
),
)
}