diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
index 37ce03604..aaa771019 100644
--- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
+++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
@@ -90,6 +90,7 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.browser.browsingmode.DefaultBrowsingModeManager
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.metrics.BreadcrumbsRecorder
+import org.mozilla.fenix.components.metrics.GrowthDataWorker
import org.mozilla.fenix.databinding.ActivityHomeBinding
import org.mozilla.fenix.exceptions.trackingprotection.TrackingProtectionExceptionsFragmentDirections
import org.mozilla.fenix.experiments.ResearchSurfaceDialogFragment
@@ -452,6 +453,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
Events.defaultBrowserChanged.record(NoExtras())
}
+ GrowthDataWorker.sendActivatedSignalIfNeeded(applicationContext)
ReEngagementNotificationWorker.setReEngagementNotificationIfNeeded(applicationContext)
MessageNotificationWorker.setMessageNotificationWorker(applicationContext)
}
diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/AdjustMetricsService.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/AdjustMetricsService.kt
index 48213b60a..862f7fe31 100644
--- a/app/src/main/java/org/mozilla/fenix/components/metrics/AdjustMetricsService.kt
+++ b/app/src/main/java/org/mozilla/fenix/components/metrics/AdjustMetricsService.kt
@@ -88,9 +88,13 @@ class AdjustMetricsService(
override fun track(event: Event) {
CoroutineScope(dispatcher).launch {
try {
- if (event is Event.GrowthData && storage.shouldTrack(event)) {
- Adjust.trackEvent(AdjustEvent(event.tokenName))
- storage.updateSentState(event)
+ if (event is Event.GrowthData) {
+ if (storage.shouldTrack(event)) {
+ Adjust.trackEvent(AdjustEvent(event.tokenName))
+ storage.updateSentState(event)
+ } else {
+ storage.updatePersistentState(event)
+ }
}
} catch (e: Exception) {
crashReporter.submitCaughtException(e)
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 f932fbb60..29d4079c5 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
@@ -46,5 +46,12 @@ 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 when User is "activated" in first week of usage.
+ * Activated = if the user is active 3 days in their first week and
+ * if they search once in the latter half of that week (days 4-7).
+ */
+ data class UserActivated(val fromSearch: Boolean) : GrowthData("imgpmr")
}
}
diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/GrowthDataWorker.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/GrowthDataWorker.kt
new file mode 100644
index 000000000..728752395
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/components/metrics/GrowthDataWorker.kt
@@ -0,0 +1,74 @@
+package org.mozilla.fenix.components.metrics
+
+import android.content.Context
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import mozilla.components.support.utils.ext.getPackageInfoCompat
+import org.mozilla.fenix.ext.metrics
+import org.mozilla.fenix.ext.settings
+import java.util.concurrent.TimeUnit
+
+/**
+ * Worker that will send the User Activated event at the end of the first week.
+ */
+class GrowthDataWorker(
+ context: Context,
+ workerParameters: WorkerParameters,
+) : Worker(context, workerParameters) {
+
+ override fun doWork(): Result {
+ val settings = applicationContext.settings()
+
+ if (!System.currentTimeMillis().isAfterFirstWeekFromInstall(applicationContext) ||
+ settings.growthUserActivatedSent
+ ) {
+ return Result.success()
+ }
+
+ applicationContext.metrics.track(Event.GrowthData.UserActivated(fromSearch = false))
+
+ return Result.success()
+ }
+
+ companion object {
+ private const val GROWTH_USER_ACTIVATED_WORK_NAME = "org.mozilla.fenix.growth.work"
+ private const val DAY_MILLIS: Long = 1000 * 60 * 60 * 24
+ private const val FULL_WEEK_MILLIS: Long = DAY_MILLIS * 7
+
+ /**
+ * Schedules the Activated User event if needed.
+ */
+ fun sendActivatedSignalIfNeeded(context: Context) {
+ val instanceWorkManager = WorkManager.getInstance(context)
+
+ if (context.settings().growthUserActivatedSent) {
+ return
+ }
+
+ val growthSignalWork = OneTimeWorkRequest.Builder(GrowthDataWorker::class.java)
+ .setInitialDelay(FULL_WEEK_MILLIS, TimeUnit.MILLISECONDS)
+ .build()
+
+ instanceWorkManager.beginUniqueWork(
+ GROWTH_USER_ACTIVATED_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ growthSignalWork,
+ ).enqueue()
+ }
+
+ /**
+ * Returns [Boolean] value signaling if current time is after the first week after install.
+ */
+ private fun Long.isAfterFirstWeekFromInstall(context: Context): Boolean {
+ val timeDifference = this - getInstalledTime(context)
+ return (FULL_WEEK_MILLIS <= timeDifference)
+ }
+
+ private fun getInstalledTime(context: Context): Long = context.packageManager
+ .getPackageInfoCompat(context.packageName, 0)
+ .firstInstallTime
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt
index bbac2287f..0fdfc47aa 100644
--- a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt
+++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricController.kt
@@ -277,6 +277,7 @@ internal class ReleaseMetricController(
}
Component.FEATURE_SEARCH to InContentTelemetry.IN_CONTENT_SEARCH -> {
BrowserSearch.inContent[value!!].add()
+ track(Event.GrowthData.UserActivated(fromSearch = true))
}
Component.SUPPORT_WEBEXTENSIONS to WebExtensionFacts.Items.WEB_EXTENSIONS_INITIALIZED -> {
metadata?.get("installed")?.let { installedAddons ->
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 4b35527f3..f51031da4 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
@@ -30,6 +30,7 @@ class MetricsMiddleware(
metrics.track(Event.GrowthData.FirstAppOpenForDay)
metrics.track(Event.GrowthData.FirstWeekSeriesActivity)
metrics.track(Event.GrowthData.UsageThreshold)
+ metrics.track(Event.GrowthData.UserActivated(fromSearch = false))
}
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 cfa9121a9..8602e77ea 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
@@ -33,6 +33,11 @@ interface MetricsStorage {
*/
suspend fun updateSentState(event: Event)
+ /**
+ * Updates locally-stored data related to an [event] that has just been sent.
+ */
+ suspend fun updatePersistentState(event: Event)
+
/**
* Will try to register this as a recorder of app usage based on whether usage recording is still
* needed. It will measure usage by to monitoring lifecycle callbacks from [application]'s
@@ -60,6 +65,7 @@ internal class DefaultMetricsStorage(
/**
* Checks local state to see whether the [event] should be sent.
*/
+ @Suppress("ComplexMethod", "CyclomaticComplexMethod")
override suspend fun shouldTrack(event: Event): Boolean =
withContext(dispatcher) {
// The side-effect of storing days of use always needs to happen.
@@ -91,6 +97,9 @@ internal class DefaultMetricsStorage(
currentTime.duringFirstMonth() &&
settings.uriLoadGrowthLastSent.hasBeenMoreThanDaySince()
}
+ is Event.GrowthData.UserActivated -> {
+ hasUserReachedActivatedThreshold()
+ }
}
}
@@ -114,6 +123,23 @@ internal class DefaultMetricsStorage(
Event.GrowthData.FirstUriLoadForDay -> {
settings.uriLoadGrowthLastSent = System.currentTimeMillis()
}
+ is Event.GrowthData.UserActivated -> {
+ settings.growthUserActivatedSent = true
+ }
+ }
+ }
+
+ override suspend fun updatePersistentState(event: Event) {
+ when (event) {
+ is Event.GrowthData.UserActivated -> {
+ if (event.fromSearch && shouldUpdateSearchUsage()) {
+ settings.growthEarlySearchUsed = true
+ } else if (!event.fromSearch && shouldUpdateUsageCount()) {
+ settings.growthEarlyUseCount.increment()
+ settings.growthEarlyUseCountLastIncrement = System.currentTimeMillis()
+ }
+ }
+ else -> Unit
}
}
@@ -176,6 +202,8 @@ internal class DefaultMetricsStorage(
private fun Long.duringFirstDay() = this < getInstalledTime() + dayMillis
+ private fun Long.afterThirdDay() = this > getInstalledTime() + threeDayMillis
+
private fun Long.duringFirstWeek() = this < getInstalledTime() + fullWeekMillis
private fun Long.duringFirstMonth() = this < getInstalledTime() + shortestMonthMillis
@@ -184,6 +212,25 @@ internal class DefaultMetricsStorage(
calendar.add(Calendar.DAY_OF_MONTH, 1)
}
+ private fun hasUserReachedActivatedThreshold(): Boolean {
+ return !settings.growthUserActivatedSent &&
+ settings.growthEarlyUseCount.value >= daysActivatedThreshold &&
+ settings.growthEarlySearchUsed
+ }
+
+ private fun shouldUpdateUsageCount(): Boolean {
+ val currentTime = System.currentTimeMillis()
+ return currentTime.afterFirstDay() &&
+ currentTime.duringFirstWeek() &&
+ settings.growthEarlyUseCountLastIncrement.hasBeenMoreThanDaySince()
+ }
+
+ private fun shouldUpdateSearchUsage(): Boolean {
+ val currentTime = System.currentTimeMillis()
+ return currentTime.afterThirdDay() &&
+ currentTime.duringFirstWeek()
+ }
+
/**
* This will store app usage time to disk, based on Resume and Pause lifecycle events. Currently,
* there is only interest in usage during the first day after install.
@@ -208,6 +255,7 @@ internal class DefaultMetricsStorage(
companion object {
private const val dayMillis: Long = 1000 * 60 * 60 * 24
+ private const val threeDayMillis: Long = 3 * dayMillis
private const val shortestMonthMillis: Long = dayMillis * 28
// Note this is 8 so that recording of FirstWeekSeriesActivity happens throughout the length
@@ -217,6 +265,9 @@ internal class DefaultMetricsStorage(
// The usage threshold we are interested in is currently 340 seconds.
private const val usageThresholdMillis = 1000 * 340
+ // The usage threshold for "activated" growth users.
+ private const val daysActivatedThreshold = 3
+
/**
* Determines whether events should be tracked based on some general criteria:
* - user has installed as a result of a campaign
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 2fb6fc4ab..32f2e6035 100644
--- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
+++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
@@ -1735,4 +1735,33 @@ class Settings(private val appContext: Context) : PreferencesHolder {
key = appContext.getPreferenceKey(R.string.pref_key_enable_tabs_tray_to_compose),
default = FeatureFlags.composeTabsTray,
)
+
+ /**
+ * Adjust Activated User sent
+ */
+ var growthUserActivatedSent by booleanPreference(
+ key = appContext.getPreferenceKey(R.string.pref_key_growth_user_activated_sent),
+ default = false,
+ )
+
+ /**
+ * Indicates how many days in the first week user opened the app.
+ */
+ val growthEarlyUseCount = counterPreference(
+ appContext.getPreferenceKey(R.string.pref_key_growth_early_browse_count),
+ maxCount = 3,
+ )
+
+ var growthEarlyUseCountLastIncrement by longPreference(
+ key = appContext.getPreferenceKey(R.string.pref_key_growth_early_browse_count_last_increment),
+ default = 0L,
+ )
+
+ /**
+ * Indicates how many days in the first week user searched in the app.
+ */
+ var growthEarlySearchUsed by booleanPreference(
+ key = appContext.getPreferenceKey(R.string.pref_key_growth_early_search),
+ default = false,
+ )
}
diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml
index 921bddf90..d52d133ad 100644
--- a/app/src/main/res/values/preference_keys.xml
+++ b/app/src/main/res/values/preference_keys.xml
@@ -290,6 +290,12 @@
pref_key_show_collections_home
+
+ pref_key_growth_user_activated_sent
+ pref_key_growth_early_browse_count
+ pref_key_growth_early_browse_count_last_increment
+ pref_key_growth_early_search
+
pref_key_tab_view_list
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 018e68062..d07620b85 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
@@ -6,7 +6,9 @@ package org.mozilla.fenix.components.metrics
import android.app.Activity
import android.app.Application
+import io.mockk.Runs
import io.mockk.every
+import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
@@ -382,6 +384,114 @@ class DefaultMetricsStorageTest {
assertTrue(updateSlot.captured > 0)
}
+ @Test
+ fun `GIVEN first week activated days of use and search use thresholds reached THEN will be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 3
+ every { settings.growthEarlySearchUsed } returns true
+ every { settings.growthUserActivatedSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `GIVEN first week activated days of use threshold not reached THEN will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 1
+ every { settings.growthEarlySearchUsed } returns true
+ every { settings.growthUserActivatedSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN first week activated search use threshold not reached THEN will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 3
+ every { settings.growthEarlySearchUsed } returns false
+ every { settings.growthUserActivatedSent } returns false
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `GIVEN first week activated already sent WHEN first week activated signal sent THEN userActivated will not be sent`() = runTest(dispatcher) {
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 5)
+ every { settings.growthEarlyUseCount.value } returns 3
+ every { settings.growthEarlySearchUsed } returns true
+ every { settings.growthUserActivatedSent } returns true
+
+ val result = storage.shouldTrack(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `WHEN first week usage signal is sent a full day after last sent THEN settings will be updated accordingly`() = runTest(dispatcher) {
+ val captureSent = slot()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3)
+ every { settings.growthEarlyUseCount.value } returns 1
+ every { settings.growthEarlyUseCount.increment() } just Runs
+ every { settings.growthEarlyUseCountLastIncrement } returns 0L
+ every { settings.growthEarlyUseCountLastIncrement = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertTrue(captureSent.captured > 0L)
+ }
+
+ @Test
+ fun `WHEN first week usage signal is sent less than a full day after last sent THEN settings will not be updated`() = runTest(dispatcher) {
+ val captureSent = slot()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3)
+ val lastUsageIncrementTime = currentTime - (dayMillis / 2)
+ every { settings.growthEarlyUseCount.value } returns 1
+ every { settings.growthEarlyUseCountLastIncrement } returns lastUsageIncrementTime
+ every { settings.growthEarlyUseCountLastIncrement = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = false))
+
+ assertFalse(captureSent.isCaptured)
+ }
+
+ @Test
+ fun `WHEN first week search activity is sent in second half of first week THEN settings will be updated`() = runTest(dispatcher) {
+ val captureSent = slot()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3) - 100
+ every { settings.growthEarlySearchUsed } returns false
+ every { settings.growthEarlySearchUsed = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = true))
+
+ assertTrue(captureSent.captured)
+ }
+
+ @Test
+ fun `WHEN first week search activity is sent in first half of first week THEN settings will not be updated`() = runTest(dispatcher) {
+ val captureSent = slot()
+ val currentTime = System.currentTimeMillis()
+ installTime = currentTime - (dayMillis * 3) + 100
+ every { settings.growthEarlySearchUsed } returns false
+ every { settings.growthEarlySearchUsed = capture(captureSent) } returns Unit
+
+ storage.updatePersistentState(Event.GrowthData.UserActivated(fromSearch = true))
+
+ assertFalse(captureSent.isCaptured)
+ }
+
private fun Calendar.copy() = clone() as Calendar
private fun Calendar.createNextDay() = copy().apply {
add(Calendar.DAY_OF_MONTH, 1)