diff --git a/app/build.gradle b/app/build.gradle index 78dca2815..e06320f3a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,6 +12,7 @@ plugins { id 'kotlin-android' id 'io.sentry.android.gradle' id 'jacoco' + id "com.jetbrains.python.envs" version "0.0.26" } android { @@ -103,6 +104,7 @@ dependencies { implementation "org.mozilla.components:service-sync-logins:${rootProject.ext.android_components_version}" implementation "org.mozilla.components:service-firefox-accounts:${rootProject.ext.android_components_version}" implementation "org.mozilla.components:service-telemetry:${rootProject.ext.android_components_version}" + implementation "org.mozilla.components:service-glean:${rootProject.ext.android_components_version}" implementation "org.mozilla.components:lib-dataprotect:${rootProject.ext.android_components_version}" implementation "org.mozilla.components:lib-fetch-httpurlconnection:${rootProject.ext.android_components_version}" implementation "org.mozilla.components:lib-publicsuffixlist:${rootProject.ext.android_components_version}" @@ -261,5 +263,10 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions.allWarningsAsErrors = true } +// Generate markdown docs for the collected metrics. +ext.gleanGenerateMarkdownDocs = true +ext.gleanDocsDirectory = "$rootDir/docs/glean" +apply from: 'https://github.com/mozilla-mobile/android-components/raw/v' + rootProject.ext.android_components_version + '/components/service/glean/scripts/sdk_generator.gradle' + // Internal, but stable and convenient. import org.gradle.api.internal.artifacts.DefaultModuleIdentifier diff --git a/app/src/main/java/mozilla/lockbox/LockboxApplication.kt b/app/src/main/java/mozilla/lockbox/LockboxApplication.kt index 4ded38a0b..c5390ea3a 100644 --- a/app/src/main/java/mozilla/lockbox/LockboxApplication.kt +++ b/app/src/main/java/mozilla/lockbox/LockboxApplication.kt @@ -26,6 +26,7 @@ import mozilla.lockbox.store.ClipboardStore import mozilla.lockbox.store.ContextStore import mozilla.lockbox.store.DataStore import mozilla.lockbox.store.FingerprintStore +import mozilla.lockbox.store.GleanTelemetryStore import mozilla.lockbox.store.LockedStore import mozilla.lockbox.store.NetworkStore import mozilla.lockbox.store.SentryStore @@ -34,6 +35,7 @@ import mozilla.lockbox.store.TelemetryStore import mozilla.lockbox.support.AdjustSupport import mozilla.lockbox.support.TimingSupport import mozilla.lockbox.support.Constant +import mozilla.lockbox.support.FeatureFlags import mozilla.lockbox.support.FxASyncDataStoreSupport import mozilla.lockbox.support.PublicSuffixSupport import mozilla.lockbox.support.SecurePreferences @@ -98,7 +100,7 @@ open class LockboxApplication : Application() { } private fun injectContext() { - val contextStoreList: List = listOf( + val contextStoreList: List = listOfNotNull( FingerprintStore.shared, SettingStore.shared, SecurePreferences.shared, @@ -106,7 +108,8 @@ open class LockboxApplication : Application() { NetworkStore.shared, TimingSupport.shared, AccountStore.shared, - TelemetryStore.shared, + if (FeatureFlags.INCLUDE_DEPRECATED_TELEMETRY) TelemetryStore.shared else null, + GleanTelemetryStore.shared, SentryStore.shared, PublicSuffixSupport.shared ) diff --git a/app/src/main/java/mozilla/lockbox/LockboxAutofillService.kt b/app/src/main/java/mozilla/lockbox/LockboxAutofillService.kt index ff546088e..e41f6e199 100644 --- a/app/src/main/java/mozilla/lockbox/LockboxAutofillService.kt +++ b/app/src/main/java/mozilla/lockbox/LockboxAutofillService.kt @@ -31,9 +31,12 @@ import mozilla.lockbox.flux.Dispatcher import mozilla.lockbox.store.AccountStore import mozilla.lockbox.store.AutofillStore import mozilla.lockbox.store.DataStore +import mozilla.lockbox.store.GleanTelemetryStore +import mozilla.lockbox.store.SettingStore import mozilla.lockbox.store.TelemetryStore import mozilla.lockbox.support.FxASyncDataStoreSupport import mozilla.lockbox.support.Constant +import mozilla.lockbox.support.FeatureFlags import mozilla.lockbox.support.PublicSuffixSupport import mozilla.lockbox.support.SecurePreferences import mozilla.lockbox.support.asOptional @@ -44,9 +47,10 @@ import mozilla.lockbox.support.isDebug class LockboxAutofillService( private val accountStore: AccountStore = AccountStore.shared, private val dataStore: DataStore = DataStore.shared, + private val settingStore: SettingStore = SettingStore.shared, private val securePreferences: SecurePreferences = SecurePreferences.shared, private val fxaSupport: FxASyncDataStoreSupport = FxASyncDataStoreSupport.shared, - private val telemetryStore: TelemetryStore = TelemetryStore.shared, + private val gleanTelemetryStore: GleanTelemetryStore = GleanTelemetryStore.shared, private val autofillStore: AutofillStore = AutofillStore.shared, val dispatcher: Dispatcher = Dispatcher.shared ) : AutofillService() { @@ -148,8 +152,10 @@ class LockboxAutofillService( private fun intializeService() { isRunning = true - val contextInjectables = arrayOf( - telemetryStore, + val contextInjectables = listOfNotNull( + settingStore, + gleanTelemetryStore, + if (FeatureFlags.INCLUDE_DEPRECATED_TELEMETRY) TelemetryStore.shared else null, securePreferences, accountStore, fxaSupport diff --git a/app/src/main/java/mozilla/lockbox/store/GleanTelemetryStore.kt b/app/src/main/java/mozilla/lockbox/store/GleanTelemetryStore.kt new file mode 100644 index 000000000..88dbec7b2 --- /dev/null +++ b/app/src/main/java/mozilla/lockbox/store/GleanTelemetryStore.kt @@ -0,0 +1,47 @@ +/* + * 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 mozilla.lockbox.store + +import android.content.Context +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import mozilla.components.service.glean.Glean +import mozilla.components.service.glean.config.Configuration +import mozilla.lockbox.BuildConfig + +open class GleanWrapper { + open var uploadEnabled: Boolean + get() = Glean.getUploadEnabled() + set(value) { + Glean.setUploadEnabled(value) + } + + open fun initialize(context: Context, channel: String) { + Glean.initialize(context, Configuration(channel = channel)) + } +} + +class GleanTelemetryStore( + private val gleanWrapper: GleanWrapper = GleanWrapper(), + private val settingStore: SettingStore = SettingStore.shared +) : ContextStore { + + companion object { + val shared by lazy { GleanTelemetryStore() } + } + + private val compositeDisposable = CompositeDisposable() + + override fun injectContext(context: Context) { + settingStore.sendUsageData + .subscribe { + gleanWrapper.uploadEnabled = it + } + .addTo(compositeDisposable) + gleanWrapper.initialize(context, BuildConfig.BUILD_TYPE) + } +} diff --git a/app/src/main/java/mozilla/lockbox/store/TelemetryStore.kt b/app/src/main/java/mozilla/lockbox/store/TelemetryStore.kt index 2ec15a8da..56e6aeee9 100644 --- a/app/src/main/java/mozilla/lockbox/store/TelemetryStore.kt +++ b/app/src/main/java/mozilla/lockbox/store/TelemetryStore.kt @@ -12,6 +12,7 @@ package mozilla.lockbox.store import android.content.Context +import androidx.annotation.VisibleForTesting import io.reactivex.disposables.CompositeDisposable import io.reactivex.rxkotlin.addTo import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient @@ -91,7 +92,8 @@ open class TelemetryStore( internal val compositeDisposable = CompositeDisposable() - init { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun register() { dispatcher.register .filterByType(TelemetryAction::class.java) .subscribe { @@ -108,6 +110,7 @@ open class TelemetryStore( } override fun injectContext(context: Context) { + register() wrapper.lateinitContext(context) settingStore .sendUsageData diff --git a/app/src/main/java/mozilla/lockbox/support/FeatureFlags.kt b/app/src/main/java/mozilla/lockbox/support/FeatureFlags.kt index 262e26549..193820f6b 100644 --- a/app/src/main/java/mozilla/lockbox/support/FeatureFlags.kt +++ b/app/src/main/java/mozilla/lockbox/support/FeatureFlags.kt @@ -47,4 +47,20 @@ object FeatureFlags { isRelease -> true else -> true } + + /** + * Include the legacy telemetry service. As part of our migration to the Glean, we will want + * to keep the legacy service running in parallel. + * + * If true, the legacy telemetry-service is used. + * If false, the legacy telemetry-service is not. + * + * Either way, the user can opt out of this from the settings. + */ + val INCLUDE_DEPRECATED_TELEMETRY = when { + isDebug -> true + isTesting -> true + isRelease -> true + else -> true + } } diff --git a/app/src/test/java/mozilla/lockbox/store/GleanTelemetryStoreTest.kt b/app/src/test/java/mozilla/lockbox/store/GleanTelemetryStoreTest.kt new file mode 100644 index 000000000..bdb7f67d0 --- /dev/null +++ b/app/src/test/java/mozilla/lockbox/store/GleanTelemetryStoreTest.kt @@ -0,0 +1,136 @@ +/* + * 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 mozilla.lockbox.store + +import android.content.Context +import android.content.SharedPreferences +import android.view.autofill.AutofillManager +import androidx.test.core.app.ApplicationProvider +import io.reactivex.subjects.ReplaySubject +import mozilla.lockbox.flux.Dispatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.powermock.api.mockito.PowerMockito.`when` +import org.powermock.api.mockito.PowerMockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(packageName = "mozilla.lockbox") +class GleanTelemetryStoreTest { + @Mock + private val settingStore = mock(SettingStore::class.java) + private val sendUsageDataStub = ReplaySubject.createWithSize(1) + + @Mock + val telemetryWrapper = object : GleanWrapper() { + override var uploadEnabled = false + var channel: String = "" + override fun initialize(context: Context, channel: String) { + this.channel = channel + } + } + + val dispatcher = Dispatcher() + + val context: Context = ApplicationProvider.getApplicationContext() + + lateinit var subject: GleanTelemetryStore + + @Before + fun setUp() { + sendUsageDataStub.onNext(true) + `when`(settingStore.sendUsageData).thenReturn(sendUsageDataStub) + subject = GleanTelemetryStore(telemetryWrapper, settingStore) + } + + @Test + fun `when context is injected, verify glean is initialized`() { + subject.injectContext(context) + assertTrue(telemetryWrapper.uploadEnabled) + } + + @Test + fun `when sendUsageData is toggled, verify glean is turned off`() { + subject.injectContext(context) + sendUsageDataStub.onNext(false) + assertFalse(telemetryWrapper.uploadEnabled) + + sendUsageDataStub.onNext(true) + assertTrue(telemetryWrapper.uploadEnabled) + } + + @Test + fun `ensure upload enabled is called before initialize`() { + // We spend quite a lot of effort here to convince ourselves that the user's preference + // for sending usage data is respected before initializing glean. + // If this test fails, then either we're losing ping data or we're uploading ping data + // when the user has explicitly said not to. + + // mock all this out for the setting store, so we can use the Rx machinery it uses. + val context = mock(Context::class.java) + `when`(context.getSystemService(eq(AutofillManager::class.java))).thenReturn(mock(AutofillManager::class.java)) + val prefs = mock(SharedPreferences::class.java) + `when`(prefs.contains(eq(SettingStore.Keys.DEVICE_SECURITY_PRESENT))).thenReturn(true) + `when`(prefs.contains(eq(SettingStore.Keys.SEND_USAGE_DATA))).thenReturn(true) + `when`(context.getSharedPreferences(anyString(), anyInt())).thenReturn(prefs) + + val fingerprintStore = mock(FingerprintStore::class.java) + `when`(fingerprintStore.isDeviceSecure).thenReturn(true) + + class DummyGleanWrapper : GleanWrapper() { + var initializationOrder = arrayListOf() + + override var uploadEnabled: Boolean = false + set(value) { + initializationOrder.add("uploadEnabled") + field = value + } + + override fun initialize(context: Context, channel: String) { + initializationOrder.add("initialize") + } + } + + fun testWithPref(enabled: Boolean) { + `when`( + prefs.getBoolean( + eq(SettingStore.Keys.SEND_USAGE_DATA), + anyBoolean() + ) + ).thenReturn(enabled) + + val telemetryWrapper = DummyGleanWrapper() + + val settingStore = SettingStore(fingerprintStore = fingerprintStore) + val gleanTelemetryStore = GleanTelemetryStore(telemetryWrapper, settingStore) + + // These should appear in the same order as they do in `injectContext` in + // `LockwiseApplication` and `initializeService` in `LockwiseAutofillService`. + settingStore.injectContext(context) + gleanTelemetryStore.injectContext(context) + + assertEquals( + "uploadEnabled, initialize", + telemetryWrapper.initializationOrder.joinToString(", ") + ) + assertEquals(enabled, telemetryWrapper.uploadEnabled) + } + + testWithPref(false) + testWithPref(true) + } +} diff --git a/app/src/test/java/mozilla/lockbox/store/TelemetryStoreTest.kt b/app/src/test/java/mozilla/lockbox/store/TelemetryStoreTest.kt index 409c5fbfc..98c736088 100644 --- a/app/src/test/java/mozilla/lockbox/store/TelemetryStoreTest.kt +++ b/app/src/test/java/mozilla/lockbox/store/TelemetryStoreTest.kt @@ -88,6 +88,7 @@ class TelemetryStoreTest : DisposingTest() { val uploadObserver = createTestObserver() wrapper.eventsSubject.subscribe(eventsObserver) wrapper.uploadSubject.subscribe(uploadObserver) + subject.register() var action: TelemetryAction = LifecycleAction.Foreground dispatcher.dispatch(action) diff --git a/docs/metrics.md b/docs/metrics.md index 5c1fdae95..e5271220a 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -5,8 +5,9 @@ _Last Updated: Feb 4, 2019_ - [Analysis](#analysis) -- [Collection](#collection) +- [Collection](#collection-legacy) - [List of Implemented Events](#list-of-implemented-events) +- [Mozilla Glean SDK](#mozilla-glean-sdk) - [Adjust SDK](#adjust-sdk) - [References](#references) @@ -50,7 +51,7 @@ In service to validating the above hypothesis, we plan on answering these specif In addition to answering the above questions that directly concern actions in the app, we will also analyze telemetry emitted from the password manager that exists in the the Firefox desktop browser. These analyses will primarily examine whether users of Lockwise start active curation of their credentials in the desktop browser (Lockwise users will not be able to edit credentials directly from the app). -## Collection +## Collection (legacy) *Note: There is currently a new Mozilla mobile telemetry SDK under development, however it will not ship prior to the Lockwise for Android app. Once the new SDK ships we will evaluate whether or not to tear out the old implementation and replace it with the new SDK.* @@ -179,6 +180,15 @@ https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/c * `value`: null * `extras`: null +## Mozilla Glean SDK + +Lockwise for Android uses the [Glean SDK](https://mozilla.github.io/glean/book/index.html) to collect telemetry. The Glean SDK provides a handful of [pings and metrics out of the box](https://mozilla.github.io/glean/book/user/pings/index.html). The data review for using the Glean SDK is available at [this link](https://github.com/mozilla-lockwise/lockwise-android/pull/1085#issuecomment-558767676). + +Lockwise for Android also uses the following Glean-enabled components of [Android Components](https://github.com/mozilla-mobile/android-components/), which are sending telemetry: + +|Name|Description|Collected metrics|Data review| +|---|---|---|---| +|[Firefox accounts](https://github.com/mozilla-mobile/android-components/tree/master/components/service/firefox-accounts)|A library for integrating with Firefox Accounts.| [docs](https://github.com/mozilla-mobile/android-components/blob/master/components/support/sync-telemetry/docs/metrics.md)| [review](https://github.com/mozilla-lockwise/lockwise-android/pull/1085#issuecomment-558767676) | ## Adjust SDK @@ -186,7 +196,9 @@ The app also includes a version of the [adjust SDK](https://github.com/adjust/an ## References -[Library used to collect and send telemetry on Android](https://github.com/mozilla-mobile/android-components/blob/master/components/service/telemetry/README.md) +[Glean SDK repository, used to collect and send telemetry](https://github.com/mozilla/glean/) + +[Legacy library used to collect and send telemetry on Android](https://github.com/mozilla-mobile/android-components/blob/master/components/service/telemetry/README.md) [Description of the "Core" ping](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/core-ping.html)