From fa51e99d75da65723ed8799f73e38bb7958aad0d Mon Sep 17 00:00:00 2001 From: Harrison Oglesby Date: Wed, 31 May 2023 09:12:17 -0700 Subject: [PATCH] Bug 1830401 - add Adjust event for Activated Users --- .../java/org/mozilla/fenix/HomeActivity.kt | 2 + .../metrics/AdjustMetricsService.kt | 10 +- .../mozilla/fenix/components/metrics/Event.kt | 7 ++ .../components/metrics/GrowthDataWorker.kt | 74 ++++++++++++ .../components/metrics/MetricController.kt | 1 + .../components/metrics/MetricsMiddleware.kt | 1 + .../components/metrics/MetricsStorage.kt | 51 ++++++++ .../java/org/mozilla/fenix/utils/Settings.kt | 29 +++++ app/src/main/res/values/preference_keys.xml | 6 + .../metrics/DefaultMetricsStorageTest.kt | 110 ++++++++++++++++++ 10 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/components/metrics/GrowthDataWorker.kt 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)