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.