diff --git a/app/metrics.yaml b/app/metrics.yaml
index 3cba5a1c7..ec88f0223 100644
--- a/app/metrics.yaml
+++ b/app/metrics.yaml
@@ -6702,6 +6702,7 @@ first_session:
metadata:
tags:
- Performance
+ - Attribution
network:
type: string
send_in_pings:
@@ -6726,6 +6727,7 @@ first_session:
metadata:
tags:
- Performance
+ - Attribution
adgroup:
type: string
send_in_pings:
@@ -6750,6 +6752,7 @@ first_session:
metadata:
tags:
- Telemetry
+ - Attribution
creative:
send_in_pings:
- first-session
@@ -6774,6 +6777,7 @@ first_session:
metadata:
tags:
- Performance
+ - Attribution
distribution_id:
type: string
description: |
@@ -6817,11 +6821,13 @@ first_session:
metadata:
tags:
- Performance
+ - Attribution
adjust_attribution_time:
type: timing_distribution
time_unit: millisecond
send_in_pings:
- first-session
+ - metrics
description: >
The time that it takes to derive the attribution parameters by
the Adjust SDK.
@@ -6834,6 +6840,123 @@ first_session:
notification_emails:
- android-probes@mozilla.com
expires: 124
+ metadata:
+ tags:
+ - Performance
+ - Attribution
+play_store_attribution:
+ source:
+ type: string
+ send_in_pings:
+ - first-session
+ description: |
+ The name of the utm_source that is responsible for this installation.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832069
+ data_reviews:
+ - https://github.com/mozilla-mobile/firefox-android/pull/1991#issuecomment-1545842578
+ data_sensitivity:
+ - technical
+ - interaction
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: 124
+ metadata:
+ tags:
+ - Attribution
+ medium:
+ type: string
+ send_in_pings:
+ - first-session
+ description: |
+ The name of the utm_medium that is responsible for this installation.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832069
+ data_reviews:
+ - https://github.com/mozilla-mobile/firefox-android/pull/1991#issuecomment-1545842578
+ data_sensitivity:
+ - technical
+ - interaction
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: 124
+ metadata:
+ tags:
+ - Attribution
+ campaign:
+ type: string
+ send_in_pings:
+ - first-session
+ description: |
+ The name of the utm_campaign that is responsible for this installation.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832069
+ data_reviews:
+ - https://github.com/mozilla-mobile/firefox-android/pull/1991#issuecomment-1545842578
+ data_sensitivity:
+ - technical
+ - interaction
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: 124
+ metadata:
+ tags:
+ - Attribution
+ term:
+ type: string
+ send_in_pings:
+ - first-session
+ description: |
+ The name of the utm_term that is responsible for this installation.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832069
+ data_reviews:
+ - https://github.com/mozilla-mobile/firefox-android/pull/1991#issuecomment-1545842578
+ data_sensitivity:
+ - technical
+ - interaction
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: 124
+ metadata:
+ tags:
+ - Attribution
+ content:
+ type: string
+ send_in_pings:
+ - first-session
+ description: |
+ The name of the utm_content that is responsible for this installation.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832069
+ data_reviews:
+ - https://github.com/mozilla-mobile/firefox-android/pull/1991#issuecomment-1545842578
+ data_sensitivity:
+ - technical
+ - interaction
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: 124
+ metadata:
+ tags:
+ - Attribution
+ attribution_time:
+ type: timing_distribution
+ time_unit: millisecond
+ send_in_pings:
+ - metrics
+ description: >
+ The time that it takes to derive the attribution parameters by
+ the Google Play Install Referrer library.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832069
+ data_reviews:
+ - https://github.com/mozilla-mobile/firefox-android/pull/1991#issuecomment-1545842578
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: 124
metadata:
tags:
- Performance
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 be42af1f4..05c3a6161 100644
--- a/app/src/main/java/org/mozilla/fenix/components/Analytics.kt
+++ b/app/src/main/java/org/mozilla/fenix/components/Analytics.kt
@@ -26,6 +26,7 @@ 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.InstallReferrerMetricsService
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.components.metrics.MetricsStorage
import org.mozilla.fenix.experiments.createNimbus
@@ -136,6 +137,7 @@ class Analytics(
storage = metricsStorage,
crashReporter = crashReporter,
),
+ InstallReferrerMetricsService(context),
),
isDataTelemetryEnabled = { context.settings().isTelemetryEnabled },
isMarketingDataTelemetryEnabled = { context.settings().isMarketingTelemetryEnabled },
diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsService.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsService.kt
new file mode 100644
index 000000000..36662bf97
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsService.kt
@@ -0,0 +1,201 @@
+/* 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 android.net.UrlQuerySanitizer
+import android.os.RemoteException
+import androidx.annotation.VisibleForTesting
+import com.android.installreferrer.api.InstallReferrerClient
+import com.android.installreferrer.api.InstallReferrerStateListener
+import org.mozilla.fenix.GleanMetrics.PlayStoreAttribution
+import org.mozilla.fenix.ext.settings
+import org.mozilla.fenix.utils.Settings
+
+/**
+ * A metrics service used to derive the UTM parameters with the Google Play Install Referrer library.
+ *
+ * At first startup, the [UTMParams] are derived from the install referrer URL and stored in settings.
+ */
+class InstallReferrerMetricsService(private val context: Context) : MetricsService {
+ override val type = MetricServiceType.Marketing
+
+ private var referrerClient: InstallReferrerClient? = null
+
+ override fun start() {
+ if (context.settings().utmParamsKnown) {
+ return
+ }
+
+ val timerId = PlayStoreAttribution.attributionTime.start()
+ val client = InstallReferrerClient.newBuilder(context).build()
+ referrerClient = client
+
+ client.startConnection(
+ object : InstallReferrerStateListener {
+ override fun onInstallReferrerSetupFinished(responseCode: Int) {
+ PlayStoreAttribution.attributionTime.stopAndAccumulate(timerId)
+ when (responseCode) {
+ InstallReferrerClient.InstallReferrerResponse.OK -> {
+ // Connection established.
+ try {
+ val response = client.installReferrer
+ recordInstallReferrer(context.settings(), response.installReferrer)
+ context.settings().utmParamsKnown = true
+ } catch (e: RemoteException) {
+ // NOOP.
+ // We can't do anything about this.
+ }
+ }
+
+ InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED -> {
+ // API not available on the current Play Store app.
+ context.settings().utmParamsKnown = true
+ }
+
+ InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE -> {
+ // Connection couldn't be established.
+ }
+ }
+ // End the connection, and null out the client.
+ stop()
+ }
+
+ override fun onInstallReferrerServiceDisconnected() {
+ // Try to restart the connection on the next request to
+ // Google Play by calling the startConnection() method.
+ referrerClient = null
+ }
+ },
+ )
+ }
+
+ override fun stop() {
+ referrerClient?.endConnection()
+ referrerClient = null
+ }
+
+ override fun track(event: Event) = Unit
+
+ override fun shouldTrack(event: Event): Boolean = false
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun recordInstallReferrer(settings: Settings, url: String?) {
+ val params = url?.let(UTMParams::fromUrl)
+ if (params == null || params.isEmpty()) {
+ return
+ }
+ params.intoSettings(settings)
+
+ params.apply {
+ source?.let {
+ PlayStoreAttribution.source.set(it)
+ }
+ medium?.let {
+ PlayStoreAttribution.medium.set(it)
+ }
+ campaign?.let {
+ PlayStoreAttribution.campaign.set(it)
+ }
+ content?.let {
+ PlayStoreAttribution.content.set(it)
+ }
+ term?.let {
+ PlayStoreAttribution.term.set(it)
+ }
+ }
+ }
+}
+
+/**
+ * Descriptions of utm parameters comes from
+ * https://support.google.com/analytics/answer/1033863
+ * - utm_source
+ * Identify the advertiser, site, publication, etc.
+ * that is sending traffic to your property, for example: google, newsletter4, billboard.
+ * - utm_medium
+ * The advertising or marketing medium, for example: cpc, banner, email newsletter.
+ * utm_campaign
+ * The individual campaign name, slogan, promo code, etc. for a product.
+ * - utm_term
+ * Identify paid search keywords.
+ * If you're manually tagging paid keyword campaigns, you should also use
+ * utm_term to specify the keyword.
+ * - utm_content
+ * Used to differentiate similar content, or links within the same ad.
+ * For example, if you have two call-to-action links within the same email message,
+ * you can use utm_content and set different values for each so you can tell
+ * which version is more effective.
+ */
+data class UTMParams(
+ val source: String?,
+ val medium: String?,
+ val campaign: String?,
+ val term: String?,
+ val content: String?,
+) {
+ companion object {
+ const val UTM_SOURCE = "utm_source"
+ const val UTM_MEDIUM = "utm_medium"
+ const val UTM_CAMPAIGN = "utm_campaign"
+ const val UTM_TERM = "utm_term"
+ const val UTM_CONTENT = "utm_content"
+
+ /**
+ * Derive a set of UTM parameters from a string URL.
+ */
+ fun fromUrl(url: String): UTMParams =
+ with(UrlQuerySanitizer()) {
+ allowUnregisteredParamaters = true
+ unregisteredParameterValueSanitizer = UrlQuerySanitizer.getUrlAndSpaceLegal()
+ parseUrl(url)
+ UTMParams(
+ source = getValue(UTM_SOURCE),
+ medium = getValue(UTM_MEDIUM),
+ campaign = getValue(UTM_CAMPAIGN),
+ term = getValue(UTM_TERM),
+ content = getValue(UTM_CONTENT),
+ )
+ }
+
+ /**
+ * Derive the set of UTM parameters stored in Settings.
+ */
+ fun fromSettings(settings: Settings): UTMParams =
+ with(settings) {
+ UTMParams(
+ source = utmSource,
+ medium = utmMedium,
+ campaign = utmCampaign,
+ term = utmTerm,
+ content = utmContent,
+ )
+ }
+ }
+
+ /**
+ * Persist the UTM params into Settings.
+ */
+ fun intoSettings(settings: Settings) {
+ with(settings) {
+ utmSource = source ?: ""
+ utmMedium = medium ?: ""
+ utmCampaign = campaign ?: ""
+ utmTerm = term ?: ""
+ utmContent = content ?: ""
+ }
+ }
+
+ /**
+ * Return [true] if none of the utm params are set.
+ */
+ fun isEmpty(): Boolean {
+ return source.isNullOrBlank() &&
+ medium.isNullOrBlank() &&
+ campaign.isNullOrBlank() &&
+ term.isNullOrBlank() &&
+ content.isNullOrBlank()
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/messaging/CustomAttributeProvider.kt b/app/src/main/java/org/mozilla/fenix/messaging/CustomAttributeProvider.kt
index 620ef9ce1..62a7ca270 100644
--- a/app/src/main/java/org/mozilla/fenix/messaging/CustomAttributeProvider.kt
+++ b/app/src/main/java/org/mozilla/fenix/messaging/CustomAttributeProvider.kt
@@ -8,6 +8,11 @@ import android.content.Context
import androidx.core.app.NotificationManagerCompat
import mozilla.components.service.nimbus.messaging.JexlAttributeProvider
import org.json.JSONObject
+import org.mozilla.fenix.components.metrics.UTMParams.Companion.UTM_CAMPAIGN
+import org.mozilla.fenix.components.metrics.UTMParams.Companion.UTM_CONTENT
+import org.mozilla.fenix.components.metrics.UTMParams.Companion.UTM_MEDIUM
+import org.mozilla.fenix.components.metrics.UTMParams.Companion.UTM_SOURCE
+import org.mozilla.fenix.components.metrics.UTMParams.Companion.UTM_TERM
import org.mozilla.fenix.ext.areNotificationsEnabledSafe
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.utils.BrowsersCache
@@ -63,6 +68,12 @@ object CustomAttributeProvider : JexlAttributeProvider {
"adjust_ad_group" to settings.adjustAdGroup,
"adjust_creative" to settings.adjustCreative,
+ UTM_SOURCE to settings.utmSource,
+ UTM_MEDIUM to settings.utmMedium,
+ UTM_CAMPAIGN to settings.utmCampaign,
+ UTM_TERM to settings.utmTerm,
+ UTM_CONTENT to settings.utmContent,
+
"are_notifications_enabled" to NotificationManagerCompat.from(context).areNotificationsEnabledSafe(),
),
)
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 35730582a..f7f7042c0 100644
--- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
+++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
@@ -185,6 +185,36 @@ class Settings(private val appContext: Context) : PreferencesHolder {
default = "",
)
+ var utmParamsKnown by booleanPreference(
+ appContext.getPreferenceKey(R.string.pref_key_utm_params_known),
+ default = false,
+ )
+
+ var utmSource by stringPreference(
+ appContext.getPreferenceKey(R.string.pref_key_utm_source),
+ default = "",
+ )
+
+ var utmMedium by stringPreference(
+ appContext.getPreferenceKey(R.string.pref_key_utm_medium),
+ default = "",
+ )
+
+ var utmCampaign by stringPreference(
+ appContext.getPreferenceKey(R.string.pref_key_utm_campaign),
+ default = "",
+ )
+
+ var utmTerm by stringPreference(
+ appContext.getPreferenceKey(R.string.pref_key_utm_term),
+ default = "",
+ )
+
+ var utmContent by stringPreference(
+ appContext.getPreferenceKey(R.string.pref_key_utm_content),
+ default = "",
+ )
+
var contileContextId by stringPreference(
appContext.getPreferenceKey(R.string.pref_key_contile_context_id),
default = "",
diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml
index 43564f32f..9dc9f5699 100644
--- a/app/src/main/res/values/preference_keys.xml
+++ b/app/src/main/res/values/preference_keys.xml
@@ -213,6 +213,13 @@
pref_key_adjust_adgroup
pref_key_adjust_creative
+ pref_key_utm_params_known
+ pref_key_utm_source
+ pref_key_utm_medium
+ pref_key_utm_campaign
+ pref_key_utm_term
+ pref_key_utm_content
+
pref_key_contile_context_id
diff --git a/app/src/test/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsServiceTest.kt b/app/src/test/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsServiceTest.kt
new file mode 100644
index 000000000..dad154a35
--- /dev/null
+++ b/app/src/test/java/org/mozilla/fenix/components/metrics/InstallReferrerMetricsServiceTest.kt
@@ -0,0 +1,142 @@
+/* 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 androidx.test.core.app.ApplicationProvider
+import mozilla.components.service.glean.testing.GleanTestRule
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.GleanMetrics.PlayStoreAttribution
+import org.mozilla.fenix.utils.Settings
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class InstallReferrerMetricsServiceTest {
+ val context: Context = ApplicationProvider.getApplicationContext()
+
+ @get:Rule
+ val gleanTestRule = GleanTestRule(testContext)
+
+ @Test
+ fun testUtmParamsFromUrl() {
+ assertEquals("SOURCE", UTMParams.fromUrl("https://example.com?utm_source=SOURCE").source)
+ assertEquals("MEDIUM", UTMParams.fromUrl("https://example.com?utm_medium=MEDIUM").medium)
+ assertEquals("CAMPAIGN", UTMParams.fromUrl("https://example.com?utm_campaign=CAMPAIGN").campaign)
+ assertEquals("TERM", UTMParams.fromUrl("https://example.com?utm_term=TERM").term)
+ assertEquals("CONTENT", UTMParams.fromUrl("https://example.com?utm_content=CONTENT").content)
+ }
+
+ @Test
+ fun testUtmParamsFromUrlWithSpaces() {
+ assertEquals("WITH SPACE", UTMParams.fromUrl("https://example.com?utm_source=WITH+SPACE").source)
+ assertEquals("WITH SPACE", UTMParams.fromUrl("https://example.com?utm_medium=WITH%20SPACE").medium)
+ assertEquals("WITH SPACE", UTMParams.fromUrl("https://example.com?utm_campaign=WITH SPACE").campaign)
+ }
+
+ @Test
+ fun testUtmParamsFromUrlWithMissingParams() {
+ assertNull(UTMParams.fromUrl("https://example.com?missing=").source)
+ assertEquals("", UTMParams.fromUrl("https://example.com?utm_source=").source)
+ }
+
+ @Test
+ fun testUtmParamsRoundTripThroughSettingsMinimumParams() {
+ val settings = Settings(context)
+ val expected = UTMParams(source = "", medium = "", campaign = "", content = "", term = "")
+ val observed = UTMParams.fromSettings(settings)
+
+ assertEquals(observed, expected)
+ assertTrue(observed.isEmpty())
+ }
+
+ @Test
+ fun testUtmParamsRoundTripThroughSettingsMaximumParams() {
+ val expected = UTMParams(source = "source", medium = "medium", campaign = "campaign", content = "content", term = "term")
+ val settings = Settings(context)
+
+ expected.intoSettings(settings)
+ val observed = UTMParams.fromSettings(settings)
+
+ assertEquals(observed, expected)
+
+ assertFalse(observed.isEmpty())
+ }
+
+ @Test
+ fun testInstallReferrerMetricsMinimumParams() {
+ val service = InstallReferrerMetricsService(context)
+ val settings = Settings(context)
+ service.recordInstallReferrer(settings, "https://example.com")
+
+ val expected = UTMParams(source = "", medium = "", campaign = "", content = "", term = "")
+ val observed = UTMParams.fromSettings(settings)
+ assertEquals(observed, expected)
+
+ assertNull(PlayStoreAttribution.source.testGetValue())
+ assertNull(PlayStoreAttribution.medium.testGetValue())
+ assertNull(PlayStoreAttribution.campaign.testGetValue())
+ assertNull(PlayStoreAttribution.content.testGetValue())
+ assertNull(PlayStoreAttribution.term.testGetValue())
+
+ assertTrue(observed.isEmpty())
+ }
+
+ @Test
+ fun testInstallReferrerMetricsPartial() {
+ val service = InstallReferrerMetricsService(context)
+ val settings = Settings(context)
+ service.recordInstallReferrer(settings, "https://example.com?utm_campaign=CAMPAIGN")
+
+ val expected = UTMParams(source = "", medium = "", campaign = "CAMPAIGN", content = "", term = "")
+ val observed = UTMParams.fromSettings(settings)
+ assertEquals(observed, expected)
+
+ assertNull(PlayStoreAttribution.source.testGetValue())
+ assertNull(PlayStoreAttribution.medium.testGetValue())
+ assertEquals("CAMPAIGN", PlayStoreAttribution.campaign.testGetValue())
+ assertNull(PlayStoreAttribution.content.testGetValue())
+ assertNull(PlayStoreAttribution.term.testGetValue())
+
+ assertFalse(observed.isEmpty())
+ }
+
+ @Test
+ fun testInstallReferrerMetricsMaximumParams() {
+ val service = InstallReferrerMetricsService(context)
+ val settings = Settings(context)
+ service.recordInstallReferrer(settings, "https://example.com?utm_source=SOURCE&utm_medium=MEDIUM&utm_campaign=CAMPAIGN&utm_content=CONTENT&utm_term=TERM")
+
+ val expected = UTMParams(source = "SOURCE", medium = "MEDIUM", campaign = "CAMPAIGN", content = "CONTENT", term = "TERM")
+ val observed = UTMParams.fromSettings(settings)
+ assertEquals(observed, expected)
+
+ assertEquals("SOURCE", PlayStoreAttribution.source.testGetValue())
+ assertEquals("MEDIUM", PlayStoreAttribution.medium.testGetValue())
+ assertEquals("CAMPAIGN", PlayStoreAttribution.campaign.testGetValue())
+ assertEquals("CONTENT", PlayStoreAttribution.content.testGetValue())
+ assertEquals("TERM", PlayStoreAttribution.term.testGetValue())
+
+ assertFalse(observed.isEmpty())
+ }
+
+ @Test
+ fun testInstallReferrerMetricsShouldTrack() {
+ val service = InstallReferrerMetricsService(context)
+ assertFalse(service.shouldTrack(Event.GrowthData.FirstAppOpenForDay))
+ }
+
+ @Test
+ fun testInstallReferrerMetricsType() {
+ val service = InstallReferrerMetricsService(context)
+ assertEquals(MetricServiceType.Marketing, service.type)
+ }
+}
diff --git a/app/tags.yaml b/app/tags.yaml
index 18bc213b5..d1c26e2f6 100644
--- a/app/tags.yaml
+++ b/app/tags.yaml
@@ -18,6 +18,8 @@ Accounts:
AndroidIntegration:
description: Corresponds to the [Feature:AndroidIntegration](https://github.com/mozilla-mobile/fenix/issues?q=label%3AFeature%3AAndroidIntegration)
label on GitHub.
+Attribution:
+ description: Corresponds to the attribution of an install derived from Google Play Store Install Referrer and the Adjust SDK.
Autofill:
description: Address and Credit Card autofill. Corresponds to the [Feature:Autofill](https://github.com/mozilla-mobile/fenix/issues?q=label%3AFeature%3AAutofill)
label on GitHub.