diff --git a/.experimenter.yaml b/.experimenter.yaml index 46d1a1e16f..d1790ace80 100644 --- a/.experimenter.yaml +++ b/.experimenter.yaml @@ -1,4 +1,12 @@ --- +growth-data: + description: A feature measuring campaign growth data + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "If true, the feature is active" homescreen: description: The homescreen that the user goes to when they press home or new tab. hasExposure: true diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index a8e47bd95d..b9f48354bb 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -377,6 +377,8 @@ open class FenixApplication : LocaleAwareApplication(), Provider { if (settings().isMarketingTelemetryEnabled) { components.analytics.metrics.start(MetricServiceType.Marketing) } + + components.appStore.dispatch(AppAction.MetricsInitializedAction) } protected open fun setupLeakCanary() { 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 63b98c17cc..1a6549702c 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Analytics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Analytics.kt @@ -22,6 +22,7 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.ReleaseChannel 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.experiments.createNimbus @@ -31,6 +32,7 @@ import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage import org.mozilla.fenix.gleanplumb.OnDiskMessageMetadataStorage import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.perf.lazyMonitored +import org.mozilla.fenix.utils.BrowsersCache import org.mozilla.geckoview.BuildConfig.MOZ_APP_BUILDID import org.mozilla.geckoview.BuildConfig.MOZ_APP_VENDOR import org.mozilla.geckoview.BuildConfig.MOZ_APP_VERSION @@ -119,7 +121,15 @@ class Analytics( MetricController.create( listOf( GleanMetricsService(context), - AdjustMetricsService(context as Application), + AdjustMetricsService( + application = context as Application, + storage = DefaultMetricsStorage( + context = context, + settings = context.settings(), + checkDefaultBrowser = { BrowsersCache.all(context).isDefaultBrowser }, + ), + crashReporter = crashReporter, + ), ), isDataTelemetryEnabled = { context.settings().isTelemetryEnabled }, isMarketingDataTelemetryEnabled = { context.settings().isMarketingTelemetryEnabled }, diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index 1d48a4272e..ef1a737e5c 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -25,6 +25,7 @@ import org.mozilla.fenix.autofill.AutofillConfirmActivity import org.mozilla.fenix.autofill.AutofillSearchActivity import org.mozilla.fenix.autofill.AutofillUnlockActivity import org.mozilla.fenix.components.appstate.AppState +import org.mozilla.fenix.components.metrics.MetricsMiddleware import org.mozilla.fenix.datastore.pocketStoriesSelectedCategoriesDataStore import org.mozilla.fenix.ext.asRecentTabs import org.mozilla.fenix.ext.components @@ -207,6 +208,7 @@ class Components(private val context: Context) { context.pocketStoriesSelectedCategoriesDataStore, ), MessagingMiddleware(messagingStorage = analytics.messagingStorage), + MetricsMiddleware(metrics = analytics.metrics), ), ) } diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt index 4db4c26e0d..aa55e33fad 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt @@ -191,4 +191,9 @@ sealed class AppAction : Action { val imageState: Wallpaper.ImageFileState, ) : WallpaperAction() } + + /** + * Indicates that the app's metrics have been initialized and startup data can be sent. + */ + object MetricsInitializedAction : AppAction() } diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt index 5355af13d5..9383bed3ed 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt @@ -220,6 +220,7 @@ internal object AppStoreReducer { val wallpaperState = state.wallpaperState.copy(availableWallpapers = wallpapers) state.copy(wallpaperState = wallpaperState) } + is AppAction.MetricsInitializedAction -> state } } 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 5f4788aff8..bf32cb29cc 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 @@ -10,12 +10,23 @@ import android.os.Bundle import android.util.Log import com.adjust.sdk.Adjust import com.adjust.sdk.AdjustConfig +import com.adjust.sdk.AdjustEvent import com.adjust.sdk.LogLevel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.lib.crash.CrashReporter import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.Config import org.mozilla.fenix.ext.settings -class AdjustMetricsService(private val application: Application) : MetricsService { +class AdjustMetricsService( + private val application: Application, + private val storage: MetricsStorage, + private val crashReporter: CrashReporter, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : MetricsService { override val type = MetricServiceType.Marketing override fun start() { @@ -70,9 +81,22 @@ class AdjustMetricsService(private val application: Application) : MetricsServic Adjust.gdprForgetMe(application.applicationContext) } - // We're not currently sending events directly to Adjust - override fun track(event: Event) { /* noop */ } - override fun shouldTrack(event: Event): Boolean = false + @Suppress("TooGenericExceptionCaught") + 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) + } + } catch (e: Exception) { + crashReporter.submitCaughtException(e) + } + } + } + + override fun shouldTrack(event: Event): Boolean = + event is Event.GrowthData companion object { private const val LOGTAG = "AdjustMetricsService" 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 b625cbd092..8df4b18a35 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 @@ -12,4 +12,14 @@ sealed class Event { internal open val extras: Map<*, String>? get() = null + + /** + * Events related to growth campaigns. + */ + sealed class GrowthData(val tokenName: String) : Event() { + /** + * Event recording whether Firefox has been set as the default browser. + */ + object SetAsDefault : GrowthData("xgpcgt") + } } 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 new file mode 100644 index 0000000000..29b485f0e2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsMiddleware.kt @@ -0,0 +1,29 @@ +package org.mozilla.fenix.components.metrics + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import org.mozilla.fenix.components.appstate.AppAction +import org.mozilla.fenix.components.appstate.AppState + +/** + * A middleware that will map incoming actions to relevant events for [metrics]. + */ +class MetricsMiddleware( + private val metrics: MetricController, +) : Middleware { + override fun invoke( + context: MiddlewareContext, + next: (AppAction) -> Unit, + action: AppAction, + ) { + handleAction(action) + next(action) + } + + private fun handleAction(action: AppAction) = when (action) { + is AppAction.MetricsInitializedAction -> { + metrics.track(Event.GrowthData.SetAsDefault) + } + 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 new file mode 100644 index 0000000000..ad7530554f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsStorage.kt @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.components.metrics + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.nimbus.FxNimbus +import org.mozilla.fenix.utils.Settings + +/** + * Interface defining functions around persisted local state for certain metrics. + */ +interface MetricsStorage { + /** + * Determines whether an [event] should be sent based on locally-stored state. + */ + suspend fun shouldTrack(event: Event): Boolean + + /** + * Updates locally-stored state for an [event] that has just been sent. + */ + suspend fun updateSentState(event: Event) +} + +internal class DefaultMetricsStorage( + context: Context, + private val settings: Settings, + private val checkDefaultBrowser: () -> Boolean, + private val shouldSendGenerally: () -> Boolean = { shouldSendGenerally(context) }, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : MetricsStorage { + /** + * Checks local state to see whether the [event] should be sent. + */ + override suspend fun shouldTrack(event: Event): Boolean = + withContext(dispatcher) { + shouldSendGenerally() && when (event) { + Event.GrowthData.SetAsDefault -> { + !settings.setAsDefaultGrowthSent && checkDefaultBrowser() + } + } + } + + override suspend fun updateSentState(event: Event) = withContext(dispatcher) { + when (event) { + Event.GrowthData.SetAsDefault -> settings.setAsDefaultGrowthSent = true + } + } + + 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 + + fun shouldSendGenerally(context: Context): Boolean { + val installedTime = context.packageManager + .getPackageInfo(context.packageName, 0) + .firstInstallTime + val timeDifference = System.currentTimeMillis() - installedTime + val withinWindow = timeDifference in windowStartMillis..windowEndMillis + + return context.settings().adjustCampaignId.isNotEmpty() && + FxNimbus.features.growthData.value().enabled && + withinWindow + } + } +} 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 597efc641b..25e51dd8f0 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -1413,4 +1413,9 @@ class Settings(private val appContext: Context) : PreferencesHolder { HttpsOnlyMode.ENABLED } } + + var setAsDefaultGrowthSent by booleanPreference( + key = appContext.getPreferenceKey(R.string.pref_key_growth_set_as_default), + default = false, + ) } diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index c01bd3dfc5..d68266645e 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -307,4 +307,7 @@ pref_key_history_metadata_feature pref_key_show_unified_search pref_key_custom_glean_server_url + + + pref_key_growth_set_as_default 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 new file mode 100644 index 0000000000..a4ae001442 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/metrics/DefaultMetricsStorageTest.kt @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.components.metrics + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.utils.Settings + +class DefaultMetricsStorageTest { + + private var checkDefaultBrowser = false + private val doCheckDefaultBrowser = { checkDefaultBrowser } + private var shouldSendGenerally = true + private val doShouldSendGenerally = { shouldSendGenerally } + + private val settings = mockk() + + private val dispatcher = StandardTestDispatcher() + + private lateinit var storage: DefaultMetricsStorage + + @Before + fun setup() { + checkDefaultBrowser = false + shouldSendGenerally = true + storage = DefaultMetricsStorage(mockk(), settings, doCheckDefaultBrowser, doShouldSendGenerally, dispatcher) + } + + @Test + fun `GIVEN that events should not be generally sent WHEN event would be tracked THEN it is not`() = runTest(dispatcher) { + shouldSendGenerally = false + checkDefaultBrowser = true + every { settings.setAsDefaultGrowthSent } returns false + + val result = storage.shouldTrack(Event.GrowthData.SetAsDefault) + + assertFalse(result) + } + + @Test + fun `GIVEN set as default has not been sent and app is not default WHEN checked for sending THEN will not be sent`() = runTest(dispatcher) { + every { settings.setAsDefaultGrowthSent } returns false + checkDefaultBrowser = false + + val result = storage.shouldTrack(Event.GrowthData.SetAsDefault) + + assertFalse(result) + } + + @Test + fun `GIVEN set as default has not been sent and app is default WHEN checked for sending THEN will be sent`() = runTest(dispatcher) { + every { settings.setAsDefaultGrowthSent } returns false + checkDefaultBrowser = true + + val result = storage.shouldTrack(Event.GrowthData.SetAsDefault) + + assertTrue(result) + } + + @Test + fun `GIVEN set as default has been sent and app is default WHEN checked for sending THEN will be not sent`() = runTest(dispatcher) { + every { settings.setAsDefaultGrowthSent } returns true + checkDefaultBrowser = true + + val result = storage.shouldTrack(Event.GrowthData.SetAsDefault) + + assertFalse(result) + } + + @Test + fun `WHEN set as default updated THEN settings will be updated accordingly`() = runTest(dispatcher) { + val updateSlot = slot() + every { settings.setAsDefaultGrowthSent = capture(updateSlot) } returns Unit + + storage.updateSentState(Event.GrowthData.SetAsDefault) + + assertTrue(updateSlot.captured) + } +} diff --git a/nimbus.fml.yaml b/nimbus.fml.yaml index 2de6265f0f..7eccf23d8d 100644 --- a/nimbus.fml.yaml +++ b/nimbus.fml.yaml @@ -219,6 +219,18 @@ features: value: enabled: false + growth-data: + description: A feature measuring campaign growth data + variables: + enabled: + description: If true, the feature is active + type: Boolean + default: false + defaults: + - channel: release + value: + enabled: true + types: objects: MessageData: