[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:
committed by
mergify[bot]
parent
bed685b995
commit
cd0e42b8e8
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
mobile/android/android-components/components/service/nimbus/.gitignore
vendored
Normal file
1
mobile/android/android-components/components/service/nimbus/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
# ProGuard rules for consumers of this library.
|
||||
|
||||
# Experiments specific protections
|
||||
-keep class mozilla.components.service.nimbus.** { *; }
|
||||
21
mobile/android/android-components/components/service/nimbus/proguard-rules.pro
vendored
Normal file
21
mobile/android/android-components/components/service/nimbus/proguard-rules.pro
vendored
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
mock-maker-inline
|
||||
@@ -0,0 +1 @@
|
||||
sdk=28
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user