diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt
index a31dfd1945..77fedea9aa 100644
--- a/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt
+++ b/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt
@@ -31,5 +31,10 @@ sealed class Event {
* Event recording the first time a URI is loaded in Firefox in a 24 hour period.
*/
object FirstUriLoadForDay : GrowthData("ja86ek")
+
+ /**
+ * Event recording the first time Firefox is used 3 days in a row in the first week of install.
+ */
+ object FirstWeekSeriesActivity : GrowthData("20ay7u")
}
}
diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsMiddleware.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsMiddleware.kt
index 5c3374c23c..e7d7cf2a17 100644
--- a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsMiddleware.kt
+++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsMiddleware.kt
@@ -24,6 +24,7 @@ class MetricsMiddleware(
is AppAction.ResumedMetricsAction -> {
metrics.track(Event.GrowthData.SetAsDefault)
metrics.track(Event.GrowthData.FirstAppOpenForDay)
+ metrics.track(Event.GrowthData.FirstWeekSeriesActivity)
}
else -> Unit
}
diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsStorage.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsStorage.kt
index c8f56491d9..f19557f7bf 100644
--- a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsStorage.kt
+++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsStorage.kt
@@ -12,6 +12,9 @@ import mozilla.components.support.utils.ext.getPackageInfoCompat
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.utils.Settings
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
/**
* Interface defining functions around persisted local state for certain metrics.
@@ -33,13 +36,20 @@ internal class DefaultMetricsStorage(
private val settings: Settings,
private val checkDefaultBrowser: () -> Boolean,
private val shouldSendGenerally: () -> Boolean = { shouldSendGenerally(context) },
+ private val getInstalledTime: () -> Long = { getInstalledTime(context) },
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : MetricsStorage {
+
+ private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+
/**
* Checks local state to see whether the [event] should be sent.
*/
override suspend fun shouldTrack(event: Event): Boolean =
withContext(dispatcher) {
+ // The side-effect of storing days of use needs to happen during the first two days after
+ // install, which would normally be skipped by shouldSendGenerally.
+ updateDaysOfUse()
shouldSendGenerally() && when (event) {
Event.GrowthData.SetAsDefault -> {
!settings.setAsDefaultGrowthSent && checkDefaultBrowser()
@@ -50,6 +60,9 @@ internal class DefaultMetricsStorage(
Event.GrowthData.FirstUriLoadForDay -> {
settings.uriLoadGrowthLastSent.hasBeenMoreThanDaySince()
}
+ Event.GrowthData.FirstWeekSeriesActivity -> {
+ shouldTrackFirstWeekActivity()
+ }
}
}
@@ -64,21 +77,80 @@ internal class DefaultMetricsStorage(
Event.GrowthData.FirstUriLoadForDay -> {
settings.uriLoadGrowthLastSent = System.currentTimeMillis()
}
+ Event.GrowthData.FirstWeekSeriesActivity -> {
+ settings.firstWeekSeriesGrowthSent = true
+ }
}
}
+ private fun updateDaysOfUse() {
+ val daysOfUse = settings.firstWeekDaysOfUseGrowthData
+ val currentDate = Calendar.getInstance(Locale.US)
+ val currentDateString = dateFormatter.format(currentDate.time)
+ if (currentDate.timeInMillis.withinFirstWeek() && daysOfUse.none { it == currentDateString }) {
+ settings.firstWeekDaysOfUseGrowthData = daysOfUse + currentDateString
+ }
+ }
+
+ private fun shouldTrackFirstWeekActivity(): Boolean = Result.runCatching {
+ if (!System.currentTimeMillis().withinFirstWeek() || settings.firstWeekSeriesGrowthSent) {
+ return false
+ }
+
+ val daysOfUse = settings.firstWeekDaysOfUseGrowthData.map {
+ dateFormatter.parse(it)
+ }.sorted()
+
+ // This loop will check whether the existing list of days of use, combined with the
+ // current date, contains any periods of 3 days of use in a row.
+ for (idx in daysOfUse.indices) {
+ if (idx + 1 > daysOfUse.lastIndex || idx + 2 > daysOfUse.lastIndex) {
+ continue
+ }
+
+ val referenceDate = daysOfUse[idx]!!.time.toCalendar()
+ val secondDateEntry = daysOfUse[idx + 1]!!.time.toCalendar()
+ val thirdDateEntry = daysOfUse[idx + 2]!!.time.toCalendar()
+ val oneDayAfterReference = referenceDate.createNextDay()
+ val twoDaysAfterReference = oneDayAfterReference.createNextDay()
+
+ if (oneDayAfterReference == secondDateEntry && thirdDateEntry == twoDaysAfterReference) {
+ return true
+ }
+ }
+ return false
+ }.getOrDefault(false)
+
private fun Long.hasBeenMoreThanDaySince(): Boolean =
System.currentTimeMillis() - this > dayMillis
+ private fun Long.toCalendar(): Calendar = Calendar.getInstance(Locale.US).also { calendar ->
+ calendar.timeInMillis = this
+ }
+
+ private fun Long.withinFirstWeek() = this < getInstalledTime() + fullWeekMillis
+
+ private fun Calendar.createNextDay() = (this.clone() as Calendar).also { calendar ->
+ calendar.add(Calendar.DAY_OF_MONTH, 1)
+ }
+
companion object {
private const val dayMillis: Long = 1000 * 60 * 60 * 24
private const val windowStartMillis: Long = dayMillis * 2
private const val windowEndMillis: Long = dayMillis * 28
+ // Note this is 8 so that recording of FirstWeekSeriesActivity happens throughout the length
+ // of the 7th day after install
+ private const val fullWeekMillis: Long = dayMillis * 8
+
+ /**
+ * Determines whether events should be tracked based on some general criteria:
+ * - user has installed as a result of a campaign
+ * - user is within 2-28 days of install
+ * - tracking is still enabled through Nimbus
+ */
fun shouldSendGenerally(context: Context): Boolean {
- val installedTime = context.packageManager
- .getPackageInfoCompat(context.packageName, 0)
- .firstInstallTime
+ val installedTime = getInstalledTime(context)
val timeDifference = System.currentTimeMillis() - installedTime
val withinWindow = timeDifference in windowStartMillis..windowEndMillis
@@ -86,5 +158,9 @@ internal class DefaultMetricsStorage(
FxNimbus.features.growthData.value().enabled &&
withinWindow
}
+
+ fun getInstalledTime(context: Context): Long = context.packageManager
+ .getPackageInfoCompat(context.packageName, 0)
+ .firstInstallTime
}
}
diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
index 578fd01943..fe406ec6a5 100644
--- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
+++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
@@ -1444,4 +1444,14 @@ class Settings(private val appContext: Context) : PreferencesHolder {
key = appContext.getPreferenceKey(R.string.pref_key_growth_uri_load_last_sent),
default = 0,
)
+
+ var firstWeekSeriesGrowthSent by booleanPreference(
+ key = appContext.getPreferenceKey(R.string.pref_key_growth_first_week_series_sent),
+ default = false,
+ )
+
+ var firstWeekDaysOfUseGrowthData by stringSetPreference(
+ key = appContext.getPreferenceKey(R.string.pref_key_growth_first_week_days_of_use),
+ default = setOf(),
+ )
}
diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml
index fb7297222e..d4055aa474 100644
--- a/app/src/main/res/values/preference_keys.xml
+++ b/app/src/main/res/values/preference_keys.xml
@@ -316,4 +316,6 @@
pref_key_growth_set_as_default
pref_key_growth_last_resumed
pref_key_growth_uri_load_last_sent
+ pref_key_growth_first_week_series_sent
+ pref_key_growth_first_week_days_of_use
diff --git a/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt b/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt
index cfcb4b6789..4266a4deee 100644
--- a/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt
+++ b/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt
@@ -14,13 +14,22 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.utils.Settings
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
class DefaultMetricsStorageTest {
+ private val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ private val calendarStart = Calendar.getInstance(Locale.US)
+ private val dayMillis: Long = 1000 * 60 * 60 * 24
+
private var checkDefaultBrowser = false
private val doCheckDefaultBrowser = { checkDefaultBrowser }
private var shouldSendGenerally = true
private val doShouldSendGenerally = { shouldSendGenerally }
+ private var installTime = 0L
+ private val doGetInstallTime = { installTime }
private val settings = mockk()
@@ -32,7 +41,12 @@ class DefaultMetricsStorageTest {
fun setup() {
checkDefaultBrowser = false
shouldSendGenerally = true
- storage = DefaultMetricsStorage(mockk(), settings, doCheckDefaultBrowser, doShouldSendGenerally, dispatcher)
+ installTime = System.currentTimeMillis()
+
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf()
+ every { settings.firstWeekDaysOfUseGrowthData = any() } returns Unit
+
+ storage = DefaultMetricsStorage(mockk(), settings, doCheckDefaultBrowser, doShouldSendGenerally, doGetInstallTime, dispatcher)
}
@Test
@@ -147,4 +161,126 @@ class DefaultMetricsStorageTest {
assertTrue(updateSlot.captured > 0)
}
+
+ @Test
+ fun `GIVEN that app has been used for less than 3 days in a row WHEN checked for first week activity THEN event will not be sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData = any() } returns Unit
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that app has only been used for 3 days in a row WHEN checked for first week activity THEN event will be sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val thirdDay = tomorrow.createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, thirdDay).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN that app has been used for 3 days but not consecutively WHEN checked for first week activity THEN event will be not sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val fourDaysFromNow = tomorrow.createNextDay().createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData = any() } returns Unit
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, fourDaysFromNow).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that app has been used for 3 days consecutively but not within first week WHEN checked for first week activity THEN event will be not sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val thirdDay = tomorrow.createNextDay()
+ val installTime9DaysEarlier = calendarStart.timeInMillis - (dayMillis * 9)
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, thirdDay).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+ installTime = installTime9DaysEarlier
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that first week activity has already been sent WHEN checked for first week activity THEN event will be not sent`() = runTest(dispatcher) {
+ val tomorrow = calendarStart.createNextDay()
+ val thirdDay = tomorrow.createNextDay()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(calendarStart, tomorrow, thirdDay).toStrings()
+ every { settings.firstWeekSeriesGrowthSent } returns true
+
+ val result = storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN that first week activity is not sent WHEN checked to send THEN current day is added to rolling days`() = runTest(dispatcher) {
+ val captureRolling = slot>()
+ val previousDay = calendarStart.createPreviousDay()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf(previousDay).toStrings()
+ every { settings.firstWeekDaysOfUseGrowthData = capture(captureRolling) } returns Unit
+ every { settings.firstWeekSeriesGrowthSent } returns false
+
+ storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(captureRolling.captured.contains(formatter.format(calendarStart.time)))
+ }
+
+ @Test
+ fun `WHEN first week activity state updated THEN settings updated accordingly`() = runTest(dispatcher) {
+ val captureSent = slot()
+ every { settings.firstWeekSeriesGrowthSent } returns false
+ every { settings.firstWeekSeriesGrowthSent = capture(captureSent) } returns Unit
+
+ storage.updateSentState(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(captureSent.captured)
+ }
+
+ @Test
+ fun `GIVEN not yet in recording window WHEN checking to track THEN days of use still updated`() = runTest(dispatcher) {
+ shouldSendGenerally = false
+ val captureSlot = slot>()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf()
+ every { settings.firstWeekDaysOfUseGrowthData = capture(captureSlot) } returns Unit
+
+ storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertTrue(captureSlot.captured.isNotEmpty())
+ }
+
+ @Test
+ fun `GIVEN outside first week after install WHEN checking to track THEN days of use is not updated`() = runTest(dispatcher) {
+ val captureSlot = slot>()
+ every { settings.firstWeekDaysOfUseGrowthData } returns setOf()
+ every { settings.firstWeekDaysOfUseGrowthData = capture(captureSlot) } returns Unit
+ installTime = calendarStart.timeInMillis - (dayMillis * 9)
+
+ storage.shouldTrack(Event.GrowthData.FirstWeekSeriesActivity)
+
+ assertFalse(captureSlot.isCaptured)
+ }
+
+ private fun Calendar.copy() = clone() as Calendar
+ private fun Calendar.createNextDay() = copy().apply {
+ add(Calendar.DAY_OF_MONTH, 1)
+ }
+ private fun Calendar.createPreviousDay() = copy().apply {
+ add(Calendar.DAY_OF_MONTH, -1)
+ }
+ private fun Set.toStrings() = map {
+ formatter.format(it.time)
+ }.toSet()
}