[components] [SYNC-1682] Create a new Nimbus component that uses the nimbus-sdk

This adds a new Nimbus component that will act as a wrapper around the uniffi generated Kotlin code from mozilla/nimbus-sdk, as well as be a point of Glean integration, at least initially.

- Integrate service-nimbus with samples-glean for testing
- Set up default endpoint for debug and release, debug pointing at the dev Kinto endpoint and release at the production endpoint.
- Adds the documentation on how to set up the Kinto dev endpoint.
- Updates samples-glean README with Nimbus wording
This commit is contained in:
Travis Long
2020-11-10 08:40:00 -06:00
committed by mergify[bot]
parent bed685b995
commit cd0e42b8e8
21 changed files with 846 additions and 27 deletions

View File

@@ -296,6 +296,10 @@ projects:
path: components/service/glean
description: 'A client-side telemetry SDK for collecting metrics and sending them to the Mozilla telemetry service'
publish: true
service-nimbus:
path: components/service/nimbus
description: 'A client-side experiment SDK'
publish: true
service-pocket:
path: components/service/pocket
description: 'A library to communicate with the Pocket API'

View File

@@ -210,6 +210,8 @@ _Components and libraries to interact with backend services._
* 🔵 [**Location**](components/service/location/README.md) - A library for accessing Mozilla's and other location services.
* 🔴 [**Nimbus**](components/service/nimbus/README.md) - A wrapper for the Nimbus SDK.
* 🔴 [**Pocket**](components/service/pocket/README.md) - A library for communicating with the Pocket API.
## Support

View File

