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 a31dfd194..77fedea9a 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 5c3374c23..e7d7cf2a1 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 e0777df7b..6b06e40ca 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 @@ -11,6 +11,9 @@ import kotlinx.coroutines.withContext 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. @@ -32,13 +35,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() @@ -49,6 +59,9 @@ internal class DefaultMetricsStorage( Event.GrowthData.FirstUriLoadForDay -> { settings.uriLoadGrowthLastSent.hasBeenMoreThanDaySince() } + Event.GrowthData.FirstWeekSeriesActivity -> { + shouldTrackFirstWeekActivity() + } } } @@ -63,21 +76,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 - .getPackageInfo(context.packageName, 0) - .firstInstallTime + val installedTime = getInstalledTime(context) val timeDifference = System.currentTimeMillis() - installedTime val withinWindow = timeDifference in windowStartMillis..windowEndMillis @@ -85,5 +157,9 @@ internal class DefaultMetricsStorage( FxNimbus.features.growthData.value().enabled && withinWindow } + + fun getInstalledTime(context: Context): Long = context.packageManager + .getPackageInfo(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 fd7b0a4c4..00e845292 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -1428,4 +1428,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 cff61b0e5..5aacac33a 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -312,4 +312,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 cfcb4b678..4266a4dee 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() }