diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index ba6fab530..b7512cf0b 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -259,6 +259,8 @@ open class FenixApplication : LocaleAwareApplication(), Provider { ProcessLifecycleOwner.get().lifecycle.addObserver(TelemetryLifecycleObserver(components.core.store)) + components.analytics.metricsStorage.tryRegisterAsUsageRecorder(this) + downloadWallpapers() } diff --git a/app/src/main/java/org/mozilla/fenix/components/Analytics.kt b/app/src/main/java/org/mozilla/fenix/components/Analytics.kt index 1a6549702..f684f3caf 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Analytics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Analytics.kt @@ -25,6 +25,7 @@ import org.mozilla.fenix.components.metrics.AdjustMetricsService import org.mozilla.fenix.components.metrics.DefaultMetricsStorage import org.mozilla.fenix.components.metrics.GleanMetricsService import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.components.metrics.MetricsStorage import org.mozilla.fenix.experiments.createNimbus import org.mozilla.fenix.ext.settings import org.mozilla.fenix.gleanplumb.CustomAttributeProvider @@ -117,17 +118,21 @@ class Analytics( ) } + val metricsStorage: MetricsStorage by lazyMonitored { + DefaultMetricsStorage( + context = context, + settings = context.settings(), + checkDefaultBrowser = { BrowsersCache.all(context).isDefaultBrowser }, + ) + } + val metrics: MetricController by lazyMonitored { MetricController.create( listOf( GleanMetricsService(context), AdjustMetricsService( application = context as Application, - storage = DefaultMetricsStorage( - context = context, - settings = context.settings(), - checkDefaultBrowser = { BrowsersCache.all(context).isDefaultBrowser }, - ), + storage = metricsStorage, crashReporter = crashReporter, ), ), 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 9fec84140..7fddf2bf5 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 Firefox is used 3 days in a row in the first week of install. */ object FirstWeekSeriesActivity : GrowthData("20ay7u") + + /** + * Event recording that usage time has reached a threshold. + */ + object UsageThreshold : GrowthData("m66prt") } } 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 83a1320f0..6e5bc48a8 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 @@ -28,6 +28,7 @@ class MetricsMiddleware( is AppAction.ResumedMetricsAction -> { metrics.track(Event.GrowthData.SetAsDefault) metrics.track(Event.GrowthData.FirstWeekSeriesActivity) + metrics.track(Event.GrowthData.UsageThreshold) } 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 7cb678a8a..b5f887ae2 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 @@ -4,11 +4,14 @@ package org.mozilla.fenix.components.metrics +import android.app.Activity +import android.app.Application import android.content.Context import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import mozilla.components.support.utils.ext.getPackageInfoCompat +import org.mozilla.fenix.android.DefaultActivityLifecycleCallbacks import org.mozilla.fenix.ext.settings import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.utils.Settings @@ -29,6 +32,18 @@ interface MetricsStorage { * Updates locally-stored state for an [event] that has just been sent. */ suspend fun updateSentState(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 + * activities and should update local state using [updateUsageState]. + */ + fun tryRegisterAsUsageRecorder(application: Application) + + /** + * Update local state with a [usageLength] measurement. + */ + fun updateUsageState(usageLength: Long) } internal class DefaultMetricsStorage( @@ -62,6 +77,10 @@ internal class DefaultMetricsStorage( Event.GrowthData.SerpAdClicked -> { currentTime.duringFirstMonth() && !settings.adClickGrowthSent } + Event.GrowthData.UsageThreshold -> { + !settings.usageTimeGrowthSent && + settings.usageTimeGrowthData > usageThresholdMillis + } } } @@ -76,9 +95,23 @@ internal class DefaultMetricsStorage( Event.GrowthData.SerpAdClicked -> { settings.adClickGrowthSent = true } + Event.GrowthData.UsageThreshold -> { + settings.usageTimeGrowthSent = true + } + } + } + + override fun tryRegisterAsUsageRecorder(application: Application) { + // Currently there is only interest in measuring usage during the first day of install. + if (!settings.usageTimeGrowthSent && System.currentTimeMillis().duringFirstDay()) { + application.registerActivityLifecycleCallbacks(UsageRecorder(this)) } } + override fun updateUsageState(usageLength: Long) { + settings.usageTimeGrowthData += usageLength + } + private fun updateDaysOfUse() { val daysOfUse = settings.firstWeekDaysOfUseGrowthData val currentDate = Calendar.getInstance(Locale.US) @@ -121,6 +154,8 @@ internal class DefaultMetricsStorage( calendar.timeInMillis = this } + private fun Long.duringFirstDay() = this < getInstalledTime() + dayMillis + private fun Long.duringFirstWeek() = this < getInstalledTime() + fullWeekMillis private fun Long.duringFirstMonth() = this < getInstalledTime() + shortestMonthMillis @@ -129,6 +164,28 @@ internal class DefaultMetricsStorage( calendar.add(Calendar.DAY_OF_MONTH, 1) } + /** + * 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. + */ + internal class UsageRecorder( + private val metricsStorage: MetricsStorage, + ) : DefaultActivityLifecycleCallbacks { + private val activityStartTimes: MutableMap = mutableMapOf() + + override fun onActivityResumed(activity: Activity) { + super.onActivityResumed(activity) + activityStartTimes[activity.componentName.toString()] = System.currentTimeMillis() + } + + override fun onActivityPaused(activity: Activity) { + super.onActivityPaused(activity) + val startTime = activityStartTimes[activity.componentName.toString()] ?: return + val elapsedTimeMillis = System.currentTimeMillis() - startTime + metricsStorage.updateUsageState(elapsedTimeMillis) + } + } + companion object { private const val dayMillis: Long = 1000 * 60 * 60 * 24 private const val shortestMonthMillis: Long = dayMillis * 28 @@ -137,6 +194,9 @@ internal class DefaultMetricsStorage( // of the 7th day after install private const val fullWeekMillis: Long = dayMillis * 8 + // The usage threshold we are interested in is currently 340 seconds. + private const val usageThresholdMillis = 1000 * 340 + /** * 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 d273bfe0e..7c2f204f4 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -1557,4 +1557,14 @@ class Settings(private val appContext: Context) : PreferencesHolder { key = appContext.getPreferenceKey(R.string.pref_key_growth_ad_click_sent), default = false, ) + + var usageTimeGrowthData by longPreference( + key = appContext.getPreferenceKey(R.string.pref_key_growth_usage_time), + default = -1, + ) + + var usageTimeGrowthSent by booleanPreference( + key = appContext.getPreferenceKey(R.string.pref_key_growth_usage_time_sent), + default = false, + ) } diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 053fa543b..d807e24e8 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -322,6 +322,8 @@ pref_key_growth_first_week_series_sent pref_key_growth_first_week_days_of_use pref_key_growth_ad_click_sent + pref_key_growth_usage_time + pref_key_growth_usage_time_sent pref_key_notification_pre_permission_prompt_enabled 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 d3f5d3481..dfe2ab282 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 @@ -4,11 +4,16 @@ package org.mozilla.fenix.components.metrics +import android.app.Activity +import android.app.Application import io.mockk.every import io.mockk.mockk import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -23,6 +28,7 @@ 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 val usageThresholdMillis: Long = 340 * 1000 private var checkDefaultBrowser = false private val doCheckDefaultBrowser = { checkDefaultBrowser } @@ -229,6 +235,89 @@ class DefaultMetricsStorageTest { assertTrue(result) } + @Test + fun `GIVEN usage time has not passed threshold and has not been sent WHEN checking to track THEN event will not be sent`() = runTest(dispatcher) { + every { settings.usageTimeGrowthData } returns usageThresholdMillis - 1 + every { settings.usageTimeGrowthSent } returns false + + val result = storage.shouldTrack(Event.GrowthData.UsageThreshold) + + assertFalse(result) + } + + @Test + fun `GIVEN usage time has passed threshold and has not been sent WHEN checking to track THEN event will be sent`() = runTest(dispatcher) { + every { settings.usageTimeGrowthData } returns usageThresholdMillis + 1 + every { settings.usageTimeGrowthSent } returns false + + val result = storage.shouldTrack(Event.GrowthData.UsageThreshold) + + assertTrue(result) + } + + @Test + fun `GIVEN usage time growth has not been sent and within first day WHEN registering as usage recorder THEN will be registered`() { + val application = mockk() + every { settings.usageTimeGrowthSent } returns false + every { application.registerActivityLifecycleCallbacks(any()) } returns Unit + + storage.tryRegisterAsUsageRecorder(application) + + verify { application.registerActivityLifecycleCallbacks(any()) } + } + + @Test + fun `GIVEN usage time growth has not been sent and not within first day WHEN registering as usage recorder THEN will not be registered`() { + val application = mockk() + installTime = System.currentTimeMillis() - dayMillis * 2 + every { settings.usageTimeGrowthSent } returns false + + storage.tryRegisterAsUsageRecorder(application) + + verify(exactly = 0) { application.registerActivityLifecycleCallbacks(any()) } + } + + @Test + fun `GIVEN usage time growth has been sent WHEN registering as usage recorder THEN will not be registered`() { + val application = mockk() + every { settings.usageTimeGrowthSent } returns true + + storage.tryRegisterAsUsageRecorder(application) + + verify(exactly = 0) { application.registerActivityLifecycleCallbacks(any()) } + } + + @Test + fun `WHEN updating usage state THEN storage will be delegated to settings`() { + val initial = 10L + val update = 15L + val slot = slot() + every { settings.usageTimeGrowthData } returns initial + every { settings.usageTimeGrowthData = capture(slot) } returns Unit + + storage.updateUsageState(update) + + assertEquals(slot.captured, initial + update) + } + + @Test + fun `WHEN usage recorder receives onResume and onPause callbacks THEN it will store usage length`() { + val storage = mockk() + val activity = mockk() + val slot = slot() + every { storage.updateUsageState(capture(slot)) } returns Unit + every { activity.componentName } returns mock() + + val usageRecorder = DefaultMetricsStorage.UsageRecorder(storage) + val startTime = System.currentTimeMillis() + + usageRecorder.onActivityResumed(activity) + usageRecorder.onActivityPaused(activity) + val stopTime = System.currentTimeMillis() + + assertTrue(slot.captured < stopTime - startTime) + } + private fun Calendar.copy() = clone() as Calendar private fun Calendar.createNextDay() = copy().apply { add(Calendar.DAY_OF_MONTH, 1)