Skip to content

Commit 27384f9

Browse files
Bug 2037747 - Add per-feature update notifications to the Android Nimbus client
1 parent 9335926 commit 27384f9

5 files changed

Lines changed: 455 additions & 20 deletions

File tree

components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ import androidx.core.content.pm.PackageInfoCompat
2020
import kotlinx.coroutines.CancellationException
2121
import kotlinx.coroutines.CoroutineScope
2222
import kotlinx.coroutines.Deferred
23+
import kotlinx.coroutines.GlobalScope
2324
import kotlinx.coroutines.Job
2425
import kotlinx.coroutines.NonCancellable
2526
import kotlinx.coroutines.async
2627
import kotlinx.coroutines.launch
27-
import kotlinx.coroutines.runBlocking
2828
import kotlinx.coroutines.withContext
2929
import mozilla.appservices.remotesettings.RemoteSettingsService
3030
import mozilla.telemetry.glean.Glean
@@ -41,6 +41,7 @@ import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent
4141
import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEventType
4242
import org.mozilla.experiments.nimbus.internal.EnrollmentStatusExtraDef
4343
import org.mozilla.experiments.nimbus.internal.FeatureExposureExtraDef
44+
import org.mozilla.experiments.nimbus.internal.FeatureUpdateDispatcher
4445
import org.mozilla.experiments.nimbus.internal.GeckoPrefHandler
4546
import org.mozilla.experiments.nimbus.internal.GeckoPrefState
4647
import org.mozilla.experiments.nimbus.internal.MalformedFeatureConfigExtraDef
@@ -86,6 +87,8 @@ open class Nimbus(
8687

8788
private val logger = delegate.logger
8889

90+
private val updateDispatcher = FeatureUpdateDispatcher()
91+
8992
private val metricsHandler = object : MetricsHandler {
9093
override fun recordDatabaseLoad(event: DatabaseLoadExtraDef) {
9194
NimbusEvents.databaseLoad.record(
@@ -205,6 +208,8 @@ open class Nimbus(
205208
)
206209
}
207210

211+
override fun getFeatureUpdateDispatcher(): FeatureUpdateDispatcher? = updateDispatcher
212+
208213
// This is currently not available from the main thread.
209214
// see https://jira.mozilla.com/browse/SDK-191
210215
@WorkerThread
@@ -279,7 +284,7 @@ open class Nimbus(
279284
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
280285
internal fun initializeOnThisThread() = withCatchAll("initialize") {
281286
nimbusClient.initialize()
282-
postEnrolmentCalculation()
287+
postEnrolmentCalculation(true)
283288
}
284289

285290
override fun fetchExperiments() {
@@ -342,9 +347,15 @@ open class Nimbus(
342347
events = nimbusClient.applyPendingExperiments()
343348
}
344349
NimbusHealth.applyPendingExperimentsTime.accumulateSingleSample(time)
350+
351+
// SAFETY: events is only null at declaration time and is
352+
// immediately assigned a non-null value inside the
353+
// measureTimeMillis lambda.
345354
recordExperimentTelemetryEvents(events!!)
355+
346356
// Get the experiments to record in telemetry
347-
postEnrolmentCalculation()
357+
postEnrolmentCalculation(false)
358+
updateDispatcher.notifyChanged(events)
348359
} catch (e: NimbusException.InvalidExperimentFormat) {
349360
reportError("Invalid experiment format", e)
350361
}
@@ -379,11 +390,22 @@ open class Nimbus(
379390
}
380391

381392
@WorkerThread
382-
private fun postEnrolmentCalculation() {
383-
nimbusClient.getActiveExperiments().let {
384-
recordExperimentTelemetry(it)
393+
private fun postEnrolmentCalculation(initial: Boolean) {
394+
nimbusClient.getActiveExperiments().also { experiments ->
395+
recordExperimentTelemetry(experiments)
385396
updateObserver { observer ->
386-
observer.onUpdatesApplied(it)
397+
observer.onUpdatesApplied(experiments)
398+
}
399+
400+
if (initial) {
401+
val featureIds = mutableSetOf<String>()
402+
for (experiment in experiments) {
403+
for (featureId in experiment.featureIds) {
404+
featureIds.add(featureId)
405+
}
406+
}
407+
408+
updateDispatcher.notifyFeatures(featureIds)
387409
}
388410
}
389411
}
@@ -431,7 +453,8 @@ open class Nimbus(
431453
val enrolmentChanges = nimbusClient.setExperimentParticipation(active)
432454
if (enrolmentChanges.isNotEmpty()) {
433455
recordExperimentTelemetryEvents(enrolmentChanges)
434-
postEnrolmentCalculation()
456+
postEnrolmentCalculation(false)
457+
updateDispatcher.notifyChanged(enrolmentChanges)
435458
}
436459
}
437460

@@ -442,7 +465,8 @@ open class Nimbus(
442465
val enrolmentChanges = nimbusClient.setRolloutParticipation(active)
443466
if (enrolmentChanges.isNotEmpty()) {
444467
recordExperimentTelemetryEvents(enrolmentChanges)
445-
postEnrolmentCalculation()
468+
postEnrolmentCalculation(false)
469+
updateDispatcher.notifyChanged(enrolmentChanges)
446470
}
447471
}
448472

@@ -495,24 +519,35 @@ open class Nimbus(
495519
nimbusClient.optOut(experimentId).also(::recordExperimentTelemetryEvents)
496520
}
497521

522+
@AnyThread
498523
override fun resetTelemetryIdentifiers() {
499524
dbScope.launch {
500-
withCatchAll("resetTelemetryIdentifiers") {
501-
nimbusClient.resetTelemetryIdentifiers().also { enrollmentChangeEvents ->
502-
recordExperimentTelemetryEvents(enrollmentChangeEvents)
503-
}
504-
}
525+
resetTelemetryIdentifiersOnThisThread()
505526
}
506527
}
507528

529+
@WorkerThread
530+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
531+
internal fun resetTelemetryIdentifiersOnThisThread() = withCatchAll("resetTelemetryIdentifiers") {
532+
nimbusClient.resetTelemetryIdentifiers().also { enrollmentChangeEvents ->
533+
recordExperimentTelemetryEvents(enrollmentChangeEvents)
534+
updateDispatcher.notifyChanged(enrollmentChangeEvents)
535+
}
536+
}
537+
538+
@AnyThread
508539
override fun optInWithBranch(experimentId: String, branch: String) {
509540
dbScope.launch {
510-
withCatchAll("optIn") {
511-
nimbusClient.optInWithBranch(experimentId, branch).also(::recordExperimentTelemetryEvents)
512-
}
541+
optInWithBranchOnThisThread(experimentId, branch)
513542
}
514543
}
515544

545+
@WorkerThread
546+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
547+
internal fun optInWithBranchOnThisThread(experimentId: String, branch: String) = withCatchAll("optIn") {
548+
nimbusClient.optInWithBranch(experimentId, branch).also(::recordExperimentTelemetryEvents)
549+
}
550+
516551
override fun recordExposureEvent(featureId: String, experimentSlug: String?) {
517552
recordExposureOnThisThread(featureId, experimentSlug)
518553
}

components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/NimbusInterface.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import org.mozilla.experiments.nimbus.internal.AvailableExperiment
1717
import org.mozilla.experiments.nimbus.internal.EnrolledExperiment
1818
import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent
1919
import org.mozilla.experiments.nimbus.internal.ExperimentBranch
20+
import org.mozilla.experiments.nimbus.internal.FeatureUpdateDispatcher
2021
import org.mozilla.experiments.nimbus.internal.GeckoPrefState
2122
import org.mozilla.experiments.nimbus.internal.PrefUnenrollReason
2223
import org.mozilla.experiments.nimbus.internal.PreviousGeckoPrefState
@@ -244,6 +245,11 @@ interface NimbusInterface : FeaturesInterface, NimbusMessagingInterface, NimbusE
244245
override val events: NimbusEventStore
245246
get() = this
246247

248+
/**
249+
* Return the feature update dispatcher.
250+
*/
251+
fun getFeatureUpdateDispatcher(): FeatureUpdateDispatcher? = null
252+
247253
/**
248254
* Interface to be implemented by classes that want to observe experiment updates
249255
*/
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.experiments.nimbus.internal
6+
7+
import androidx.annotation.AnyThread
8+
import kotlinx.coroutines.DelicateCoroutinesApi
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.GlobalScope
11+
import kotlinx.coroutines.launch
12+
import org.mozilla.experiments.nimbus.internal.EnrollmentChangeEvent
13+
import java.util.concurrent.locks.ReentrantLock
14+
import kotlin.concurrent.withLock
15+
16+
/**
17+
* The feature update dispatcher dispatches callbacks when feature
18+
* configurations change.
19+
*/
20+
class FeatureUpdateDispatcher {
21+
private val lock = ReentrantLock()
22+
private val callbackMap: MutableMap<String, MutableSet<() -> Unit>> = mutableMapOf()
23+
24+
/**
25+
* Register a callback to be called when the feature value changes.
26+
*/
27+
@AnyThread
28+
public fun register(featureId: String, callback: () -> Unit) {
29+
lock.withLock {
30+
callbackMap
31+
.getOrPut(featureId, { mutableSetOf<() -> Unit>() })
32+
.add(callback)
33+
}
34+
}
35+
36+
/**
37+
* Remove a callback registration for a feature.
38+
*/
39+
@AnyThread
40+
public fun unregister(featureId: String, callback: () -> Unit) {
41+
lock.withLock {
42+
callbackMap.get(featureId)?.run { remove(callback) }
43+
}
44+
}
45+
46+
/**
47+
* Trigger the callbacks for all the features that have changed.
48+
*/
49+
@AnyThread
50+
internal fun notifyChanged(events: List<EnrollmentChangeEvent>) {
51+
if (events.isEmpty()) {
52+
return
53+
}
54+
55+
val featureIds = mutableSetOf<String>()
56+
57+
for (event in events) {
58+
for (featureId in event.featureIds) {
59+
featureIds.add(featureId)
60+
}
61+
}
62+
63+
notifyFeatures(featureIds)
64+
}
65+
66+
/**
67+
* Trigger the callbacks for the given features.
68+
*/
69+
@AnyThread
70+
@OptIn(DelicateCoroutinesApi::class)
71+
internal fun notifyFeatures(featureIds: Set<String>) {
72+
val toUpdate = mutableSetOf<() -> Unit>()
73+
74+
lock.withLock {
75+
for (featureId in featureIds) {
76+
callbackMap.get(featureId)?.also { callbacks ->
77+
for (callback in callbacks) {
78+
toUpdate.add(callback)
79+
}
80+
}
81+
}
82+
}
83+
84+
GlobalScope.launch(Dispatchers.Main) {
85+
for (callback in toUpdate) {
86+
callback()
87+
}
88+
}
89+
}
90+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.experiments.nimbus
6+
7+
import android.os.Looper
8+
import org.junit.Assert.assertEquals
9+
import org.junit.runner.RunWith
10+
import org.mozilla.experiments.nimbus.internal.NimbusUpdateDispatcher
11+
import org.robolectric.RobolectricTestRunner
12+
import org.robolectric.Shadows.shadowOf
13+
14+
@RunWith(RobolectricTestRunner::class)
15+
class FeatureUpdateDispatcherTests {
16+
@Test
17+
fun `test update registration`() {
18+
val updates = FeatureUpdateDispatcher()
19+
20+
var fooCalls = 0
21+
var barCalls = 0
22+
var bazCalls = 0
23+
24+
fun assertCalls(expectedFoo: Int, expectedBar: Int, expectedBaz: Int) {
25+
shadowOf(Looper.getMainLooper()).idle()
26+
27+
assertEquals(expectedFoo, fooCalls)
28+
assertEquals(expectedBar, barCalls)
29+
assertEquals(expectedBaz, bazCalls)
30+
}
31+
32+
val fooCallback = { fooCalls++ }
33+
val barCallback = { barCalls++ }
34+
val bazCallback = { bazCalls++ }
35+
36+
assertCalls(0, 0, 0)
37+
38+
updates.notifyFeatures(setOf("foo", "bar", "baz"))
39+
assertCalls(0, 0, 0)
40+
41+
updates.register("foo", fooCallback)
42+
updates.notifyFeatures(setOf("foo", "bar", "baz"))
43+
assertCalls(1, 0, 0)
44+
45+
updates.register("bar", barCallback)
46+
updates.notifyFeatures(setOf("foo", "bar", "baz"))
47+
assertCalls(2, 1, 0)
48+
49+
updates.register("baz", bazCallback)
50+
updates.notifyFeatures(setOf("foo", "bar", "baz"))
51+
assertCalls(3, 2, 1)
52+
53+
updates.unregister("foo", fooCallback)
54+
updates.notifyFeatures(setOf("foo", "bar", "baz"))
55+
assertCalls(3, 3, 2)
56+
57+
updates.unregister("bar", barCallback)
58+
updates.notifyFeatures(setOf("foo", "bar", "baz"))
59+
assertCalls(3, 3, 3)
60+
61+
updates.unregister("baz", bazCallback)
62+
updates.notifyFeatures(setOf("foo", "bar", "baz"))
63+
assertCalls(3, 3, 3)
64+
}
65+
66+
@Test
67+
fun `multiple callbacks for feature`() {
68+
val updates = FeatureUpdateDispatcher()
69+
70+
var aCalls = 0
71+
var bCalls = 0
72+
73+
fun assertCalls(expectedA: Int, expectedB: Int) {
74+
shadowOf(Looper.getMainLooper()).idle()
75+
76+
assertEquals(expectedA, aCalls)
77+
assertEquals(expectedB, bCalls)
78+
}
79+
80+
val callbackA = { aCalls++ }
81+
val callbackB = { bCalls++ }
82+
83+
assertCalls(0, 0)
84+
85+
updates.notifyFeatures(setOf("foo"))
86+
assertCalls(0, 0)
87+
88+
updates.register("foo", callbackA)
89+
updates.notifyFeatures(setOf("foo"))
90+
assertCalls(1, 0)
91+
92+
updates.register("foo", callbackB)
93+
updates.notifyFeatures(setOf("foo"))
94+
assertCalls(2, 1)
95+
96+
updates.unregister("foo", callbackA)
97+
updates.notifyFeatures(setOf("foo"))
98+
assertCalls(2, 2)
99+
100+
updates.unregister("foo", callbackB)
101+
updates.notifyFeatures(setOf("foo"))
102+
assertCalls(2, 2)
103+
}
104+
}

0 commit comments

Comments
 (0)