@@ -30,7 +30,7 @@ object Versions {
const val disklrucache = "2.0.2"
const val leakcanary = "2.4"
const val mozilla_appservices = "63.0.0"
const val mozilla_appservices = "67.0.0"
const val mozilla_glean = "33.0.4"
@@ -135,6 +135,8 @@ object Dependencies {
const val mozilla_fxa = "org.mozilla.appservices:fxaclient:${Versions.mozilla_appservices}"
const val mozilla_nimbus = "org.mozilla.appservices:nimbus:${Versions.mozilla_appservices}"
const val mozilla_glean_forUnitTests = "org.mozilla.telemetry:glean-forUnitTests:${Versions.mozilla_glean}"
const val mozilla_sync_logins = "org.mozilla.appservices:logins:${Versions.mozilla_appservices}"

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.withContext
import mozilla.appservices.places.PlacesApi
import mozilla.appservices.places.PlacesException
import mozilla.appservices.places.VisitObservation
import mozilla.appservices.places.FrecencyThresholdOption
import mozilla.components.concept.storage.HistoryAutocompleteResult
import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.concept.storage.PageObservation
@@ -102,7 +103,10 @@ open class PlacesHistoryStorage(
override suspend fun getTopFrecentSites(numItems: Int): List<TopFrecentSiteInfo> {
return withContext(readScope.coroutineContext) {
places.reader().getTopFrecentSiteInfos(numItems).map { it.into() }
places.reader().getTopFrecentSiteInfos(
numItems,
frecencyThreshold = FrecencyThresholdOption.NONE
).map { it.into() }
}
}

View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,346 @@
# [Android Components](../../../README.md) > Service > Experiments
A wrapper for the Nimbus SDK.
Contents:
- [Usage](#usage)
## Usage
### Setting up the dependency
Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
```Groovy
implementation "org.mozilla.components:service-nimbus:{latest-version}"
```
### Initializing the Experiments library
### Updating of experiments
### Checking if a user is part of an experiment
## Testing Nimbus
This section contains information about the Kinto and UI schemas needed to set up and run experiments on the "Dev" Kinto instance located at https://kinto.dev.mozaws.net.
**NOTE** The dev server instance requires LDAP authorization, but does not require connection to the internal Mozilla VPN.
## Where to add the Kinto and UI schemas
For testing purposes, create a collection with an id of `nimbus-mobile-experiments` in the `main` bucket on the [Kinto dev server](https://kinto.dev.mozaws.net/v1/admin/).
### JSON Schema
```JSON
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "http://mozilla.org/example.json",
"type": "object",
"title": "Nimbus Schema",
"description": "This is the description of the current Nimbus experiment schema, which can be found at https://github.com/mozilla/nimbus-shared",
"default": {},
"examples": [
{
"slug": "secure-gold",
"endDate": null,
"branches": [
{
"slug": "control",
"ratio": 1
},
{
"slug": "treatment",
"ratio": 1
}
],
"probeSets": [],
"startDate": null,
"application": "org.mozilla.fenix",
"bucketConfig": {
"count": 100,
"start": 0,
"total": 10000,
"namespace": "secure-gold",
"randomizationUnit": "nimbus_id"
},
"userFacingName": "Diagnostic test experiment",
"referenceBranch": "control",
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "This is a test experiment for diagnostic purposes.",
"id": "secure-gold"
}
],
"required": [
"slug",
"branches",
"application",
"bucketConfig",
"userFacingName",
"referenceBranch",
"isEnrollmentPaused",
"proposedEnrollment",
"userFacingDescription",
"id"
],
"properties": {
"slug": {
"$id": "#/properties/slug",
"type": "string",
"title": "Slug",
"description": "The slug is the unique identifier for the experiment.",
"default": "",
"examples": ["fenix-search-widget-experiment"]
},
"endDate": {
"$id": "#/properties/endDate",
"type": ["string", "null"],
"format": "date-time",
"title": "End Date",
"description": "This is the date that the experiment will end.",
"default": null,
"examples": [null]
},
"branches": {
"$id": "#/properties/branches",
"type": "array",
"title": "Branches",
"description": "Branches relate to the specific treatments to be applied for the experiment.",
"default": [],
"examples": [
[
{
"slug": "control",
"ratio": 1
},
{
"slug": "treatment",
"ratio": 1
}
]
],
"additionalItems": true,
"items": {
"$id": "#/properties/branches/items",
"anyOf": [
{
"$id": "#/properties/branches/items/anyOf/0",
"type": "object",
"title": "Branch Items",
"description": "Each branch has a slug, or name, and a ratio that weights selection into that branch",
"default": {},
"examples": [
{
"slug": "control",
"ratio": 1
}
],
"required": ["slug", "ratio"],
"properties": {
"slug": {
"$id": "#/properties/branches/items/anyOf/0/properties/slug",
"type": "string",
"title": "Branch Slug",
"description": "The branch slug is the unique name of the branch, within this experiment.",
"default": "control",
"examples": ["control"]
},
"ratio": {
"$id": "#/properties/branches/items/anyOf/0/properties/ratio",
"type": "integer",
"title": "Branch Ratio",
"description": "This is the weighting of the branch for branch selection.",
"default": 1,
"examples": [1]
}
},
"additionalProperties": true
}
]
}
},
"probeSets": {
"$id": "#/properties/probeSets",
"type": "array",
"title": "Probe Sets",
"description": "Currently unimplemented/used",
"default": [],
"examples": [[]],
"additionalItems": true,
"items": {
"$id": "#/properties/probeSets/items"
}
},
"startDate": {
"$id": "#/properties/startDate",
"type": ["string", "null"],
"format": "date-time",
"title": "Start Date",
"description": "The date that the experiment will start",
"default": null,
"examples": [null]
},
"application": {
"$id": "#/properties/application",
"type": "string",
"title": "Application",
"description": "This is the application to target",
"default": "",
"examples": [
"org.mozilla.fenix",
"org.mozilla.firefox",
"org.mozilla.ios.firefox"
]
},
"bucketConfig": {
"$id": "#/properties/bucketConfig",
"type": "object",
"title": "Bucket Configuration",
"description": "This is the configuration of the bucketing for determining the experiment sample size",
"default": {},
"examples": [
{
"count": 2000,
"start": 0,
"total": 10000,
"namespace": "performance-experiments",
"randomizationUnit": "nimbus_id"
}
],
"required": ["count", "start", "total", "namespace", "randomizationUnit"],
"properties": {
"count": {
"$id": "#/properties/bucketConfig/properties/count",
"type": "integer",
"title": "Count",
"description": "The total count of buckets to assign to be eligible to enroll in the experiment.",
"default": 0,
"examples": [2000]
},
"start": {
"$id": "#/properties/bucketConfig/properties/start",
"type": "integer",
"title": "Starting Bucket",
"description": "This is the bucket that the count of buckets will start from.",
"default": 0,
"examples": [0]
},
"total": {
"$id": "#/properties/bucketConfig/properties/total",
"type": "integer",
"title": "Total Buckets",
"description": "This is the total number of buckets to divide the population into for enrollment purposes.",
"default": 10000,
"examples": [10000]
},
"namespace": {
"$id": "#/properties/bucketConfig/properties/namespace",
"type": "string",
"title": "Namespace",
"description": "This is the bucket namespace and should always match the experiment slug",
"default": "",
"examples": ["secure-gold"]
},
"randomizationUnit": {
"$id": "#/properties/bucketConfig/properties/randomizationUnit",
"type": "string",
"title": "Randomization Unit",
"description": "This is the id to use for randomization for the purpose of bucketing. Currently only nimbus_id implemented.",
"default": "nimbus_id",
"examples": ["nimbus_id"]
}
},
"additionalProperties": true
},
"userFacingName": {
"$id": "#/properties/userFacingName",
"type": "string",
"title": "User Facing Name",
"description": "The user-facing name of the experiment.",
"default": "",
"examples": ["Diagnostic test experiment"]
},
"referenceBranch": {
"$id": "#/properties/referenceBranch",
"type": "string",
"title": "Reference Branch",
"description": "Not currently implemented, do not change default",
"default": "control",
"examples": ["control"]
},
"isEnrollmentPaused": {
"$id": "#/properties/isEnrollmentPaused",
"type": "boolean",
"title": "Enrollment Paused",
"description": "True if the enrollment is paused, false if enrollment is active.",
"default": false,
"examples": [false]
},
"proposedEnrollment": {
"$id": "#/properties/proposedEnrollment",
"type": "integer",
"title": "Proposed Enrollment",
"description": "The length in days that enrollment is proposed.",
"default": 7,
"examples": [7]
},
"userFacingDescription": {
"$id": "#/properties/userFacingDescription",
"type": "string",
"title": "User Facing Description",
"description": "This is the description of the experiment that would be presented to the user.",
"default": "",
"examples": ["This is a test experiment for diagnostic purposes."]
},
"id": {
"$id": "#/properties/id",
"type": "string",
"title": "ID",
"description": "An analog of the slug? Not sure, make this match slug...",
"default": "",
"examples": ["secure-gold"]
}
},
"additionalProperties": true
}
```
## UI Schema
```JSON
{
"ui:order": [
"slug",
"userFacingName",
"userFacingDescription",
"application",
"startDate",
"endDate",
"bucketConfig",
"branches",
"referenceBranch",
"isEnrollmentPaused",
"proposedEnrollment",
"id",
"probeSets"
],
"userFacingDescription": {
"ui:widget": "textarea"
},
"bucketConfig": {
"ui:order": ["start", "count", "total", "namespace", "randomizationUnit"]
},
"branches": {
"ui:order": ["slug", "ratio"]
}
}
```
## License
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/

View File

@@ -0,0 +1,53 @@
/* 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/. */
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion config.compileSdkVersion
defaultConfig {
minSdkVersion config.minSdkVersion
targetSdkVersion config.targetSdkVersion
}
buildTypes {
debug {
// Export experiments proguard rules even in debug since consuming apps may still
// enable proguard/R8
consumerProguardFiles 'proguard-rules-consumer.pro'
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
consumerProguardFiles 'proguard-rules-consumer.pro'
}
}
}
dependencies {
api Dependencies.mozilla_nimbus
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
implementation project(':support-base')
implementation project(':support-locale')
compileOnly project(":service-glean")
testImplementation project(":service-glean")
testImplementation Dependencies.mozilla_glean_forUnitTests
testImplementation Dependencies.androidx_work_testing
testImplementation Dependencies.mozilla_full_megazord_forUnitTests
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_mockito
testImplementation Dependencies.testing_mockwebserver
testImplementation Dependencies.testing_robolectric
testImplementation project(':support-test')
}
apply from: '../../../publish.gradle'
ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)

View File

@@ -0,0 +1,4 @@
# ProGuard rules for consumers of this library.
# Experiments specific protections
-keep class mozilla.components.service.nimbus.** { *; }

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="nimbus_dev_endpoint" translatable="false">https://kinto.dev.mozaws.net</string>
<string name="nimbus_staging_endpoint" translatable="false">https://settings.stage.mozaws.net</string>
<string name="nimbus_production_endpoint" translatable="false">https://firefox.settings.services.mozilla.com</string>
<string name="nimbus_default_endpoint" translatable="false">https://kinto.dev.mozaws.net</string>
</resources>

View File

@@ -0,0 +1,6 @@
<!-- 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/. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="mozilla.components.service.nimbus">
</manifest>

View File

@@ -0,0 +1,232 @@
/* 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.components.service.nimbus
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.PackageInfoCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import mozilla.components.service.glean.Glean
import mozilla.components.support.base.log.Log
import mozilla.components.support.locale.getLocaleTag
import org.mozilla.experiments.nimbus.AppContext
import org.mozilla.experiments.nimbus.AvailableRandomizationUnits
import org.mozilla.experiments.nimbus.EnrolledExperiment
import org.mozilla.experiments.nimbus.NimbusClient
import org.mozilla.experiments.nimbus.RemoteSettingsConfig
import java.io.File
import java.util.Locale
import java.util.concurrent.Executors
/**
* This is the main experiments API, which is exposed through the global [Nimbus] object.
*/
internal interface NimbusApi {
/**
* Get the list of currently enrolled experiments
*
* @return A list of [EnrolledExperiment]s
*/
fun getActiveExperiments(): List<EnrolledExperiment>
/**
* Get the currently enrolled branch for the given experiment
*
* @param experimentId The string experiment-id or "slug" for which to retrieve the branch
*
* @return A String representing the branch-id or "slug"
*/
fun getExperimentBranch(experimentId: String): String?
/**
* Refreshes the experiments from the endpoint
*/
fun updateExperiments()
/**
* Opt out of a specific experiment
*
* @param experimentId The string experiment-id or "slug" for which to opt out of
*/
fun optOut(experimentId: String)
/**
* Set global opt-in/out for all experiments
*
* @param active Set `true` to enable experiment enrollment or `false` to unenroll from all
*/
fun setGlobalUserParticipation(active: Boolean)
/**
* Get current global opt-in/out state for all experiments
*
* @return the current state of the global participation, or null if not initialized
*/
fun getGlobalUserParticipation(): Boolean?
}
/**
* A singleton implementation of the [NimbusApi] interface backed by the Nimbus SDK.
*/
object Nimbus : NimbusApi {
private const val LOG_TAG = "service/Nimbus"
private const val EXPERIMENT_BUCKET_NAME = "main"
private const val EXPERIMENT_COLLECTION_NAME = "nimbus-mobile-experiments"
private const val NIMBUS_DATA_DIR: String = "nimbus_data"
// Using a single threaded executor here to enforce synchronization where needed.
private val scope: CoroutineScope =
CoroutineScope(Executors.newSingleThreadExecutor().asCoroutineDispatcher())
private lateinit var nimbus: NimbusClient
private lateinit var dataDir: File
private var onExperimentUpdated: ((List<EnrolledExperiment>) -> Unit)? = null
private var isInitialized = false
/**
* Initialize the Nimbus SDK library.
*
* This should only be initialized once by the application.
*
* @param context [Context] to access application and device parameters. As we cannot enforce
* through the compiler that the context pass to the initialize function is a Application
* Context, there could potentially be a memory leak if the initializing application doesn't
* comply.
*
* @param onExperimentUpdated callback that will be executed when the list of experiments has
* been updated from the experiments endpoint and evaluated by the Nimbus-SDK. This is meant to
* be used for consuming applications to perform any actions as a result of enrollment in an
* experiment so that the application is not required to await the network request. The
* callback will be supplied with the list of active experiments (if any) for which the client
* is enrolled.
*/
fun init(
context: Context,
onExperimentUpdated: ((activeExperiments: List<EnrolledExperiment>) -> Unit)? = null
) {
// Set the name of the native library so that we use
// the appservices megazord for compiled code.
System.setProperty(
"uniffi.component.nimbus.libraryOverride",
System.getProperty("mozilla.appservices.megazord.library", "megazord")
)
this.onExperimentUpdated = onExperimentUpdated
// Do initialization off of the main thread
scope.launch {
// Build Nimbus AppContext object to pass into initialize
val experimentContext = buildExperimentContext(context)
// Build a File object to represent the data directory for Nimbus data
dataDir = File(context.applicationInfo.dataDir, NIMBUS_DATA_DIR)
// Initialize Nimbus
nimbus = NimbusClient(
experimentContext,
dataDir.path,
RemoteSettingsConfig(
serverUrl = context.resources.getString(R.string.nimbus_default_endpoint),
bucketName = EXPERIMENT_BUCKET_NAME,
collectionName = EXPERIMENT_COLLECTION_NAME
),
// The "dummy" field here is required for obscure reasons when generating code on desktop,
// so we just automatically set it to a dummy value.
AvailableRandomizationUnits(clientId = null, dummy = 0)
)
// Get experiments
val activeExperiments = nimbus.getActiveExperiments()
// Record enrollments in telemetry
recordExperimentTelemetry(activeExperiments)
isInitialized = true
// Invoke the callback with the list of active experiments for the consuming app to
// process.
onExperimentUpdated?.invoke(activeExperiments)
}
}
override fun getActiveExperiments(): List<EnrolledExperiment> =
if (isInitialized) { nimbus.getActiveExperiments() } else { emptyList() }
override fun getExperimentBranch(experimentId: String): String? =
if (isInitialized) { nimbus.getExperimentBranch(experimentId) } else { null }
override fun updateExperiments() {
if (!isInitialized) return
nimbus.updateExperiments()
onExperimentUpdated?.invoke(nimbus.getActiveExperiments())
}
override fun optOut(experimentId: String) {
if (!isInitialized) return
nimbus.optOut(experimentId)
}
override fun setGlobalUserParticipation(active: Boolean) {
if (!isInitialized) return
nimbus.setGlobalUserParticipation(active)
}
override fun getGlobalUserParticipation(): Boolean? {
if (!isInitialized) return null
return nimbus.getGlobalUserParticipation()
}
// This function shouldn't be exposed to the public API, but is meant for testing purposes to
// force an experiment/branch enrollment.
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
internal fun optInWithBranch(experiment: String, branch: String) {
if (!isInitialized) return
nimbus.optInWithBranch(experiment, branch)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun recordExperimentTelemetry(experiments: List<EnrolledExperiment>) {
// Call Glean.setExperimentActive() for each active experiment.
experiments.forEach {
// For now, we will just record the experiment id and the branch id. Once we can call
// Glean from Rust, this will move to the nimbus-sdk Rust core.
Glean.setExperimentActive(it.slug, it.branchSlug)
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun buildExperimentContext(context: Context): AppContext {
val packageInfo: PackageInfo? = try {
context.packageManager.getPackageInfo(
context.packageName, 0
)
} catch (e: PackageManager.NameNotFoundException) {
Log.log(Log.Priority.ERROR,
LOG_TAG,
message = "Could not retrieve package info for appBuild and appVersion"
)
null
}
return AppContext(
appId = context.packageName,
androidSdkVersion = Build.VERSION.SDK_INT.toString(),
appBuild = packageInfo?.let { PackageInfoCompat.getLongVersionCode(it).toString() },
appVersion = packageInfo?.versionName,
architecture = Build.SUPPORTED_ABIS[0],
debugTag = null,
deviceManufacturer = Build.MANUFACTURER,
deviceModel = Build.MODEL,
locale = Locale.getDefault().getLocaleTag(),
os = "Android",
osVersion = Build.VERSION.RELEASE)
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="nimbus_dev_endpoint" translatable="false">https://kinto.dev.mozaws.net</string>
<string name="nimbus_staging_endpoint" translatable="false">https://settings.stage.mozaws.net</string>
<string name="nimbus_production_endpoint" translatable="false">https://firefox.settings.services.mozilla.com</string>
<string name="nimbus_default_endpoint" translatable="false">https://firefox.settings.services.mozilla.com</string>
</resources>

View File

@@ -0,0 +1,61 @@
/* 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.components.service.nimbus
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Response
import mozilla.components.service.glean.Glean
import mozilla.components.service.glean.config.Configuration
import mozilla.components.service.glean.net.ConceptFetchHttpUploader
import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mozilla.experiments.nimbus.EnrolledExperiment
@RunWith(AndroidJUnit4::class)
class NimbusTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
@get:Rule
val gleanRule = GleanTestRule(context)
@Test
fun `recordExperimentTelemetry correctly records the experiment and branch`() {
// Glean needs to be initialized for the experiments API to accept enrollment events, so we
// init it with a mock client so we don't upload anything.
val mockClient: Client = mock()
`when`(mockClient.fetch(any())).thenReturn(
Response("URL", 200, mock(), mock()))
Glean.initialize(
context,
true,
Configuration(
httpClient = ConceptFetchHttpUploader(lazy { mockClient })
)
)
// Create a list of experiments to test the telemetry enrollment recording
val enrolledExperiments = listOf(EnrolledExperiment(
slug = "test-experiment",
branchSlug = "test-branch",
userFacingDescription = "A test experiment for testing experiments",
userFacingName = "Test Experiment"))
Nimbus.recordExperimentTelemetry(experiments = enrolledExperiments)
assertTrue(Glean.testIsExperimentActive("test-experiment"))
val experimentData = Glean.testGetExperimentData("test-experiment")
assertEquals("test-branch", experimentData.branch)
}
}

View File

@@ -20,3 +20,38 @@ fun String.toLocale(): Locale {
Locale(this)
}
}
/**
* Gets a gecko-compatible locale string (e.g. "es-ES" instead of Java [Locale]
* "es_ES") for the default locale.
* If the locale can't be determined on the system, the value is "und",
* to indicate "undetermined".
*
* This method approximates the API21 method [Locale.toLanguageTag].
*
* @return a locale string that supports custom injected locale/languages.
*/
fun Locale.getLocaleTag(): String {
// Thanks to toLanguageTag() being introduced in API21, we could have
// simply returned `locale.toLanguageTag();` from this function. However
// what kind of languages the Android build supports is up to the manufacturer
// and our apps usually support translations for more rare languages, through
// our custom locale injector. For this reason, we can't use `toLanguageTag`
// and must try to replicate its logic ourselves.
// `locale.language` can, but should never be, an empty string.
// Modernize certain language codes.
val language = when (this.language) {
"iw" -> "he"
"in" -> "id"
"ji" -> "yi"
else -> this.language
}
val country = this.country // Can be an empty string.
return when {
language.isEmpty() -> "und"
country.isEmpty() -> language
else -> "$language-$country"
}
}

View File

@@ -11,7 +11,7 @@ The main concepts shown in the sample app are:
* Usage of the `metrics.yaml` file.
* Integration between Glean and the application's build process to generate the specific metrics API.
* Usage of the generated specific metrics API.
* Integration of the Experiments library.
* Integration of the Nimbus experimentation library.
## License

View File

@@ -48,8 +48,10 @@ android {
dependencies {
implementation project(':service-glean')
implementation project(':service-experiments')
implementation project(':service-nimbus')
implementation project(':support-base')
implementation project(':support-rusthttp')
implementation project(':support-rustlog')
implementation project(':lib-fetch-httpurlconnection')
implementation Dependencies.kotlin_stdlib

View File

@@ -7,13 +7,14 @@ package org.mozilla.samples.glean
import android.app.Application
import android.content.Intent
import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient
import mozilla.components.service.experiments.Configuration as ExperimentsConfig
import mozilla.components.service.glean.Glean
import mozilla.components.service.glean.config.Configuration
import mozilla.components.service.glean.net.ConceptFetchHttpUploader
import mozilla.components.service.experiments.Experiments
import mozilla.components.service.nimbus.Nimbus
import mozilla.components.support.base.log.Log
import mozilla.components.support.base.log.sink.AndroidLogSink
import mozilla.components.support.rusthttp.RustHttpConfig
import mozilla.components.support.rustlog.RustLog
import org.mozilla.samples.glean.GleanMetrics.Basic
import org.mozilla.samples.glean.GleanMetrics.Test
import org.mozilla.samples.glean.GleanMetrics.Custom
@@ -37,14 +38,9 @@ class GleanApplication : Application() {
val config = Configuration(httpClient = httpClient)
Glean.initialize(applicationContext, uploadEnabled = true, configuration = config)
// Initialize the Experiments library and pass in the callback that will generate a
// broadcast Intent to signal the application that experiments have been updated. This is
// only relevant to the experiments library, aside from recording the experiment in Glean.
Experiments.initialize(applicationContext, ExperimentsConfig(httpClient = client)) {
val intent = Intent()
intent.action = "org.mozilla.samples.glean.experiments.updated"
sendBroadcast(intent)
}
/** Begin Nimbus component specific code. Note: this is not relevant to Glean */
initNimbus()
/** End Nimbus specific code. */
Test.timespan.start()
@@ -53,4 +49,19 @@ class GleanApplication : Application() {
// Set a sample value for a metric.
Basic.os.set("Android")
}
/**
* Initialize the Nimbus experiments library and pass in the callback that will generate a
* broadcast Intent to signal the application that experiments have been updated. This is
* only relevant to the Nimbus library, aside from recording the experiment in Glean.
*/
private fun initNimbus() {
RustLog.enable()
RustHttpConfig.setClient(lazy { HttpURLConnectionClient() })
Nimbus.init(this) {
val intent = Intent()
intent.action = "org.mozilla.samples.glean.experiments.updated"
sendBroadcast(intent)
}
}
}

View File

@@ -9,7 +9,8 @@ import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
import mozilla.components.service.experiments.Experiments
import mozilla.components.service.nimbus.Nimbus
import org.mozilla.experiments.nimbus.EnrolledExperiment
import org.mozilla.samples.glean.GleanMetrics.Test
import org.mozilla.samples.glean.GleanMetrics.BrowserEngagement
import org.mozilla.samples.glean.library.SamplesGleanLibrary
@@ -19,9 +20,10 @@ import org.mozilla.samples.glean.library.SamplesGleanLibrary
*/
open class MainActivity : AppCompatActivity(), ExperimentUpdateReceiver.ExperimentUpdateListener {
// This BroadcastReceiver is not relevant to the Glean SDK, but is relevant to the experiments
// library.
// This BroadcastReceiver and list are not relevant to the Glean SDK, but is relevant to the
// Nimbus experiments library.
private var experimentUpdateReceiver: ExperimentUpdateReceiver? = null
private var activeExperiments: List<EnrolledExperiment> = listOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -59,8 +61,23 @@ open class MainActivity : AppCompatActivity(), ExperimentUpdateReceiver.Experime
SamplesGleanLibrary.recordMetric()
SamplesGleanLibrary.recordExperiment()
// The following is not relevant to the Glean SDK, but to the experiments library.
// The following is not relevant to the Glean SDK, but to the Nimbus experiments library.
// Set up the ExperimentUpdateReceiver to receive experiment updated Intents.
setupNimbusExperiments()
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(experimentUpdateReceiver)
}
/** Begin Nimbus component specific functions */
/**
* This sets up the update receiver and sets the onClickListener for the "Update Experiments"
* button. This is not relevant to the Glean SDK, but to the Nimbus experiments library.
*/
private fun setupNimbusExperiments() {
experimentUpdateReceiver = ExperimentUpdateReceiver(this)
val filter = IntentFilter()
filter.addAction("org.mozilla.samples.glean.experiments.updated")
@@ -72,31 +89,33 @@ open class MainActivity : AppCompatActivity(), ExperimentUpdateReceiver.Experime
}
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(experimentUpdateReceiver)
}
/**
* This function will be called by the ExperimentUpdateListener interface when the experiments
* are updated. This is not relevant to the Glean SDK, but to the experiments library.
* are updated. This is not relevant to the Glean SDK, but to the Nimbus experiments library.
*/
override fun onExperimentsUpdated() {
textViewExperimentStatus.setBackgroundColor(Color.WHITE)
textViewExperimentStatus.text = getString(R.string.experiment_not_active)
Experiments.withExperiment("test-color") {
val color = when (it) {
activeExperiments = Nimbus.getActiveExperiments()
if (activeExperiments.any { it.slug == "test-color" }) {
val color = when (Nimbus.getExperimentBranch("test-color")) {
"blue" -> Color.BLUE
"red" -> Color.RED
"control" -> Color.DKGRAY
else -> Color.WHITE
}
// Dispatch the UI work back to the appropriate thread
this@MainActivity.runOnUiThread {
textViewExperimentStatus.setBackgroundColor(color)
textViewExperimentStatus.text = getString(R.string.experiment_active_branch, it)
textViewExperimentStatus.text = getString(
R.string.experiment_active_branch,
"Experiment Branch: ${Nimbus.getExperimentBranch("test-color")}")
}
}
}
/** End Nimbus component functions */
}