152 lines
5.5 KiB
Kotlin
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,
|
|
),
|
|
)
|
|
}
|