diff --git a/README.md b/README.md
index 1cef7ea1c01..ae860561ff4 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,19 @@ This repository contains a subset of the Firebase Android SDK source. It
currently includes the following Firebase libraries, and some of their
dependencies:
+ * `firebase-abt`
* `firebase-common`
+ * `firebase-common-ktx`
* `firebase-database`
- * `firebase-functions`
+ * `firebase-database-collection`
+ * `firebase-datatransport`
* `firebase-firestore`
- * `firebase-storage`
+ * `firebase-firestore-ktx`
+ * `firebase-functions`
+ * `firebase-functions-ktx`
* `firebase-inappmessaging-display`
+ * `firebase-remote-config`
+ * `firebase-storage`
Firebase is an app development platform with tools to help you build, grow and
diff --git a/firebase-abt/firebase-abt.gradle b/firebase-abt/firebase-abt.gradle
new file mode 100644
index 00000000000..80a2092b8a4
--- /dev/null
+++ b/firebase-abt/firebase-abt.gradle
@@ -0,0 +1,72 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+plugins {
+ id 'firebase-library'
+}
+
+firebaseLibrary {
+ testLab.enabled = true
+ publishSources = true
+}
+
+android {
+
+ lintOptions {
+ abortOnError false
+ }
+ sourceSets {
+ main {
+ java {
+ }
+ }
+ test {
+ java {
+ }
+ }
+ }
+
+ compileSdkVersion project.targetSdkVersion
+ defaultConfig {
+ minSdkVersion project.minSdkVersion
+ targetSdkVersion project.targetSdkVersion
+ versionName version
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ implementation project(':firebase-common')
+ implementation project(':protolite-well-known-types')
+ implementation ('com.google.firebase:firebase-measurement-connector:18.0.0') {
+ exclude group: "com.google.firebase", module: "firebase-common"
+ }
+ testImplementation 'org.mockito:mockito-core:2.25.0'
+ testImplementation 'com.google.truth:truth:0.44'
+ testImplementation 'junit:junit:4.13-beta-2'
+ testImplementation 'androidx.test:runner:1.2.0'
+ testImplementation 'org.robolectric:robolectric:4.2'
+ testImplementation 'io.grpc:grpc-testing:1.12.0'
+ testImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+
+}
diff --git a/firebase-abt/gradle.properties b/firebase-abt/gradle.properties
new file mode 100644
index 00000000000..af4118964cb
--- /dev/null
+++ b/firebase-abt/gradle.properties
@@ -0,0 +1,2 @@
+version=17.1.2
+latestReleasedVersion=17.1.1
\ No newline at end of file
diff --git a/firebase-abt/src/main/AndroidManifest.xml b/firebase-abt/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..803d6a25c68
--- /dev/null
+++ b/firebase-abt/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-abt/src/main/java/com/google/firebase/abt/AbtException.java b/firebase-abt/src/main/java/com/google/firebase/abt/AbtException.java
new file mode 100644
index 00000000000..2bfb45719a4
--- /dev/null
+++ b/firebase-abt/src/main/java/com/google/firebase/abt/AbtException.java
@@ -0,0 +1,34 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.abt;
+
+/**
+ * An exception thrown when there's an issue with a call to the {@link
+ * com.google.firebase.abt.FirebaseABTesting} API.
+ *
+ * @author Miraziz Yusupov
+ */
+public class AbtException extends Exception {
+
+ /** Creates an ABT exception with the given message. */
+ public AbtException(String message) {
+ super(message);
+ }
+
+ /** Creates an ABT exception with the given message and cause. */
+ public AbtException(String message, Exception cause) {
+ super(message, cause);
+ }
+}
diff --git a/firebase-abt/src/main/java/com/google/firebase/abt/AbtExperimentInfo.java b/firebase-abt/src/main/java/com/google/firebase/abt/AbtExperimentInfo.java
new file mode 100644
index 00000000000..263f8a1b36c
--- /dev/null
+++ b/firebase-abt/src/main/java/com/google/firebase/abt/AbtExperimentInfo.java
@@ -0,0 +1,252 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.abt;
+
+import androidx.annotation.VisibleForTesting;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * A set of values describing an ABT experiment. The values are sent to the Analytics SDK for
+ * tracking when an experiment is applied on an App instance.
+ *
+ *
The experiment info is expected to be in a {@code {@link Map}} format. All
+ * such maps must contain all the keys in {@link #ALL_REQUIRED_KEYS}; if a key is missing, an {@link
+ * AbtException} will be thrown. Any keys not defined in {@link #ALL_REQUIRED_KEYS} will be ignored.
+ *
+ * Changes in the values returned by the ABT server and client SDKs must be reflected here
+ *
+ * @author Miraziz Yusupov
+ */
+public class AbtExperimentInfo {
+
+ /**
+ * The experiment id key.
+ *
+ *
An experiment id is unique within a Firebase project and is assigned by the ABT service.
+ */
+ @VisibleForTesting static final String EXPERIMENT_ID_KEY = "experimentId";
+
+ /**
+ * The variant id key.
+ *
+ *
A variant id determines which variant of the experiment an App instance belongs to and is
+ * assigned by the ABT service.
+ */
+ @VisibleForTesting static final String VARIANT_ID_KEY = "variantId";
+ /**
+ * The trigger event key.
+ *
+ *
The ABT server does not pass this key if the value is empty, so it is optional.
+ *
+ *
The occurrence of a trigger event activates the experiment for an App instance.
+ */
+ @VisibleForTesting static final String TRIGGER_EVENT_KEY = "triggerEvent";
+
+ /**
+ * The experiment start time key.
+ *
+ *
The experiment start time is the point in time when the experiment was started in the
+ * Firebase console. The start time must be in an ISO 8601 compliant format.
+ */
+ @VisibleForTesting static final String EXPERIMENT_START_TIME_KEY = "experimentStartTime";
+
+ /**
+ * The trigger timeout key.
+ *
+ *
A trigger timeout defines how long an experiment can run in an App instance without being
+ * triggered. The timeout must be in milliseconds and convertible into a {@code long}.
+ */
+ @VisibleForTesting static final String TRIGGER_TIMEOUT_KEY = "triggerTimeoutMillis";
+
+ /**
+ * The time to live key.
+ *
+ *
A time to live defines how long an experiment can run in an App instance. The time must be
+ * in milliseconds and convertible into a {@code long}.
+ */
+ @VisibleForTesting static final String TIME_TO_LIVE_KEY = "timeToLiveMillis";
+
+ /** The set of all keys required by the ABT SDK to define an experiment. */
+ private static final String[] ALL_REQUIRED_KEYS = {
+ EXPERIMENT_ID_KEY,
+ EXPERIMENT_START_TIME_KEY,
+ TIME_TO_LIVE_KEY,
+ TRIGGER_TIMEOUT_KEY,
+ VARIANT_ID_KEY,
+ };
+
+ /**
+ * The String format of a protobuf Timestamp; the format is ISO 8601 compliant.
+ *
+ *
The protobuf Timestamp field gets converted to an ISO 8601 string when returned as JSON. For
+ * example, the Firebase Remote Config backend sends experiment start time as a Timestamp field,
+ * which gets converted to an ISO 8601 string when sent as JSON.
+ */
+ @VisibleForTesting
+ static final DateFormat protoTimestampStringParser =
+ new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US);
+
+ /** The experiment id as defined by the ABT backend. */
+ private final String experimentId;
+ /** The id of the variant of this experiment the current App instance has been assigned to. */
+ private final String variantId;
+ /** The name of the event that will trigger the activation of this experiment. */
+ private final String triggerEventName;
+ /** The start time of this experiment. */
+ private final Date experimentStartTime;
+ /** The amount of time, in milliseconds, before the trigger for this experiment expires. */
+ private final long triggerTimeoutInMillis;
+ /** The amount of time, in milliseconds, before the experiment expires for this App instance. */
+ private final long timeToLiveInMillis;
+
+ /** Creates an instance of {@link AbtExperimentInfo} with all the required keys. */
+ @VisibleForTesting
+ AbtExperimentInfo(
+ String experimentId,
+ String variantId,
+ String triggerEventName,
+ Date experimentStartTime,
+ long triggerTimeoutInMillis,
+ long timeToLiveInMillis) {
+
+ this.experimentId = experimentId;
+ this.variantId = variantId;
+ this.triggerEventName = triggerEventName;
+ this.experimentStartTime = experimentStartTime;
+ this.triggerTimeoutInMillis = triggerTimeoutInMillis;
+ this.timeToLiveInMillis = timeToLiveInMillis;
+ }
+
+ /**
+ * Converts a map of strings containing an ABT experiment's tracking information into an instance
+ * of {@link AbtExperimentInfo}.
+ *
+ * @param experimentInfoMap A {@link Map} that contains all the keys specified in {@link
+ * #ALL_REQUIRED_KEYS}. The values of each key must be convertible to the appropriate type,
+ * e.g., the value for {@link #EXPERIMENT_START_TIME_KEY} must be an ISO 8601 Date string.
+ * @return An {@link AbtExperimentInfo} with the values of the experiment in {@code
+ * experimentInfoMap}.
+ * @throws AbtException If one of the keys is missing, or any of the values cannot be converted to
+ * their appropriate type.
+ */
+ static AbtExperimentInfo fromMap(Map experimentInfoMap) throws AbtException {
+
+ validateExperimentInfoMap(experimentInfoMap);
+
+ try {
+ Date experimentStartTime =
+ protoTimestampStringParser.parse(experimentInfoMap.get(EXPERIMENT_START_TIME_KEY));
+ long triggerTimeoutInMillis = Long.parseLong(experimentInfoMap.get(TRIGGER_TIMEOUT_KEY));
+ long timeToLiveInMillis = Long.parseLong(experimentInfoMap.get(TIME_TO_LIVE_KEY));
+
+ return new AbtExperimentInfo(
+ experimentInfoMap.get(EXPERIMENT_ID_KEY),
+ experimentInfoMap.get(VARIANT_ID_KEY),
+ experimentInfoMap.containsKey(TRIGGER_EVENT_KEY)
+ ? experimentInfoMap.get(TRIGGER_EVENT_KEY)
+ : "",
+ experimentStartTime,
+ triggerTimeoutInMillis,
+ timeToLiveInMillis);
+ } catch (ParseException e) {
+ throw new AbtException(
+ "Could not process experiment: parsing experiment start time failed.", e);
+ } catch (NumberFormatException e) {
+ throw new AbtException(
+ "Could not process experiment: one of the durations could not be converted into a long.",
+ e);
+ }
+ }
+
+ /** Returns the id of this experiment. */
+ String getExperimentId() {
+ return experimentId;
+ }
+
+ /** Returns the id of the variant this App instance got assigned to. */
+ String getVariantId() {
+ return variantId;
+ }
+
+ /** Returns the name of the event that will trigger the activation of this experiment. */
+ String getTriggerEventName() {
+ return triggerEventName;
+ }
+
+ /** Returns the time the experiment was started, in millis since epoch. */
+ long getStartTimeInMillisSinceEpoch() {
+ return experimentStartTime.getTime();
+ }
+
+ /** Returns the amount of time before the trigger event expires for this experiment. */
+ long getTriggerTimeoutInMillis() {
+ return triggerTimeoutInMillis;
+ }
+
+ /** Returns the amount of time before the experiment expires in this App instance. */
+ long getTimeToLiveInMillis() {
+ return timeToLiveInMillis;
+ }
+
+ /**
+ * Verifies that {@code experimentInfoMap} contains all the keys in {@link #ALL_REQUIRED_KEYS}.
+ *
+ * @throws AbtException If {@code experimentInfoMap} is missing a key.
+ */
+ private static void validateExperimentInfoMap(Map experimentInfoMap)
+ throws AbtException {
+
+ List missingKeys = new ArrayList<>();
+ for (String key : ALL_REQUIRED_KEYS) {
+ if (!experimentInfoMap.containsKey(key)) {
+ missingKeys.add(key);
+ }
+ }
+
+ if (!missingKeys.isEmpty()) {
+ throw new AbtException(
+ String.format(
+ "The following keys are missing from the experiment info map: %s", missingKeys));
+ }
+ }
+
+ /**
+ * Used for testing {@code FirebaseABTesting#replaceAllExperiments(List)} without leaking the
+ * implementation details of this class.
+ */
+ @VisibleForTesting
+ Map toStringMap() {
+
+ Map experimentInfoMap = new HashMap<>();
+
+ experimentInfoMap.put(EXPERIMENT_ID_KEY, experimentId);
+ experimentInfoMap.put(VARIANT_ID_KEY, variantId);
+ experimentInfoMap.put(TRIGGER_EVENT_KEY, triggerEventName);
+ experimentInfoMap.put(
+ EXPERIMENT_START_TIME_KEY, protoTimestampStringParser.format(experimentStartTime));
+ experimentInfoMap.put(TRIGGER_TIMEOUT_KEY, Long.toString(triggerTimeoutInMillis));
+ experimentInfoMap.put(TIME_TO_LIVE_KEY, Long.toString(timeToLiveInMillis));
+
+ return experimentInfoMap;
+ }
+}
diff --git a/firebase-abt/src/main/java/com/google/firebase/abt/FirebaseABTesting.java b/firebase-abt/src/main/java/com/google/firebase/abt/FirebaseABTesting.java
new file mode 100644
index 00000000000..96f7989a5bd
--- /dev/null
+++ b/firebase-abt/src/main/java/com/google/firebase/abt/FirebaseABTesting.java
@@ -0,0 +1,329 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.abt;
+
+import static com.google.firebase.abt.FirebaseABTesting.OriginService.REMOTE_CONFIG;
+
+import android.content.Context;
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.analytics.connector.AnalyticsConnector.ConditionalUserProperty;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Manages Firebase A/B Testing Experiments.
+ *
+ * To register an ABT experiment in an App, the experiment's information needs to be sent to the
+ * Google Analytics for Firebase SDK. To simplify those interactions with Analytics,
+ * FirebaseABTesting's methods provide a simple interface for managing ABT experiments.
+ *
+ *
An instance of this class handles all experiments for a specific origin (an impact service
+ * such as Firebase Remote Config) in a specific App.
+ *
+ *
The clients of this class are first party teams that use ABT experiments in their SDKs.
+ *
+ * @author Miraziz Yusupov
+ */
+public class FirebaseABTesting {
+
+ @VisibleForTesting static final String ABT_PREFERENCES = "com.google.firebase.abt";
+
+ @VisibleForTesting
+ static final String ORIGIN_LAST_KNOWN_START_TIME_KEY_FORMAT = "%s_lastKnownExperimentStartTime";
+
+ /** The App's Firebase Analytics client. */
+ private final AnalyticsConnector analyticsConnector;
+
+ /** The name of an ABT client. */
+ private final String originService;
+
+ /**
+ * Select keys of fields in the experiment descriptions returned from the Firebase Remote Config
+ * server.
+ */
+ @StringDef({REMOTE_CONFIG})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface OriginService {
+ /** Must match the origin code in Google Analytics for Firebase. */
+ String REMOTE_CONFIG = "frc";
+ }
+
+ /**
+ * Maximum number of conditional user properties allowed for the origin service. Null until
+ * retrieved from Analytics.
+ */
+ @Nullable private Integer maxUserProperties;
+
+ /**
+ * Creates an instance of the ABT class for the specified App and origin service.
+ *
+ * @param unusedAppContext {@link Context} of an App.
+ * @param originService the name of an origin service.
+ */
+ public FirebaseABTesting(
+ Context unusedAppContext,
+ AnalyticsConnector analyticsConnector,
+ @OriginService String originService) {
+ this.analyticsConnector = analyticsConnector;
+ this.originService = originService;
+
+ this.maxUserProperties = null;
+ }
+
+ /**
+ * Replaces the origin's list of experiments in the App with the experiments defined in {@code
+ * replacementExperiments}, adhering to a "discard oldest" overflow policy.
+ *
+ *
Note: This is a blocking call and should only be called from a worker thread.
+ *
+ *
The maps of {@code replacementExperiments} must be in the format defined by the ABT service.
+ * The current SDK's format for experiment maps is specified in {@link
+ * AbtExperimentInfo#fromMap(Map)}.
+ *
+ * @param replacementExperiments list of experiment info {@link Map}s, where each map contains the
+ * identifiers and metadata of a distinct experiment that is currently running. If the value
+ * is null, this method is a no-op.
+ * @throws IllegalArgumentException If {@code replacementExperiments} is null.
+ * @throws AbtException If there is no Analytics SDK or if any experiment map in {@code
+ * replacementExperiments} could not be parsed.
+ */
+ @WorkerThread
+ public void replaceAllExperiments(List> replacementExperiments)
+ throws AbtException {
+
+ throwAbtExceptionIfAnalyticsIsNull();
+
+ if (replacementExperiments == null) {
+ throw new IllegalArgumentException("The replacementExperiments list is null.");
+ }
+
+ replaceAllExperimentsWith(convertMapsToExperimentInfos(replacementExperiments));
+ }
+
+ /**
+ * Clears the origin service's list of experiments in the App.
+ *
+ * Note: This is a blocking call and therefore should be called from a worker thread.
+ *
+ * @throws AbtException If there is no Analytics SDK.
+ */
+ @WorkerThread
+ public void removeAllExperiments() throws AbtException {
+
+ throwAbtExceptionIfAnalyticsIsNull();
+
+ removeExperiments(getAllExperimentsInAnalytics());
+ }
+
+ /**
+ * Replaces the origin's list of experiments in the App with {@code replacementExperiments}. If
+ * {@code replacementExperiments} is an empty list, then all the origin's experiments in the App
+ * are removed.
+ *
+ *
The replacement is done as follows:
+ *
+ *
+ * Any experiment in the origin's list that is not in {@code replacementExperiments} is
+ * removed.
+ * Any experiment in {@code replacementExperiments} that is not already in the origin's list
+ * is added. If the origin's list has the maximum number of experiments allowed and an
+ * experiment needs to be added, the oldest experiment in the list is removed.
+ *
+ *
+ * Experiments in {@code replacementExperiments} that have previously been discarded will be
+ * ignored. An experiment is assumed to be previously discarded if it's start time is before the
+ * last start time seen by this instance and it does not exist in the origin's list.
+ *
+ * @param replacementExperiments list of {@link AbtExperimentInfo}s, each containing the
+ * identifiers and metadata of a distinct experiment that is currently running. Must contain
+ * at least one valid experiment.
+ * @throws AbtException If there is no Analytics SDK.
+ */
+ private void replaceAllExperimentsWith(List replacementExperiments)
+ throws AbtException {
+
+ if (replacementExperiments.isEmpty()) {
+ removeAllExperiments();
+ return;
+ }
+
+ Set replacementExperimentIds = new HashSet<>();
+ for (AbtExperimentInfo replacementExperiment : replacementExperiments) {
+ replacementExperimentIds.add(replacementExperiment.getExperimentId());
+ }
+
+ List experimentsInAnalytics = getAllExperimentsInAnalytics();
+ Set idsOfExperimentsInAnalytics = new HashSet<>();
+ for (ConditionalUserProperty experimentInAnalytics : experimentsInAnalytics) {
+ idsOfExperimentsInAnalytics.add(experimentInAnalytics.name);
+ }
+
+ List experimentsToRemove =
+ getExperimentsToRemove(experimentsInAnalytics, replacementExperimentIds);
+ removeExperiments(experimentsToRemove);
+
+ List experimentsToAdd =
+ getExperimentsToAdd(replacementExperiments, idsOfExperimentsInAnalytics);
+ addExperiments(experimentsToAdd);
+ }
+
+ /** Returns this origin's experiments in Analytics that are no longer assigned to this App. */
+ private ArrayList getExperimentsToRemove(
+ List experimentsInAnalytics, Set replacementExperimentIds) {
+
+ ArrayList experimentsToRemove = new ArrayList<>();
+ for (ConditionalUserProperty experimentInAnalytics : experimentsInAnalytics) {
+ if (!replacementExperimentIds.contains(experimentInAnalytics.name)) {
+ experimentsToRemove.add(experimentInAnalytics);
+ }
+ }
+ return experimentsToRemove;
+ }
+
+ /**
+ * Returns the new experiments in the specified {@link AbtExperimentInfo}s that need to be added
+ * to this origin's list of experiments in Analytics.
+ */
+ private ArrayList getExperimentsToAdd(
+ List replacementExperiments, Set idsOfExperimentsInAnalytics) {
+
+ ArrayList experimentsToAdd = new ArrayList<>();
+ for (AbtExperimentInfo replacementExperiment : replacementExperiments) {
+ if (!idsOfExperimentsInAnalytics.contains(replacementExperiment.getExperimentId())) {
+ experimentsToAdd.add(replacementExperiment);
+ }
+ }
+ return experimentsToAdd;
+ }
+
+ /** Adds the given experiments to the origin's list in Analytics. */
+ private void addExperiments(List experimentsToAdd) {
+
+ Deque dequeOfExperimentsInAnalytics =
+ new ArrayDeque<>(getAllExperimentsInAnalytics());
+
+ int fetchedMaxUserProperties = getMaxUserPropertiesInAnalytics();
+
+ for (AbtExperimentInfo experimentToAdd : experimentsToAdd) {
+ while (dequeOfExperimentsInAnalytics.size() >= fetchedMaxUserProperties) {
+ removeExperimentFromAnalytics(dequeOfExperimentsInAnalytics.pollFirst().name);
+ }
+
+ ConditionalUserProperty experiment = createConditionalUserProperty(experimentToAdd);
+ addExperimentToAnalytics(experiment);
+ dequeOfExperimentsInAnalytics.offer(experiment);
+ }
+ }
+
+ private void removeExperiments(Collection experiments) {
+ for (ConditionalUserProperty experiment : experiments) {
+ removeExperimentFromAnalytics(experiment.name);
+ }
+ }
+ /**
+ * Returns the {@link ConditionalUserProperty} created from the specified {@link
+ * AbtExperimentInfo}.
+ */
+ private ConditionalUserProperty createConditionalUserProperty(AbtExperimentInfo experimentInfo) {
+
+ ConditionalUserProperty conditionalUserProperty = new ConditionalUserProperty();
+
+ conditionalUserProperty.origin = originService;
+ conditionalUserProperty.creationTimestamp = experimentInfo.getStartTimeInMillisSinceEpoch();
+ conditionalUserProperty.name = experimentInfo.getExperimentId();
+ conditionalUserProperty.value = experimentInfo.getVariantId();
+
+ // For a conditional user property to be immediately activated/triggered, its trigger
+ // event needs to be null, not just an empty string.
+ conditionalUserProperty.triggerEventName =
+ TextUtils.isEmpty(experimentInfo.getTriggerEventName())
+ ? null
+ : experimentInfo.getTriggerEventName();
+ conditionalUserProperty.triggerTimeout = experimentInfo.getTriggerTimeoutInMillis();
+ conditionalUserProperty.timeToLive = experimentInfo.getTimeToLiveInMillis();
+
+ return conditionalUserProperty;
+ }
+
+ /**
+ * Returns the {@link List} of {@link AbtExperimentInfo} converted from the {@link List} of
+ * experiment info {@link Map}s.
+ */
+ private static List convertMapsToExperimentInfos(
+ List> replacementExperimentsMaps) throws AbtException {
+
+ List replacementExperimentInfos = new ArrayList<>();
+ for (Map replacementExperimentMap : replacementExperimentsMaps) {
+ replacementExperimentInfos.add(AbtExperimentInfo.fromMap(replacementExperimentMap));
+ }
+ return replacementExperimentInfos;
+ }
+
+ private void addExperimentToAnalytics(ConditionalUserProperty experiment) {
+ analyticsConnector.setConditionalUserProperty(experiment);
+ }
+
+ private void throwAbtExceptionIfAnalyticsIsNull() throws AbtException {
+ if (analyticsConnector == null) {
+ throw new AbtException(
+ "The Analytics SDK is not available. "
+ + "Please check that the Analytics SDK is included in your app dependencies.");
+ }
+ }
+
+ /**
+ * The method takes a String instead of a {@link ConditionalUserProperty} to make it easier to
+ * test. The method itself is tested to make it easier to figure out whether part of ABT is
+ * breaking, or if the underlying Analytics clear method is failing.
+ */
+ @VisibleForTesting
+ void removeExperimentFromAnalytics(String experimentId) {
+ analyticsConnector.clearConditionalUserProperty(
+ experimentId, /*clearEventName=*/ null, /*clearEventParams=*/ null);
+ }
+
+ @WorkerThread
+ private int getMaxUserPropertiesInAnalytics() {
+ if (maxUserProperties == null) {
+ maxUserProperties = analyticsConnector.getMaxUserProperties(originService);
+ }
+ return maxUserProperties;
+ }
+
+ /**
+ * Returns a list of all this origin's experiments in this App's Analytics SDK.
+ *
+ * The list is sorted chronologically by the experiment start time, with the oldest experiment
+ * at index 0.
+ */
+ @WorkerThread
+ private List getAllExperimentsInAnalytics() {
+ return analyticsConnector.getConditionalUserProperties(
+ originService, /*propertyNamePrefix=*/ "");
+ }
+}
diff --git a/firebase-abt/src/main/java/com/google/firebase/abt/component/AbtComponent.java b/firebase-abt/src/main/java/com/google/firebase/abt/component/AbtComponent.java
new file mode 100644
index 00000000000..2aebc960daa
--- /dev/null
+++ b/firebase-abt/src/main/java/com/google/firebase/abt/component/AbtComponent.java
@@ -0,0 +1,65 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.abt.component;
+
+import android.content.Context;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.VisibleForTesting;
+import com.google.firebase.abt.FirebaseABTesting;
+import com.google.firebase.abt.FirebaseABTesting.OriginService;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Component for providing multiple Firebase A/B Testing (ABT) instances. Firebase Android
+ * Components uses this class to retrieve instances of ABT for dependency injection.
+ *
+ * A unique ABT instance is returned for each {@code originService}.
+ *
+ * @author Miraziz Yusupov
+ */
+public class AbtComponent {
+
+ @GuardedBy("this")
+ private final Map abtOriginInstances = new HashMap<>();
+
+ private final Context appContext;
+ private final AnalyticsConnector analyticsConnector;
+
+ /** Firebase ABT Component constructor. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+ protected AbtComponent(Context appContext, AnalyticsConnector analyticsConnector) {
+ this.appContext = appContext;
+ this.analyticsConnector = analyticsConnector;
+ }
+
+ /**
+ * Returns the Firebase ABT instance associated with the given {@code originService}.
+ *
+ * @param originService the name of the ABT client, as defined in Analytics.
+ */
+ public synchronized FirebaseABTesting get(@OriginService String originService) {
+ if (!abtOriginInstances.containsKey(originService)) {
+ abtOriginInstances.put(originService, createAbtInstance(originService));
+ }
+ return abtOriginInstances.get(originService);
+ }
+
+ @VisibleForTesting
+ protected FirebaseABTesting createAbtInstance(@OriginService String originService) {
+ return new FirebaseABTesting(appContext, analyticsConnector, originService);
+ }
+}
diff --git a/firebase-abt/src/main/java/com/google/firebase/abt/component/AbtRegistrar.java b/firebase-abt/src/main/java/com/google/firebase/abt/component/AbtRegistrar.java
new file mode 100644
index 00000000000..87200c0d806
--- /dev/null
+++ b/firebase-abt/src/main/java/com/google/firebase/abt/component/AbtRegistrar.java
@@ -0,0 +1,49 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.abt.component;
+
+import android.content.Context;
+import androidx.annotation.Keep;
+import com.google.firebase.abt.BuildConfig;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.components.Component;
+import com.google.firebase.components.ComponentRegistrar;
+import com.google.firebase.components.Dependency;
+import com.google.firebase.platforminfo.LibraryVersionComponent;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Registrar for setting up Firebase ABT's dependency injections in Firebase Android Components.
+ *
+ * @author Miraziz Yusupov
+ */
+@Keep
+public class AbtRegistrar implements ComponentRegistrar {
+
+ @Override
+ public List> getComponents() {
+ return Arrays.asList(
+ Component.builder(AbtComponent.class)
+ .add(Dependency.required(Context.class))
+ .add(Dependency.optional(AnalyticsConnector.class))
+ .factory(
+ container ->
+ new AbtComponent(
+ container.get(Context.class), container.get(AnalyticsConnector.class)))
+ .build(),
+ LibraryVersionComponent.create("fire-abt", BuildConfig.VERSION_NAME));
+ }
+}
diff --git a/firebase-abt/src/test/java/com/google/firebase/abt/AbtExperimentInfoTest.java b/firebase-abt/src/test/java/com/google/firebase/abt/AbtExperimentInfoTest.java
new file mode 100644
index 00000000000..885136825e3
--- /dev/null
+++ b/firebase-abt/src/test/java/com/google/firebase/abt/AbtExperimentInfoTest.java
@@ -0,0 +1,74 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.abt;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.firebase.abt.AbtExperimentInfo.EXPERIMENT_ID_KEY;
+import static com.google.firebase.abt.AbtExperimentInfo.EXPERIMENT_START_TIME_KEY;
+import static com.google.firebase.abt.AbtExperimentInfo.TIME_TO_LIVE_KEY;
+import static com.google.firebase.abt.AbtExperimentInfo.TRIGGER_EVENT_KEY;
+import static com.google.firebase.abt.AbtExperimentInfo.TRIGGER_TIMEOUT_KEY;
+import static com.google.firebase.abt.AbtExperimentInfo.VARIANT_ID_KEY;
+import static com.google.firebase.abt.AbtExperimentInfo.protoTimestampStringParser;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Unit tests for {@link AbtExperimentInfo}.
+ *
+ * @author Miraziz Yusupov
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class AbtExperimentInfoTest {
+
+ @Test
+ public void fromMap_hasTriggerEvent_returnsConvertedExperiment() throws Exception {
+
+ Map experimentInfoMap = createExperimentInfoMap("exp1", "var1", "trigger");
+
+ AbtExperimentInfo info = AbtExperimentInfo.fromMap(experimentInfoMap);
+ assertThat(info.getTriggerEventName()).isEqualTo("trigger");
+ }
+
+ @Test
+ public void fromMap_noTriggerEvent_returnsExperimentWithEmptyTriggerEvent() throws Exception {
+
+ Map experimentInfoMap = createExperimentInfoMap("exp2", "var2", "");
+
+ AbtExperimentInfo info = AbtExperimentInfo.fromMap(experimentInfoMap);
+ assertThat(info.getTriggerEventName()).isEmpty();
+ }
+
+ private static Map createExperimentInfoMap(
+ String experimentId, String variantId, String triggerEvent) {
+
+ Map experimentInfoMap = new HashMap<>();
+ experimentInfoMap.put(EXPERIMENT_ID_KEY, experimentId);
+ experimentInfoMap.put(VARIANT_ID_KEY, variantId);
+ if (!triggerEvent.isEmpty()) {
+ experimentInfoMap.put(TRIGGER_EVENT_KEY, triggerEvent);
+ }
+ experimentInfoMap.put(EXPERIMENT_START_TIME_KEY, protoTimestampStringParser.format(555L));
+ experimentInfoMap.put(TRIGGER_TIMEOUT_KEY, "5");
+ experimentInfoMap.put(TIME_TO_LIVE_KEY, "5");
+ return experimentInfoMap;
+ }
+}
diff --git a/firebase-abt/src/test/java/com/google/firebase/abt/FirebaseABTWithoutAnalyticsTest.java b/firebase-abt/src/test/java/com/google/firebase/abt/FirebaseABTWithoutAnalyticsTest.java
new file mode 100644
index 00000000000..ab5cd78e7f9
--- /dev/null
+++ b/firebase-abt/src/test/java/com/google/firebase/abt/FirebaseABTWithoutAnalyticsTest.java
@@ -0,0 +1,124 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.abt;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import com.google.common.base.Preconditions;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.abt.FirebaseABTesting.OriginService;
+import com.google.firebase.abt.component.AbtComponent;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/**
+ * Unit tests for {@link FirebaseABTesting} without the Analytics SDK {@link AnalyticsConnector}.
+ *
+ * @author Miraziz Yusupov
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class FirebaseABTWithoutAnalyticsTest {
+
+ private static final String APP_ID = "1:14368190084:android:09cb977358c6f241";
+ private static final String API_KEY = "api_key";
+
+ private static final String VARIANT_ID_VALUE = "var1";
+ private static final String TRIGGER_EVENT_NAME_VALUE = "trigger_event_value";
+ private static final long TRIGGER_TIMEOUT_IN_MILLIS_VALUE = 1000L;
+ private static final long TIME_TO_LIVE_IN_MILLIS_VALUE = 2000L;
+
+ private FirebaseABTesting firebaseAbt;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ initializeFirebaseApp(RuntimeEnvironment.application);
+
+ Preconditions.checkArgument(FirebaseApp.getInstance().get(AnalyticsConnector.class) == null);
+
+ firebaseAbt =
+ FirebaseApp.getInstance().get(AbtComponent.class).get(OriginService.REMOTE_CONFIG);
+ }
+
+ @Test
+ public void replaceAllExperimentsWithoutAnalytics_experimentsListIsNull_throwsAbtException() {
+ AbtException actualException =
+ assertThrows(
+ AbtException.class,
+ () -> firebaseAbt.replaceAllExperiments(/*replacementExperiments=*/ null));
+ assertThat(actualException).hasMessageThat().contains("Analytics");
+ }
+
+ @Test
+ public void replaceAllExperimentsWithoutAnalytics_sendsValidExperimentList_throwsAbtException() {
+
+ List experimentInfos = new ArrayList<>();
+ experimentInfos.add(
+ createExperimentInfo("expid1", /*experimentStartTimeInEpochMillis=*/ 1000L));
+ experimentInfos.add(
+ createExperimentInfo("expid2", /*experimentStartTimeInEpochMillis=*/ 2000L));
+
+ List> experimentInfoMaps = new ArrayList<>();
+ for (AbtExperimentInfo experimentInfo : experimentInfos) {
+ experimentInfoMaps.add(experimentInfo.toStringMap());
+ }
+
+ AbtException actualException =
+ assertThrows(
+ AbtException.class, () -> firebaseAbt.replaceAllExperiments(experimentInfoMaps));
+ assertThat(actualException).hasMessageThat().contains("Analytics");
+ }
+
+ @Test
+ public void removeAllExperimentsWithoutAnalytics_throwsAbtException() {
+ AbtException actualException =
+ assertThrows(AbtException.class, () -> firebaseAbt.removeAllExperiments());
+ assertThat(actualException).hasMessageThat().contains("Analytics");
+ }
+
+ private static AbtExperimentInfo createExperimentInfo(
+ String experimentId, long experimentStartTimeInEpochMillis) {
+
+ return new AbtExperimentInfo(
+ experimentId,
+ VARIANT_ID_VALUE,
+ TRIGGER_EVENT_NAME_VALUE,
+ new Date(experimentStartTimeInEpochMillis),
+ TRIGGER_TIMEOUT_IN_MILLIS_VALUE,
+ TIME_TO_LIVE_IN_MILLIS_VALUE);
+ }
+
+ private static void initializeFirebaseApp(Context context) {
+ FirebaseApp.clearInstancesForTest();
+
+ FirebaseApp.initializeApp(
+ context, new FirebaseOptions.Builder().setApiKey(API_KEY).setApplicationId(APP_ID).build());
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/.gitignore b/firebase-remote-config/bandwagoner/.gitignore
new file mode 100644
index 00000000000..796b96d1c40
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/firebase-remote-config/bandwagoner/bandwagoner.gradle b/firebase-remote-config/bandwagoner/bandwagoner.gradle
new file mode 100644
index 00000000000..0a826413f12
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/bandwagoner.gradle
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+apply plugin: 'com.android.application'
+apply plugin: com.google.firebase.gradle.plugins.ci.device.FirebaseTestLabPlugin
+
+android {
+ compileSdkVersion 28
+ lintOptions {
+ abortOnError false
+ }
+ defaultConfig {
+ applicationId "com.googletest.firebase.remoteconfig.bandwagoner"
+ minSdkVersion 16
+ targetSdkVersion 28
+ versionCode 1
+ versionName "1.0"
+ multiDexEnabled true
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ testOptions {
+ animationsDisabled = true
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ // Required for lambda expressions.
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+firebaseTestLab {
+ device 'model=walleye,version=26,locale=en,orientation=portrait'
+
+}
+
+dependencies {
+ implementation project(":firebase-remote-config")
+ // This is required since a project dependency on frc does not expose the Apis of its "implementation" dependencies.
+ // The alternative would be to make common an "api" dep of remote-config.
+ // It should not matter for released artifacts.
+ implementation project(":firebase-common")
+
+ implementation('com.google.firebase:firebase-iid:18.0.0') {
+ exclude group: 'com.google.firebase', module: 'firebase-common'
+ }
+
+ implementation 'com.google.android.gms:play-services-basement:17.0.0'
+ implementation 'com.google.android.gms:play-services-tasks:17.0.0'
+
+ // Support Libraries
+ implementation 'com.google.guava:guava:27.1-android'
+ implementation 'androidx.legacy:legacy-support-v4:1.0.0'
+
+ implementation 'androidx.appcompat:appcompat:1.0.2'
+ implementation 'androidx.annotation:annotation:1.1.0'
+ implementation 'androidx.core:core:1.0.2'
+ implementation 'com.google.android.material:material:1.0.0'
+ api 'com.google.auto.value:auto-value-annotations:1.6.5'
+ annotationProcessor 'com.google.auto.value:auto-value:1.6.2'
+ implementation 'androidx.test.espresso:espresso-idling-resource:3.2.0'
+
+ androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.2.0'
+ androidTestImplementation 'androidx.test:rules:1.2.0'
+ androidTestImplementation 'androidx.test:runner:1.2.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+}
+
+ext.packageName = "com.googletest.firebase.remoteconfig.bandwagoner"
+apply from: '../../gradle/googleServices.gradle'
diff --git a/firebase-remote-config/bandwagoner/proguard-rules.pro b/firebase-remote-config/bandwagoner/proguard-rules.pro
new file mode 100644
index 00000000000..db68ab40f43
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in bandwagoneragoner.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
diff --git a/firebase-remote-config/bandwagoner/src/androidTest/AndroidManifest.xml b/firebase-remote-config/bandwagoner/src/androidTest/AndroidManifest.xml
new file mode 100644
index 00000000000..8e37b4c31ab
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/bandwagoner/src/androidTest/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerEspressoTest.java b/firebase-remote-config/bandwagoner/src/androidTest/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerEspressoTest.java
new file mode 100644
index 00000000000..7063f5bf342
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/androidTest/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerEspressoTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.clearText;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.action.ViewActions.typeText;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsString;
+
+import android.content.Context;
+import androidx.test.espresso.IdlingRegistry;
+import androidx.test.espresso.IdlingResource;
+import androidx.test.rule.ActivityTestRule;
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * All the Firebase Remote Config (FRC) SDK integration tests that can be run with just the 3P API.
+ *
+ * @author Miraziz Yusupov
+ */
+@RunWith(AndroidJUnit4.class)
+public class BandwagonerEspressoTest {
+ private static final String KEY_FOR_STRING = "string_key";
+ private static final String STRING_TYPE = "String";
+ private static final String STRING_STATIC_DEFAULT_VALUE = "";
+ private static final String STRING_REMOTE_DEFAULT_VALUE = "default_v1_remote_string_value";
+
+ private IdlingResource idlingResource;
+
+ @Rule
+ public ActivityTestRule activityTestRule =
+ new ActivityTestRule<>(MainActivity.class);
+
+ @Before
+ public void setUp() {
+ idlingResource = IdlingResourceManager.getInstance();
+ IdlingRegistry.getInstance().register(idlingResource);
+
+ onView(withId(R.id.reset_frc_button)).perform(click());
+ }
+
+ @Test
+ public void getDataTypes_returnsStaticDefaults() throws InterruptedException {
+ verifyKeyValuePair(KEY_FOR_STRING, STRING_TYPE, "");
+ }
+
+ @Test
+ public void activateFetchedWithoutFetching_activateFetchedReturnsFalse()
+ throws InterruptedException {
+
+ onView(withId(R.id.activate_fetched_button)).perform(click());
+ onView(withId(R.id.api_call_results))
+ .check(
+ matches(withText(allOf(containsString("activateFetched"), containsString("false!")))));
+ }
+
+ @Test
+ public void fetchAndActivateFetchedTwice_activateFetchedReturnsFalse()
+ throws InterruptedException {
+
+ onView(withId(R.id.fetch_button)).perform(click());
+
+ onView(withId(R.id.activate_fetched_button)).perform(click());
+ onView(withId(R.id.api_call_results))
+ .check(
+ matches(
+ withText(allOf(containsString("activateFetched"), containsString("successful!")))));
+
+ onView(withId(R.id.activate_fetched_button)).perform(click());
+ onView(withId(R.id.api_call_results))
+ .check(
+ matches(withText(allOf(containsString("activateFetched"), containsString("false!")))));
+ }
+
+ @Test
+ public void fetchAndGetString_returnsStaticDefault() throws InterruptedException {
+
+ verifyKeyValuePair(KEY_FOR_STRING, STRING_TYPE, STRING_STATIC_DEFAULT_VALUE);
+
+ onView(withId(R.id.fetch_button)).perform(click());
+
+ verifyKeyValuePair(KEY_FOR_STRING, STRING_TYPE, STRING_STATIC_DEFAULT_VALUE);
+ }
+
+ @Test
+ public void fetchActivateFetchedAndGetString_returnsRemoteValue() throws InterruptedException {
+
+ verifyKeyValuePair(KEY_FOR_STRING, STRING_TYPE, STRING_STATIC_DEFAULT_VALUE);
+
+ onView(withId(R.id.fetch_button)).perform(click());
+ onView(withId(R.id.activate_fetched_button)).perform(click());
+
+ verifyKeyValuePair(KEY_FOR_STRING, STRING_TYPE, STRING_REMOTE_DEFAULT_VALUE);
+ }
+
+ private void verifyKeyValuePair(String key, String dataType, String expectedValue)
+ throws InterruptedException {
+
+ onView(withId(R.id.frc_parameter_key)).perform(click(), clearText(), typeText(key));
+ onView(withText("Get " + dataType)).perform(click());
+
+ String expectedResult = String.format("%s: (%s, %s)", dataType, key, expectedValue);
+ onView(withId(R.id.frc_parameter_value))
+ .check(matches(withText(containsString(expectedResult))));
+ }
+
+ @After
+ public void cleanUp() {
+ clearIdlingResources();
+ clearCacheFiles();
+ }
+
+ private void clearIdlingResources() {
+ IdlingRegistry.getInstance().unregister(idlingResource);
+ }
+
+ private void clearCacheFiles() {
+ Context context = activityTestRule.getActivity().getApplicationContext();
+
+ for (String fileName : context.fileList()) {
+ context.deleteFile(fileName);
+ }
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/AndroidManifest.xml b/firebase-remote-config/bandwagoner/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..4b53a6c5b1b
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/AndroidManifest.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/ApiFragment.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/ApiFragment.java
new file mode 100644
index 00000000000..8b2aea37957
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/ApiFragment.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import static com.googletest.firebase.remoteconfig.bandwagoner.Constants.TAG;
+import static com.googletest.firebase.remoteconfig.bandwagoner.TimeFormatHelper.getCurrentTimeString;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.ToggleButton;
+import androidx.annotation.IdRes;
+import androidx.fragment.app.Fragment;
+import com.google.android.gms.tasks.Task;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfig;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings;
+
+/**
+ * The main layout and logic for running Firebase Remote Config (FRC) API calls and displaying their
+ * results.
+ *
+ * @author Miraziz Yusupov
+ */
+public class ApiFragment extends Fragment {
+
+ private FirebaseRemoteConfig frc;
+ private View rootView;
+ private EditText minimumFetchIntervalText;
+ private EditText parameterKeyText;
+ private TextView parameterValueText;
+ private TextView apiCallProgressText;
+ private TextView apiCallResultsText;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ frc = FirebaseRemoteConfig.getInstance();
+ frc.setConfigSettings(
+ new FirebaseRemoteConfigSettings.Builder().setDeveloperModeEnabled(true).build());
+ }
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+
+ rootView = inflater.inflate(R.layout.api_fragment, container, false);
+
+ addListenerToButton(R.id.fetch_button, this::onFetch);
+ addListenerToButton(R.id.activate_fetched_button, this::onActivateFetched);
+ addListenerToButton(R.id.get_string_button, this::onGetString);
+ addListenerToButton(R.id.reset_frc_button, this::onReset);
+
+ minimumFetchIntervalText = rootView.findViewById(R.id.frc_minimum_fetch_interval);
+ parameterKeyText = rootView.findViewById(R.id.frc_parameter_key);
+ parameterValueText = rootView.findViewById(R.id.frc_parameter_value);
+ apiCallProgressText = rootView.findViewById(R.id.api_call_progress);
+ apiCallResultsText = rootView.findViewById(R.id.api_call_results);
+
+ ToggleButton devModeButton = rootView.findViewById(R.id.dev_mode_toggle_button);
+ devModeButton.setOnCheckedChangeListener((unusedView, isChecked) -> onDevModeToggle(isChecked));
+ devModeButton.toggle();
+
+ TextView sdkVersionText = rootView.findViewById(R.id.sdk_version_text);
+
+ TextView iidText = rootView.findViewById(R.id.iid_text);
+ iidText.setText("IID: " + FirebaseInstanceId.getInstance().getId());
+
+ apiCallResultsText.setText(FirebaseInstanceId.getInstance().getToken());
+
+ return rootView;
+ }
+
+ /** Adds the given {@link OnClickListener} to the button specified by {@code buttonResourceId}. */
+ private void addListenerToButton(@IdRes int buttonResourceId, OnClickListener onClickListener) {
+ rootView.findViewById(buttonResourceId).setOnClickListener(onClickListener);
+ }
+
+ /** Sets the version of the FRC server the SDK fetches from. */
+ @SuppressWarnings("FirebaseUseExplicitDependencies")
+ private void onDevModeToggle(boolean isChecked) {
+ hideSoftKeyboard();
+
+ frc.setConfigSettings(
+ new FirebaseRemoteConfigSettings.Builder().setDeveloperModeEnabled(isChecked).build());
+ }
+
+ /**
+ * Fetches configs from the FRC server.
+ *
+ * Logs the result of the operation in the {@code api_call_results} {@link TextView}.
+ */
+ private void onFetch(View unusedView) {
+ hideSoftKeyboard();
+
+ IdlingResourceManager.getInstance().increment();
+
+ String minimumFetchIntervalString = minimumFetchIntervalText.getText().toString();
+
+ apiCallProgressText.setText("Fetching...");
+ Task fetchTask;
+ if (!minimumFetchIntervalString.isEmpty()) {
+ fetchTask =
+ frc.fetch(
+ /* minimumFetchIntervalInSeconds= */ Integer.valueOf(minimumFetchIntervalString));
+ } else {
+ fetchTask = frc.fetch();
+ }
+
+ fetchTask.addOnCompleteListener(
+ (completedFetchTask) -> {
+ if (isFragmentDestroyed()) {
+ Log.w(TAG, "Fragment was destroyed before fetch was completed.");
+ IdlingResourceManager.getInstance().decrement();
+ return;
+ }
+
+ String currentTimeString = getCurrentTimeString();
+ if (completedFetchTask.isSuccessful()) {
+ apiCallResultsText.setText(
+ String.format("%s - Fetch was successful!", currentTimeString));
+ Log.i(TAG, "Fetch was successful!");
+ } else {
+ apiCallResultsText.setText(
+ String.format(
+ "%s - Fetch failed with exception: %s",
+ currentTimeString, completedFetchTask.getException()));
+ Log.e(TAG, "Fetch failed!", completedFetchTask.getException());
+ }
+
+ apiCallProgressText.setText("");
+ IdlingResourceManager.getInstance().decrement();
+ });
+ }
+
+ /**
+ * Activates the most recently fetched configs.
+ *
+ * Logs the result of the operation in the {@code api_call_results} {@link TextView}.
+ */
+ private void onActivateFetched(View unusedView) {
+ hideSoftKeyboard();
+
+ boolean activated = frc.activateFetched();
+ apiCallResultsText.setText(
+ String.format(
+ "%s - activateFetched %s!",
+ getCurrentTimeString(), activated ? "was successful" : "returned false"));
+ }
+
+ /**
+ * Gets an FRC parameter value in a {@link String} format.
+ *
+ *
Sets the {@code frc_parameter_value} {@link TextView} to the value of the FRC parameter for
+ * the key in the {@code frc_parameter_key} {@link EditText}.
+ */
+ private void onGetString(View unusedView) {
+ hideSoftKeyboard();
+
+ String paramKey = parameterKeyText.getText().toString();
+ String paramValue = frc.getString(paramKey);
+
+ parameterValueText.setText(
+ String.format("%s - String: (%s, %s)", getCurrentTimeString(), paramKey, paramValue));
+ }
+
+ /** Resets all FRC configs and settings, both in memory and disk. */
+ private void onReset(View unusedView) {
+ hideSoftKeyboard();
+
+ IdlingResourceManager.getInstance().increment();
+
+ apiCallProgressText.setText("Resetting...");
+ frc.reset()
+ .addOnCompleteListener(
+ (resetTask) -> {
+ if (isFragmentDestroyed()) {
+ Log.w(TAG, "Fragment was destroyed before fetch was completed.");
+ IdlingResourceManager.getInstance().decrement();
+ return;
+ }
+
+ String currentTimeString = getCurrentTimeString();
+ if (resetTask.isSuccessful()) {
+ // Reset means dev mode was turned off.
+ ((ToggleButton) rootView.findViewById(R.id.dev_mode_toggle_button))
+ .setChecked(false);
+
+ apiCallResultsText.setText(
+ String.format("%s - Reset was successful!", currentTimeString));
+ Log.i(TAG, "Reset was successful!");
+ } else {
+ apiCallResultsText.setText(
+ String.format(
+ "%s - Reset failed with exception: %s",
+ currentTimeString, resetTask.getException()));
+ Log.e(TAG, "Reset failed!", resetTask.getException());
+ }
+
+ apiCallProgressText.setText("");
+ IdlingResourceManager.getInstance().decrement();
+ });
+ }
+
+ /** Hides the soft keyboard, usually after clicking a button. */
+ public void hideSoftKeyboard() {
+ Activity activity = getActivity();
+ if (activity == null || activity.getCurrentFocus() == null) {
+ return;
+ }
+
+ InputMethodManager inputMethodManager =
+ (InputMethodManager) activity.getSystemService(Activity.INPUT_METHOD_SERVICE);
+ inputMethodManager.hideSoftInputFromWindow(activity.getCurrentFocus().getWindowToken(), 0);
+ }
+
+ private boolean isFragmentDestroyed() {
+ return this.isRemoving()
+ || this.getActivity() == null
+ || this.isDetached()
+ || !this.isAdded()
+ || this.getView() == null;
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerFragment.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerFragment.java
new file mode 100644
index 00000000000..6cb85303944
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerFragment.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import android.widget.TextView;
+import androidx.fragment.app.Fragment;
+
+/**
+ * Fragment with text views for showing results of async operations.
+ *
+ * @author Miraziz Yusupov
+ */
+public class BandwagonerFragment extends Fragment {
+ TextView callProgressText;
+ TextView callResultsText;
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/Constants.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/Constants.java
new file mode 100644
index 00000000000..e795c79d49d
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/Constants.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+/**
+ * Constants shared between all Firebase Remote Config (FRC) SDK classes.
+ *
+ * @author Miraziz Yusupov
+ */
+class Constants {
+ public static final String TAG = "Bandwagoner";
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/IdlingResourceManager.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/IdlingResourceManager.java
new file mode 100644
index 00000000000..7418c80e1f0
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/IdlingResourceManager.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import androidx.test.espresso.idling.CountingIdlingResource;
+
+/**
+ * Manager for a singleton instance of {@link CountingIdlingResource}.
+ *
+ * @author Miraziz Yusupov
+ */
+public class IdlingResourceManager {
+ private static CountingIdlingResource idlingResource;
+
+ public static CountingIdlingResource getInstance() {
+ if (idlingResource == null) {
+ idlingResource =
+ new CountingIdlingResource("BandwagonerIdlingResource", /*debugCounting=*/ true);
+ }
+ return idlingResource;
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainActivity.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainActivity.java
new file mode 100644
index 00000000000..3fee1b21350
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainActivity.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.viewpager.widget.PagerAdapter;
+import androidx.viewpager.widget.ViewPager;
+import com.google.android.material.tabs.TabLayout;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+
+/**
+ * A tabbed activity that has a primary tab with a {@link ApiFragment} for testing the Firebase
+ * Remote Config (FRC) SDK API.
+ *
+ * @author Miraziz Yusupov
+ */
+public class MainActivity extends AppCompatActivity {
+
+ private static final ImmutableList TABS =
+ ImmutableList.of(Tab.create("Api", new ApiFragment()));
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
+ toolbar.setTitle(R.string.app_name);
+ setSupportActionBar(toolbar);
+
+ setupTabs();
+ }
+
+ private void setupTabs() {
+
+ PagerAdapter pagerAdapter =
+ new FragmentPagerAdapter(getSupportFragmentManager()) {
+ @Override
+ public int getCount() {
+ return TABS.size();
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ return TABS.get(position).fragment();
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ return TABS.get(position).name();
+ }
+ };
+
+ ViewPager pages = (ViewPager) findViewById(R.id.view_pager);
+ pages.setAdapter(pagerAdapter);
+ pages.setCurrentItem(0);
+
+ TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);
+ tabLayout.setupWithViewPager(pages);
+ }
+
+ @AutoValue
+ abstract static class Tab {
+ static Tab create(String name, Fragment fragment) {
+ return new AutoValue_MainActivity_Tab(name, fragment);
+ }
+
+ abstract String name();
+
+ abstract Fragment fragment();
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainApplication.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainApplication.java
new file mode 100644
index 00000000000..3675a36a0b3
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainApplication.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import android.app.Application;
+import com.google.firebase.FirebaseApp;
+
+/**
+ * Initial logic for the App; used to start the {@link FirebaseApp} when the App starts.
+ *
+ * @author Miraziz Yusupov
+ */
+public class MainApplication extends Application {
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ FirebaseApp.initializeApp(this);
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TaskHelper.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TaskHelper.java
new file mode 100644
index 00000000000..692fd9aa3a5
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TaskHelper.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import static com.googletest.firebase.remoteconfig.bandwagoner.Constants.TAG;
+import static com.googletest.firebase.remoteconfig.bandwagoner.TimeFormatHelper.getCurrentTimeString;
+
+import android.util.Log;
+import androidx.fragment.app.Fragment;
+import com.google.android.gms.tasks.Task;
+
+/**
+ * Helper methods for dealing with the interactions between Tasks and Views.
+ *
+ * @author Miraziz Yusupov
+ */
+public class TaskHelper {
+
+ static void addDebugOnCompleteListener(
+ Task> task, BandwagonerFragment fragment, String taskName) {
+
+ task.addOnCompleteListener(
+ (unusedVoid) -> {
+ if (isFragmentDestroyed(fragment)) {
+ Log.w(TAG, "Fragment was destroyed before " + taskName + " was completed.");
+ IdlingResourceManager.getInstance().decrement();
+ return;
+ }
+
+ String currentTimeString = getCurrentTimeString();
+ if (task.isSuccessful()) {
+ fragment.callResultsText.setText(
+ String.format(
+ "%s - %s task was successful with return value: %s!",
+ currentTimeString, taskName, task.getResult()));
+ Log.i(TAG, taskName + " task was successful! Return value: " + task.getResult());
+ } else {
+ fragment.callResultsText.setText(
+ String.format(
+ "%s - %s task failed with exception: %s",
+ currentTimeString, taskName, task.getException()));
+ Log.e(TAG, taskName + " task failed!", task.getException());
+ }
+
+ fragment.callProgressText.setText("");
+ IdlingResourceManager.getInstance().decrement();
+ });
+ }
+
+ static boolean isFragmentDestroyed(Fragment fragment) {
+ return fragment.isRemoving()
+ || fragment.getActivity() == null
+ || fragment.isDetached()
+ || !fragment.isAdded()
+ || fragment.getView() == null;
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TimeFormatHelper.java b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TimeFormatHelper.java
new file mode 100644
index 00000000000..dc393103f6e
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TimeFormatHelper.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.googletest.firebase.remoteconfig.bandwagoner;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+
+/**
+ * Utility class for time-related helper methods.
+ *
+ * @author Miraziz Yusupov
+ */
+final class TimeFormatHelper {
+
+ private static final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss");
+
+ private TimeFormatHelper() {}
+
+ /** Returns the current time in a human readable format. */
+ static String getCurrentTimeString() {
+ return timeFormat.format(Calendar.getInstance().getTime());
+ }
+}
diff --git a/firebase-remote-config/bandwagoner/src/main/res/layout/activity_main.xml b/firebase-remote-config/bandwagoner/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000000..ea8de86100b
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/layout/activity_main.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/bandwagoner/src/main/res/layout/analytics_fragment.xml b/firebase-remote-config/bandwagoner/src/main/res/layout/analytics_fragment.xml
new file mode 100644
index 00000000000..06ad13318ba
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/layout/analytics_fragment.xml
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/bandwagoner/src/main/res/layout/api_fragment.xml b/firebase-remote-config/bandwagoner/src/main/res/layout/api_fragment.xml
new file mode 100644
index 00000000000..ce5287f280b
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/layout/api_fragment.xml
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/bandwagoner/src/main/res/layout/settings_fragment.xml b/firebase-remote-config/bandwagoner/src/main/res/layout/settings_fragment.xml
new file mode 100644
index 00000000000..a9fef679eb8
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/layout/settings_fragment.xml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/bandwagoner/src/main/res/values/.gitignore b/firebase-remote-config/bandwagoner/src/main/res/values/.gitignore
new file mode 100644
index 00000000000..f69770c7d02
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/values/.gitignore
@@ -0,0 +1 @@
+google-services.xml
diff --git a/firebase-remote-config/bandwagoner/src/main/res/values/colors.xml b/firebase-remote-config/bandwagoner/src/main/res/values/colors.xml
new file mode 100644
index 00000000000..711b8064187
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/values/colors.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ #3F51B5
+ #303F9F
+ #FF4081
+
diff --git a/firebase-remote-config/bandwagoner/src/main/res/values/dimens.xml b/firebase-remote-config/bandwagoner/src/main/res/values/dimens.xml
new file mode 100644
index 00000000000..72172a5deb1
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/values/dimens.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ 16dp
+ 16dp
+ 16dp
+ 8dp
+
diff --git a/firebase-remote-config/bandwagoner/src/main/res/values/strings.xml b/firebase-remote-config/bandwagoner/src/main/res/values/strings.xml
new file mode 100644
index 00000000000..2d122cd2a3d
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/values/strings.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ Bandwagoner 3P
+
diff --git a/firebase-remote-config/bandwagoner/src/main/res/values/styles.xml b/firebase-remote-config/bandwagoner/src/main/res/values/styles.xml
new file mode 100644
index 00000000000..f12925e09ff
--- /dev/null
+++ b/firebase-remote-config/bandwagoner/src/main/res/values/styles.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/firebase-remote-config.gradle b/firebase-remote-config/firebase-remote-config.gradle
new file mode 100644
index 00000000000..e37988538a2
--- /dev/null
+++ b/firebase-remote-config/firebase-remote-config.gradle
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id 'firebase-library'
+ id 'com.google.protobuf'
+}
+
+firebaseLibrary {
+ testLab.enabled = true
+ publishSources = true
+}
+
+protobuf {
+ // Configure the protoc executable
+ protoc {
+ // Download from repositories
+ artifact = 'com.google.protobuf:protoc:3.4.0'
+ }
+ plugins {
+ javalite {
+ // The codegen for lite comes as a separate artifact
+ artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
+ }
+ }
+ generateProtoTasks {
+ all().each { task ->
+ task.builtins {
+ // In most cases you don't need the full Java output
+ // if you use the lite output.
+ remove java
+ }
+ task.plugins {
+ javalite {}
+ }
+ }
+ }
+}
+
+firebaseLibrary {
+ testLab.enabled = true
+ publishSources = true
+}
+
+android {
+ compileSdkVersion project.targetSdkVersion
+ defaultConfig {
+ minSdkVersion 16
+ targetSdkVersion project.targetSdkVersion
+ multiDexEnabled true
+ versionName version
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ sourceSets {
+ main {
+ proto {
+ srcDir 'src/proto'
+ }
+ }
+
+ androidTest.resources.srcDirs += ['src/androidTest/res']
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ implementation project(':firebase-common')
+ implementation project (':firebase-abt')
+
+ implementation ('com.google.firebase:firebase-iid:18.0.0') {
+ exclude group: "com.google.firebase", module: "firebase-common"
+ }
+ implementation 'com.google.firebase:firebase-measurement-connector:18.0.0'
+ implementation 'com.google.protobuf:protobuf-lite:3.0.1'
+
+ compileOnly 'com.google.code.findbugs:jsr305:3.0.2'
+
+ testImplementation 'org.mockito:mockito-core:2.25.0'
+ testImplementation 'com.google.truth:truth:0.44'
+ testImplementation 'junit:junit:4.12'
+ testImplementation 'org.robolectric:robolectric:4.2'
+ testImplementation "org.skyscreamer:jsonassert:1.5.0"
+
+ androidTestImplementation 'androidx.test:runner:1.2.0'
+ androidTestImplementation 'org.mockito:mockito-core:2.25.0'
+ androidTestImplementation 'com.google.truth:truth:0.44'
+
+ androidTestImplementation 'com.linkedin.dexmaker:dexmaker:2.25.0'
+ androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:2.25.0'
+ androidTestImplementation 'junit:junit:4.12'
+ androidTestImplementation "org.skyscreamer:jsonassert:1.5.0"
+}
diff --git a/firebase-remote-config/gradle.properties b/firebase-remote-config/gradle.properties
new file mode 100644
index 00000000000..97b35cf127d
--- /dev/null
+++ b/firebase-remote-config/gradle.properties
@@ -0,0 +1,20 @@
+#
+# Copyright 2018 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+#
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+version=17.0.1
+latestReleasedVersion=17.0.0
+android.enableUnitTestBinaryResources=true
+
diff --git a/firebase-remote-config/src/androidTest/AndroidManifest.xml b/firebase-remote-config/src/androidTest/AndroidManifest.xml
new file mode 100644
index 00000000000..8dfe46a3881
--- /dev/null
+++ b/firebase-remote-config/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/src/androidTest/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigIntegrationTest.java b/firebase-remote-config/src/androidTest/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigIntegrationTest.java
new file mode 100644
index 00000000000..e42de012132
--- /dev/null
+++ b/firebase-remote-config/src/androidTest/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigIntegrationTest.java
@@ -0,0 +1,177 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.res.Resources;
+import androidx.test.runner.AndroidJUnit4;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.Tasks;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.abt.FirebaseABTesting;
+import com.google.firebase.remoteconfig.internal.ConfigCacheClient;
+import com.google.firebase.remoteconfig.internal.ConfigContainer;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler;
+import com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.skyscreamer.jsonassert.JSONAssert;
+
+@RunWith(AndroidJUnit4.class)
+public class FirebaseRemoteConfigIntegrationTest {
+ private static final String API_KEY = "api_key";
+ private static final String APP_ID = "1:14368190084:android:09cb977358c6f241";
+
+ @Mock private ConfigCacheClient mockFetchedCache;
+ @Mock private ConfigCacheClient mockActivatedCache;
+ @Mock private ConfigCacheClient mockDefaultsCache;
+ @Mock private ConfigFetchHandler mockFetchHandler;
+ @Mock private ConfigGetParameterHandler mockGetHandler;
+ @Mock private ConfigMetadataClient metadataClient;
+
+ @Mock private ConfigCacheClient mockFireperfFetchedCache;
+ @Mock private ConfigCacheClient mockFireperfActivatedCache;
+
+ @Mock private FirebaseABTesting mockFirebaseAbt;
+ private FirebaseRemoteConfig frc;
+
+ // We use a HashMap so that Mocking is easier.
+ private static final HashMap DEFAULTS_MAP = Maps.newHashMap();
+
+ @Before
+ public void setUp() {
+ DEFAULTS_MAP.put("first_default_key", "first_default_value");
+ DEFAULTS_MAP.put("second_default_key", "second_default_value");
+ DEFAULTS_MAP.put("third_default_key", "third_default_value");
+
+ MockitoAnnotations.initMocks(this);
+ Executor directExecutor = MoreExecutors.directExecutor();
+
+ Context context = getInstrumentation().getTargetContext();
+ FirebaseApp.clearInstancesForTest();
+ FirebaseApp firebaseApp =
+ FirebaseApp.initializeApp(
+ context,
+ new FirebaseOptions.Builder().setApiKey(API_KEY).setApplicationId(APP_ID).build());
+
+ // Catch all to avoid NPEs (the getters should never return null).
+ when(mockFetchedCache.get()).thenReturn(Tasks.forResult(null));
+ when(mockActivatedCache.get()).thenReturn(Tasks.forResult(null));
+ when(mockFireperfFetchedCache.get()).thenReturn(Tasks.forResult(null));
+ when(mockFireperfActivatedCache.get()).thenReturn(Tasks.forResult(null));
+
+ frc =
+ new FirebaseRemoteConfig(
+ context,
+ firebaseApp,
+ mockFirebaseAbt,
+ directExecutor,
+ mockFetchedCache,
+ mockActivatedCache,
+ mockDefaultsCache,
+ mockFetchHandler,
+ mockGetHandler,
+ metadataClient);
+ }
+
+ @Test
+ public void setDefaults_goodXml_setsDefaults() throws Exception {
+ ConfigContainer goodDefaultsXmlContainer = newDefaultsContainer(DEFAULTS_MAP);
+
+ frc.setDefaults(getResourceId("frc_good_defaults"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+ verify(mockDefaultsCache).putWithoutWaitingForDiskWrite(captor.capture());
+
+ JSONAssert.assertEquals(
+ captor.getValue().toString(), goodDefaultsXmlContainer.toString(), false);
+ }
+
+ @Test
+ public void setDefaultsAsync_goodXml_setsDefaults() throws Exception {
+ ConfigContainer goodDefaultsXmlContainer = newDefaultsContainer(DEFAULTS_MAP);
+ cachePutReturnsConfig(mockDefaultsCache, goodDefaultsXmlContainer);
+
+ Task task = frc.setDefaultsAsync(getResourceId("frc_good_defaults"));
+ Tasks.await(task);
+
+ // Assert defaults were set correctly.
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+ verify(mockDefaultsCache).put(captor.capture());
+ assertThat(captor.getValue()).isEqualTo(goodDefaultsXmlContainer);
+ }
+
+ @Test
+ public void setDefaults_emptyXml_setsEmptyDefaults() throws Exception {
+ ConfigContainer emptyDefaultsXmlContainer = newDefaultsContainer(ImmutableMap.of());
+
+ frc.setDefaults(getResourceId("frc_empty_defaults"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+ verify(mockDefaultsCache).putWithoutWaitingForDiskWrite(captor.capture());
+
+ assertThat(captor.getValue()).isEqualTo(emptyDefaultsXmlContainer);
+ }
+
+ @Test
+ public void setDefaults_badXml_ignoresBadEntries() throws Exception {
+ ConfigContainer badDefaultsXmlContainer =
+ newDefaultsContainer(ImmutableMap.of("second_default_key", "second_default_value"));
+
+ frc.setDefaults(getResourceId("frc_bad_defaults"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+ verify(mockDefaultsCache).putWithoutWaitingForDiskWrite(captor.capture());
+
+ assertThat(captor.getValue()).isEqualTo(badDefaultsXmlContainer);
+ }
+
+ private static void cachePutReturnsConfig(
+ ConfigCacheClient cacheClient, ConfigContainer container) {
+ when(cacheClient.put(container)).thenReturn(Tasks.forResult(container));
+ }
+
+ private static ConfigContainer newDefaultsContainer(Map configsMap)
+ throws Exception {
+ return ConfigContainer.newBuilder()
+ .replaceConfigsWith(configsMap)
+ .withFetchTime(new Date(0L))
+ .build();
+ }
+
+ private static int getResourceId(String xmlResourceName) {
+ Resources r = getInstrumentation().getTargetContext().getResources();
+ return r.getIdentifier(
+ xmlResourceName, "xml", getInstrumentation().getTargetContext().getPackageName());
+ }
+}
diff --git a/firebase-remote-config/src/androidTest/res/xml/frc_bad_defaults.xml b/firebase-remote-config/src/androidTest/res/xml/frc_bad_defaults.xml
new file mode 100644
index 00000000000..91d5baacfab
--- /dev/null
+++ b/firebase-remote-config/src/androidTest/res/xml/frc_bad_defaults.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ first_default_key
+ first_default_value
+
+
+ second_default_key
+ second_default_value
+
+
+ third_default_key
+ third_default_value
+
+
+ fourth_default_key
+ fourth_default_value
+
+
+
+ fifth_default_value
+ fifth_default_key
+
+
+
+
+
diff --git a/firebase-remote-config/src/androidTest/res/xml/frc_empty_defaults.xml b/firebase-remote-config/src/androidTest/res/xml/frc_empty_defaults.xml
new file mode 100644
index 00000000000..93d5367677e
--- /dev/null
+++ b/firebase-remote-config/src/androidTest/res/xml/frc_empty_defaults.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/src/androidTest/res/xml/frc_good_defaults.xml b/firebase-remote-config/src/androidTest/res/xml/frc_good_defaults.xml
new file mode 100644
index 00000000000..787026c499f
--- /dev/null
+++ b/firebase-remote-config/src/androidTest/res/xml/frc_good_defaults.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ first_default_key
+ first_default_value
+
+
+ second_default_key
+ second_default_value
+
+
+ third_default_key
+ third_default_value
+
+
\ No newline at end of file
diff --git a/firebase-remote-config/src/main/AndroidManifest.xml b/firebase-remote-config/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..bd731ed658e
--- /dev/null
+++ b/firebase-remote-config/src/main/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
new file mode 100644
index 00000000000..507acc81e29
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
@@ -0,0 +1,741 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import android.content.Context;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+import androidx.annotation.XmlRes;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.Tasks;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.abt.AbtException;
+import com.google.firebase.abt.FirebaseABTesting;
+import com.google.firebase.remoteconfig.internal.ConfigCacheClient;
+import com.google.firebase.remoteconfig.internal.ConfigContainer;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler.FetchResponse;
+import com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient;
+import com.google.firebase.remoteconfig.internal.DefaultsXmlParser;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Entry point for the Firebase Remote Config (FRC) API.
+ *
+ * Callers should first get the singleton object using {@link #getInstance()}, and then call
+ * operations on that singleton object. The singleton contains the complete set of FRC parameter
+ * values available to your app. The singleton also stores values fetched from the FRC Server until
+ * they are made available for use with a call to {@link #activate()}.
+ *
+ * @author Miraziz Yusupov
+ */
+public class FirebaseRemoteConfig {
+ // -------------------------------------------------------------------------------
+ // Firebase Android Components logic.
+
+ /**
+ * Returns a singleton instance of Firebase Remote Config.
+ *
+ *
{@link FirebaseRemoteConfig} uses the default {@link FirebaseApp}, so if no {@link
+ * FirebaseApp} has been initialized yet, this method throws an {@link IllegalStateException}.
+ *
+ *
To identify the current app instance, the fetch request creates a Firebase Instance ID
+ * token, which periodically sends data to the Firebase backend. To stop the periodic sync, call
+ * {@link com.google.firebase.iid.FirebaseInstanceId#deleteInstanceId}. To create a new token and
+ * resume the periodic sync, call {@code fetchConfig} again.
+ *
+ * @return A singleton instance of {@link FirebaseRemoteConfig} for the default {@link
+ * FirebaseApp}.
+ */
+ public static FirebaseRemoteConfig getInstance() {
+ return getInstance(FirebaseApp.getInstance());
+ }
+
+ /** Returns an instance of Firebase Remote Config for the given {@link FirebaseApp}. */
+ public static FirebaseRemoteConfig getInstance(FirebaseApp app) {
+ return app.get(RemoteConfigComponent.class).getDefault();
+ }
+
+ // -------------------------------------------------------------------------------
+ // Firebase Remote Config logic.
+
+ /** The static default string value for any given key. */
+ public static final String DEFAULT_VALUE_FOR_STRING = "";
+ /** The static default long value for any given key. */
+ public static final long DEFAULT_VALUE_FOR_LONG = 0L;
+ /** The static default double value for any given key. */
+ public static final double DEFAULT_VALUE_FOR_DOUBLE = 0D;
+ /** The static default boolean value for any given key. */
+ public static final boolean DEFAULT_VALUE_FOR_BOOLEAN = false;
+ /** The static default byte array value for any given key. */
+ public static final byte[] DEFAULT_VALUE_FOR_BYTE_ARRAY = new byte[0];
+
+ /** Indicates that the value returned is the static default value. */
+ public static final int VALUE_SOURCE_STATIC = 0;
+ /** Indicates that the value returned was retrieved from the defaults set by the client. */
+ public static final int VALUE_SOURCE_DEFAULT = 1;
+ /** Indicates that the value returned was retrieved from the Firebase Remote Config Server. */
+ public static final int VALUE_SOURCE_REMOTE = 2;
+
+ /**
+ * Indicates that the most recent fetch of parameter values from the Firebase Remote Config Server
+ * was completed successfully.
+ */
+ public static final int LAST_FETCH_STATUS_SUCCESS = -1;
+ /**
+ * Indicates that the FirebaseRemoteConfig singleton object has not yet attempted to fetch
+ * parameter values from the Firebase Remote Config Server.
+ */
+ public static final int LAST_FETCH_STATUS_NO_FETCH_YET = 0;
+ /**
+ * Indicates that the most recent attempt to fetch parameter values from the Firebase Remote
+ * Config Server has failed.
+ */
+ public static final int LAST_FETCH_STATUS_FAILURE = 1;
+ /**
+ * Indicates that the most recent attempt to fetch parameter values from the Firebase Remote
+ * Config Server was throttled.
+ */
+ public static final int LAST_FETCH_STATUS_THROTTLED = 2;
+
+ /**
+ * The general logging tag for all Firebase Remote Config logs.
+ *
+ * @hide
+ */
+ public static final String TAG = "FirebaseRemoteConfig";
+
+ private final Context context;
+ private final FirebaseApp firebaseApp;
+ /**
+ * Firebase A/B Testing (ABT) is only valid for the 3P namespace, so the ABT variable will be null
+ * if the current instance of Firebase Remote Config is using a non-3P namespace.
+ */
+ @Nullable private final FirebaseABTesting firebaseAbt;
+
+ private final Executor executor;
+ private final ConfigCacheClient fetchedConfigsCache;
+ private final ConfigCacheClient activatedConfigsCache;
+ private final ConfigCacheClient defaultConfigsCache;
+ private final ConfigFetchHandler fetchHandler;
+ private final ConfigGetParameterHandler getHandler;
+ private final ConfigMetadataClient frcMetadata;
+
+ /**
+ * Firebase Remote Config constructor.
+ *
+ * @hide
+ */
+ FirebaseRemoteConfig(
+ Context context,
+ FirebaseApp firebaseApp,
+ @Nullable FirebaseABTesting firebaseAbt,
+ Executor executor,
+ ConfigCacheClient fetchedConfigsCache,
+ ConfigCacheClient activatedConfigsCache,
+ ConfigCacheClient defaultConfigsCache,
+ ConfigFetchHandler fetchHandler,
+ ConfigGetParameterHandler getHandler,
+ ConfigMetadataClient frcMetadata) {
+ this.context = context;
+ this.firebaseApp = firebaseApp;
+ this.firebaseAbt = firebaseAbt;
+ this.executor = executor;
+ this.fetchedConfigsCache = fetchedConfigsCache;
+ this.activatedConfigsCache = activatedConfigsCache;
+ this.defaultConfigsCache = defaultConfigsCache;
+ this.fetchHandler = fetchHandler;
+ this.getHandler = getHandler;
+ this.frcMetadata = frcMetadata;
+ }
+
+ /**
+ * Returns a {@link Task} representing the initialization status of this Firebase Remote Config
+ * instance.
+ */
+ public Task ensureInitialized() {
+ Task activatedConfigsTask = activatedConfigsCache.get();
+ Task defaultsConfigsTask = defaultConfigsCache.get();
+ Task fetchedConfigsTask = fetchedConfigsCache.get();
+ Task metadataTask = Tasks.call(executor, this::getInfo);
+
+ return Tasks.whenAllComplete(
+ activatedConfigsTask, defaultsConfigsTask, fetchedConfigsTask, metadataTask)
+ .continueWith(executor, (unusedListOfCompletedTasks) -> metadataTask.getResult());
+ }
+
+ /**
+ * Asynchronously fetches and then activates the fetched configs.
+ *
+ * If the time elapsed since the last fetch from the Firebase Remote Config backend is more
+ * than the default minimum fetch interval, configs are fetched from the backend.
+ *
+ *
After the fetch is complete, the configs are activated so that the fetched key value pairs
+ * take effect.
+ *
+ * @return {@link Task} with a {@code true} result if the current call activated the fetched
+ * configs; if no configs were fetched from the backend and the local fetched configs have
+ * already been activated, returns a {@link Task} with a {@code false} result.
+ */
+ public Task fetchAndActivate() {
+ return fetch().onSuccessTask(executor, (unusedVoid) -> activate());
+ }
+
+ /**
+ * Activates the most recently fetched configs, so that the fetched key value pairs take effect.
+ *
+ * @return True if the current call activated the fetched configs; false if the fetched configs
+ * were already activated by a previous call.
+ * @deprecated Use {@link #activate()} instead.
+ */
+ @Deprecated
+ @WorkerThread
+ public boolean activateFetched() {
+ @Nullable ConfigContainer fetchedContainer = fetchedConfigsCache.getBlocking();
+ if (fetchedContainer == null) {
+ return false;
+ }
+
+ // If the activated configs exist, verify that the fetched configs are fresher.
+ @Nullable ConfigContainer activatedContainer = activatedConfigsCache.getBlocking();
+ if (!isFetchedFresh(fetchedContainer, activatedContainer)) {
+ return false;
+ }
+
+ // Write the newly activated configs to disk, and then clear the fetched configs from disk.
+ // Fire and forget call, so consistency between disk and memory is not guaranteed.
+ activatedConfigsCache
+ .putWithoutWaitingForDiskWrite(fetchedContainer)
+ .addOnSuccessListener(
+ executor,
+ newlyActivatedContainer -> {
+ fetchedConfigsCache.clear();
+ updateAbtWithActivatedExperiments(newlyActivatedContainer.getAbtExperiments());
+ });
+ return true;
+ }
+
+ /**
+ * Asynchronously activates the most recently fetched configs, so that the fetched key value pairs
+ * take effect.
+ *
+ * @return {@link Task} with a {@code true} result if the current call activated the fetched
+ * configs; if the fetched configs were already activated by a previous call, returns a {@link
+ * Task} with a {@code false} result.
+ */
+ public Task activate() {
+ Task fetchedConfigsTask = fetchedConfigsCache.get();
+ Task activatedConfigsTask = activatedConfigsCache.get();
+
+ return Tasks.whenAllComplete(fetchedConfigsTask, activatedConfigsTask)
+ .continueWithTask(
+ executor,
+ (unusedListOfCompletedTasks) -> {
+ if (!fetchedConfigsTask.isSuccessful() || fetchedConfigsTask.getResult() == null) {
+ return Tasks.forResult(false);
+ }
+ ConfigContainer fetchedContainer = fetchedConfigsTask.getResult();
+
+ // If the activated configs exist, verify that the fetched configs are fresher.
+ if (activatedConfigsTask.isSuccessful()) {
+ @Nullable ConfigContainer activatedContainer = activatedConfigsTask.getResult();
+ if (!isFetchedFresh(fetchedContainer, activatedContainer)) {
+ return Tasks.forResult(false);
+ }
+ }
+
+ return activatedConfigsCache
+ .put(fetchedContainer)
+ .continueWith(executor, this::processActivatePutTask);
+ });
+ }
+
+ /**
+ * Starts fetching configs, adhering to the default minimum fetch interval.
+ *
+ * The fetched configs only take effect after the next {@link #activate} call.
+ *
+ *
Depending on the time elapsed since the last fetch from the Firebase Remote Config backend,
+ * configs are either served from local storage, or fetched from the backend. The default minimum
+ * fetch interval can be set with {@code
+ * FirebaseRemoteConfigSettings.Builder#setMinimumFetchIntervalInSeconds(long)}; the static
+ * default is 12 hours.
+ *
+ *
To identify the current app instance, the fetch request creates a Firebase Instance ID
+ * token, which periodically sends data to the Firebase backend. To stop the periodic sync, call
+ * {@link com.google.firebase.iid.FirebaseInstanceId#deleteInstanceId}. To create a new token and
+ * resume the periodic sync, call {@code fetchConfig} again.
+ *
+ * @return {@link Task} representing the {@code fetch} call.
+ */
+ public Task fetch() {
+ Task fetchTask = fetchHandler.fetch();
+
+ // Convert Task type to Void.
+ return fetchTask.onSuccessTask((unusedFetchResponse) -> Tasks.forResult(null));
+ }
+
+ /**
+ * Starts fetching configs, adhering to the specified minimum fetch interval.
+ *
+ * The fetched configs only take effect after the next {@link #activate()} call.
+ *
+ *
Depending on the time elapsed since the last fetch from the Firebase Remote Config backend,
+ * configs are either served from local storage, or fetched from the backend.
+ *
+ *
To identify the current app instance, the fetch request creates a Firebase Instance ID
+ * token, which periodically sends data to the Firebase backend. To stop the periodic sync, call
+ * {@link com.google.firebase.iid.FirebaseInstanceId#deleteInstanceId}. To create a new token and
+ * resume the periodic sync, call {@code fetchConfig} again.
+ *
+ * @param minimumFetchIntervalInSeconds If configs in the local storage were fetched more than
+ * this many seconds ago, configs are served from the backend instead of local storage.
+ * @return {@link Task} representing the {@code fetch} call.
+ */
+ public Task fetch(long minimumFetchIntervalInSeconds) {
+ Task fetchTask = fetchHandler.fetch(minimumFetchIntervalInSeconds);
+
+ // Convert Task type to Void.
+ return fetchTask.onSuccessTask((unusedFetchResponse) -> Tasks.forResult(null));
+ }
+
+ /**
+ * Returns the parameter value for the given key as a {@link String}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The activated value, if the last successful {@link #activate()} contained the key.
+ * The default value, if the key was set with {@link #setDefaultsAsync}.
+ * {@link #DEFAULT_VALUE_FOR_STRING}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key.
+ * @return {@link String} representing the value of the Firebase Remote Config parameter with the
+ * given key.
+ */
+ public String getString(String key) {
+ return getHandler.getString(key);
+ }
+
+ /**
+ * Returns the parameter value for the given key as a {@code boolean}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The activated value, if the last successful {@link #activate()} contained the key, and
+ * the value can be converted into a {@code boolean}.
+ * The default value, if the key was set with {@link #setDefaultsAsync}, and the value can
+ * be converted into a {@code boolean}.
+ * {@link #DEFAULT_VALUE_FOR_BOOLEAN}.
+ *
+ *
+ * "1", "true", "t", "yes", "y", and "on" are strings that are interpreted (case insensitive)
+ * as {@code true}, and "0", "false", "f", "no", "n", "off", and empty string are interpreted
+ * (case insensitive) as {@code false}.
+ *
+ * @param key A Firebase Remote Config parameter key with a {@code boolean} parameter value.
+ * @return {@code boolean} representing the value of the Firebase Remote Config parameter with the
+ * given key.
+ */
+ public boolean getBoolean(String key) {
+ return getHandler.getBoolean(key);
+ }
+
+ /**
+ * Returns the parameter value for the given key as a {@code byte[]}.
+ *
+ *
Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The activated value, if the last successful {@link #activate()} contained the key.
+ * The default value, if the key was set with {@link #setDefaultsAsync}.
+ * {@link #DEFAULT_VALUE_FOR_BYTE_ARRAY}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key.
+ * @return {@code byte[]} representing the value of the Firebase Remote Config parameter with the
+ * given key.
+ */
+ @Deprecated
+ public byte[] getByteArray(String key) {
+ return getHandler.getByteArray(key);
+ }
+
+ /**
+ * Returns the parameter value for the given key as a {@code double}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The activated value, if the last successful {@link #activate()} contained the key, and
+ * the value can be converted into a {@code double}.
+ * The default value, if the key was set with {@link #setDefaultsAsync}, and the value can
+ * be converted into a {@code double}.
+ * {@link #DEFAULT_VALUE_FOR_DOUBLE}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key with a {@code double} parameter value.
+ * @return {@code double} representing the value of the Firebase Remote Config parameter with the
+ * given key.
+ */
+ public double getDouble(String key) {
+ return getHandler.getDouble(key);
+ }
+
+ /**
+ * Returns the parameter value for the given key as a {@code long}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The activated value, if the last successful {@link #activate()} contained the key, and
+ * the value can be converted into a {@code long}.
+ * The default value, if the key was set with {@link #setDefaultsAsync}, and the value can
+ * be converted into a {@code long}.
+ * {@link #DEFAULT_VALUE_FOR_LONG}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key with a {@code long} parameter value.
+ * @return {@code long} representing the value of the Firebase Remote Config parameter with the
+ * given key.
+ */
+ public long getLong(String key) {
+ return getHandler.getLong(key);
+ }
+
+ /**
+ * Returns the parameter value for the given key as a {@link FirebaseRemoteConfigValue}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The activated value, if the last successful {@link #activate()} contained the key.
+ * The default value, if the key was set with {@link #setDefaultsAsync}.
+ * A {@link FirebaseRemoteConfigValue} that returns the static value for each type.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key.
+ * @return {@link FirebaseRemoteConfigValue} representing the value of the Firebase Remote Config
+ * parameter with the given key.
+ */
+ public FirebaseRemoteConfigValue getValue(String key) {
+ return getHandler.getValue(key);
+ }
+
+ /**
+ * Returns a {@link Set} of all Firebase Remote Config parameter keys with the given prefix.
+ *
+ * @param prefix The key prefix to look for. If the prefix is empty, all keys are returned.
+ * @return {@link Set} of Remote Config parameter keys that start with the specified prefix.
+ */
+ public Set getKeysByPrefix(String prefix) {
+ return getHandler.getKeysByPrefix(prefix);
+ }
+
+ /**
+ * Returns a {@link Map} of Firebase Remote Config key value pairs.
+ *
+ * Evaluates the values of the parameters in the following order:
+ *
+ *
+ * The activated value, if the last successful {@link #activate()} contained the key.
+ * The default value, if the key was set with {@link #setDefaultsAsync}.
+ *
+ */
+ public Map getAll() {
+ return getHandler.getAll();
+ }
+
+ /**
+ * Returns the state of this {@link FirebaseRemoteConfig} instance as a {@link
+ * FirebaseRemoteConfigInfo}.
+ */
+ public FirebaseRemoteConfigInfo getInfo() {
+ return frcMetadata.getInfo();
+ }
+
+ /**
+ * Changes the settings for this {@link FirebaseRemoteConfig} instance.
+ *
+ * @param settings The new settings to be applied.
+ * @deprecated Use {@link #setConfigSettingsAsync(FirebaseRemoteConfigSettings)} instead.
+ */
+ @Deprecated
+ public void setConfigSettings(FirebaseRemoteConfigSettings settings) {
+ frcMetadata.setConfigSettingsWithoutWaitingOnDiskWrite(settings);
+ }
+
+ /**
+ * Asynchronously changes the settings for this {@link FirebaseRemoteConfig} instance.
+ *
+ * @param settings The new settings to be applied.
+ */
+ public Task setConfigSettingsAsync(FirebaseRemoteConfigSettings settings) {
+ return Tasks.call(
+ executor,
+ () -> {
+ frcMetadata.setConfigSettings(settings);
+
+ // Return value required; return null for Void.
+ return null;
+ });
+ }
+
+ /**
+ * Sets default configs using the given {@link Map}.
+ *
+ * The values in {@code defaults} must be one of the following types:
+ *
+ *
+ * Long
+ * String
+ * Double
+ * byte[]
+ * Boolean
+ *
+ *
+ * @param defaults Map of key value pairs representing Firebase Remote Config parameter keys and
+ * values.
+ * @deprecated Use {@link #setDefaultsAsync} instead.
+ */
+ @Deprecated
+ public void setDefaults(Map defaults) {
+ // Fetch values from the server are in the Map format, so match that here.
+ Map defaultsStringMap = new HashMap<>();
+ for (Map.Entry defaultsEntry : defaults.entrySet()) {
+ defaultsStringMap.put(defaultsEntry.getKey(), defaultsEntry.getValue().toString());
+ }
+
+ setDefaultsWithStringsMap(defaultsStringMap);
+ }
+
+ /**
+ * Asynchronously sets default configs using the given {@link Map}.
+ *
+ * The values in {@code defaults} must be one of the following types:
+ *
+ *
+ * byte[]
+ * Boolean
+ * Double
+ * Long
+ * String
+ *
+ *
+ * @param defaults {@link Map} of key value pairs representing Firebase Remote Config parameter
+ * keys and values.
+ */
+ public Task setDefaultsAsync(Map defaults) {
+ // Fetch values from the server are in the Map format, so match that here.
+ Map defaultsStringMap = new HashMap<>();
+ for (Map.Entry defaultsEntry : defaults.entrySet()) {
+ defaultsStringMap.put(defaultsEntry.getKey(), defaultsEntry.getValue().toString());
+ }
+
+ return setDefaultsWithStringsMapAsync(defaultsStringMap);
+ }
+
+ /**
+ * Sets default configs using an XML resource.
+ *
+ * @param resourceId Id for the XML resource, which should be in your application's {@code
+ * res/xml} folder.
+ */
+ public void setDefaults(@XmlRes int resourceId) {
+ Map xmlDefaults = DefaultsXmlParser.getDefaultsFromXml(context, resourceId);
+ setDefaultsWithStringsMap(xmlDefaults);
+ }
+
+ /**
+ * Sets default configs using an XML resource.
+ *
+ * @param resourceId Id for the XML resource, which should be in your application's {@code
+ * res/xml} folder.
+ */
+ public Task setDefaultsAsync(@XmlRes int resourceId) {
+ Map xmlDefaults = DefaultsXmlParser.getDefaultsFromXml(context, resourceId);
+ return setDefaultsWithStringsMapAsync(xmlDefaults);
+ }
+
+ /**
+ * Deletes all activated, fetched and defaults configs and resets all Firebase Remote Config
+ * settings.
+ *
+ * @return {@link Task} representing the {@code clear} call.
+ */
+ public Task reset() {
+ // Use a Task to avoid throwing potential file I/O errors to the caller and because
+ // frcMetadata's clear call is blocking.
+ return Tasks.call(
+ executor,
+ () -> {
+ activatedConfigsCache.clear();
+ fetchedConfigsCache.clear();
+ defaultConfigsCache.clear();
+ frcMetadata.clear();
+ return null;
+ });
+ }
+
+ /**
+ * Loads all the configs from disk by calling {@link ConfigCacheClient#get} on each cache client.
+ *
+ * @hide
+ */
+ void startLoadingConfigsFromDisk() {
+ activatedConfigsCache.get();
+ defaultConfigsCache.get();
+ fetchedConfigsCache.get();
+ }
+
+ /**
+ * Processes the result of the put task that persists activated configs. If the task is
+ * successful, clears the fetched cache and updates the ABT SDK with the current experiments.
+ *
+ * @param putTask the {@link Task} returned by a {@link ConfigCacheClient#put(ConfigContainer)}
+ * call on {@link #activatedConfigsCache}.
+ * @return True if {@code putTask} was successful, false otherwise.
+ */
+ private boolean processActivatePutTask(Task putTask) {
+ if (putTask.isSuccessful()) {
+ fetchedConfigsCache.clear();
+
+ // An activate call should only be made if there are fetched values to activate, which are
+ // then put into the activated cache. So, if the put is called and succeeds, then the returned
+ // values from the put task must be non-null.
+ if (putTask.getResult() != null) {
+ updateAbtWithActivatedExperiments(putTask.getResult().getAbtExperiments());
+ } else {
+ // Should never happen.
+ Log.e(TAG, "Activated configs written to disk are null.");
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Asynchronously sets the defaults cache to the given default values, and persists the values to
+ * disk.
+ */
+ private void setDefaultsWithStringsMap(Map defaultsStringMap) {
+ try {
+ ConfigContainer defaultConfigs =
+ ConfigContainer.newBuilder().replaceConfigsWith(defaultsStringMap).build();
+ defaultConfigsCache.putWithoutWaitingForDiskWrite(defaultConfigs);
+ } catch (JSONException e) {
+ Log.e(TAG, "The provided defaults map could not be processed.", e);
+ }
+ }
+
+ /**
+ * Asynchronously sets the defaults cache to the given default values, and persists the values to
+ * disk.
+ *
+ * @return A task with result {@code null} on failure.
+ */
+ private Task setDefaultsWithStringsMapAsync(Map defaultsStringMap) {
+ ConfigContainer defaultConfigs = null;
+ try {
+ defaultConfigs = ConfigContainer.newBuilder().replaceConfigsWith(defaultsStringMap).build();
+ } catch (JSONException e) {
+ Log.e(TAG, "The provided defaults map could not be processed.", e);
+ return Tasks.forResult(null);
+ }
+
+ Task putTask = defaultConfigsCache.put(defaultConfigs);
+ // Convert Task type to Void.
+ return putTask.onSuccessTask((unusedContainer) -> Tasks.forResult(null));
+ }
+
+ /**
+ * Notifies the Firebase A/B Testing SDK about activated experiments.
+ *
+ * @hide
+ */
+ // TODO(issues/255): Find a cleaner way to test ABT component dependency without
+ // having to make this method visible.
+ @VisibleForTesting
+ void updateAbtWithActivatedExperiments(@NonNull JSONArray abtExperiments) {
+ if (firebaseAbt == null) {
+ // If there is no firebaseAbt instance, then this FRC is either in a non-3P namespace or
+ // in a non-main FirebaseApp, so there is no reason to call ABT.
+ // For more info: RemoteConfigComponent#isAbtSupported.
+ return;
+ }
+
+ try {
+ List> experimentInfoMaps = toExperimentInfoMaps(abtExperiments);
+ firebaseAbt.replaceAllExperiments(experimentInfoMaps);
+ } catch (JSONException e) {
+ Log.e(TAG, "Could not parse ABT experiments from the JSON response.", e);
+ } catch (AbtException e) {
+ // TODO(issues/256): Find a way to log errors for all non-Analytics related exceptions
+ // without coupling the FRC and ABT SDKs.
+ Log.w(TAG, "Could not update ABT experiments.", e);
+ }
+ }
+
+ /**
+ * Converts a JSON array of Firebase A/B Testing experiments into a Java list of String maps.
+ *
+ * @param abtExperimentsJson A {@link JSONArray} of {@link JSONObject}s, where each object
+ * represents a single experiment. Each {@link JSONObject} should only contain {@link String}
+ * values.
+ * @return A {@link List} of {@code {@link Map}}s, where each map represents a
+ * single experiment.
+ * @throws JSONException If the {@code abtExperimentsJson} could not be converted into a list of
+ * String maps.
+ */
+ @VisibleForTesting
+ static List> toExperimentInfoMaps(JSONArray abtExperimentsJson)
+ throws JSONException {
+ List> experimentInfoMaps = new ArrayList<>();
+ for (int index = 0; index < abtExperimentsJson.length(); index++) {
+ Map experimentInfo = new HashMap<>();
+
+ JSONObject abtExperimentJson = abtExperimentsJson.getJSONObject(index);
+ Iterator experimentJsonKeyIterator = abtExperimentJson.keys();
+ while (experimentJsonKeyIterator.hasNext()) {
+ String key = experimentJsonKeyIterator.next();
+ experimentInfo.put(key, abtExperimentJson.getString(key));
+ }
+
+ experimentInfoMaps.add(experimentInfo);
+ }
+ return experimentInfoMaps;
+ }
+
+ /** Returns true if the fetched configs are fresher than the activated configs. */
+ private static boolean isFetchedFresh(
+ ConfigContainer fetched, @Nullable ConfigContainer activated) {
+ return activated == null || !fetched.getFetchTime().equals(activated.getFetchTime());
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientException.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientException.java
new file mode 100644
index 00000000000..52fce97463e
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientException.java
@@ -0,0 +1,33 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+/**
+ * A Firebase Remote Config internal issue that isn't caused by an interaction with the Firebase
+ * Remote Config server.
+ *
+ * @author Miraziz Yusupov
+ */
+public class FirebaseRemoteConfigClientException extends FirebaseRemoteConfigException {
+ /** Creates a Firebase Remote Config client exception with the given message. */
+ public FirebaseRemoteConfigClientException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ /** Creates a Firebase Remote Config client exception with the given message and cause. */
+ public FirebaseRemoteConfigClientException(String detailMessage, Throwable cause) {
+ super(detailMessage, cause);
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigException.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigException.java
new file mode 100644
index 00000000000..0f5f5cf665d
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigException.java
@@ -0,0 +1,30 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import com.google.firebase.FirebaseException;
+
+/** Base class for {@link FirebaseRemoteConfig} exceptions. */
+public class FirebaseRemoteConfigException extends FirebaseException {
+ /** Creates a Firebase Remote Config exception with the given message. */
+ public FirebaseRemoteConfigException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ /** Creates a Firebase Remote Config exception with the given message and cause. */
+ public FirebaseRemoteConfigException(String detailMessage, Throwable cause) {
+ super(detailMessage, cause);
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchException.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchException.java
new file mode 100644
index 00000000000..1646e3bd221
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchException.java
@@ -0,0 +1,35 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+/**
+ * Exception thrown when the {@link FirebaseRemoteConfig#fetch()} operation cannot be completed
+ * successfully.
+ *
+ * @deprecated Use {@link FirebaseRemoteConfigServerException} or {@link
+ * FirebaseRemoteConfigClientException} instead.
+ */
+@Deprecated
+public class FirebaseRemoteConfigFetchException extends FirebaseRemoteConfigException {
+ /** Creates a Firebase Remote Config fetch exception with the given message. */
+ public FirebaseRemoteConfigFetchException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ /** Creates a Firebase Remote Config fetch exception with the given message and cause. */
+ public FirebaseRemoteConfigFetchException(String detailMessage, Throwable cause) {
+ super(detailMessage, cause);
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchThrottledException.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchThrottledException.java
new file mode 100644
index 00000000000..a715319da8f
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchThrottledException.java
@@ -0,0 +1,52 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+/** An exception thrown when a {@link FirebaseRemoteConfig#fetch()} call is throttled. */
+public class FirebaseRemoteConfigFetchThrottledException
+ extends FirebaseRemoteConfigFetchException {
+ private final long throttleEndTimeMillis;
+
+ /**
+ * Creates a throttled exception with the earliest time that a fetch call might be made without
+ * being throttled.
+ *
+ * @param throttleEndTimeMillis the time, in millis since epoch, until which all fetch calls will
+ * be throttled.
+ */
+ public FirebaseRemoteConfigFetchThrottledException(long throttleEndTimeMillis) {
+ this("Fetch was throttled.", throttleEndTimeMillis);
+ }
+
+ /**
+ * Creates a throttled exception with the given message and the earliest time that a fetch call
+ * might be made without being throttled.
+ *
+ * @param message The error message to display to callers.
+ * @param throttledEndTimeInMillis the time, in millis since epoch, until which all fetch calls
+ * will be throttled.
+ * @hide
+ */
+ public FirebaseRemoteConfigFetchThrottledException(
+ String message, long throttledEndTimeInMillis) {
+ super(message);
+ throttleEndTimeMillis = throttledEndTimeInMillis;
+ }
+
+ /** Returns the time, in millis since epoch, until which all fetch calls will be throttled. */
+ public long getThrottleEndTimeMillis() {
+ return throttleEndTimeMillis;
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigInfo.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigInfo.java
new file mode 100644
index 00000000000..601844c3d09
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigInfo.java
@@ -0,0 +1,44 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+/** Wraps the current state of the FirebaseRemoteConfig singleton object. */
+public interface FirebaseRemoteConfigInfo {
+ /**
+ * Gets the timestamp (milliseconds since epoch) of the last successful fetch, regardless of
+ * whether the fetch was activated or not.
+ *
+ * @return -1 if no fetch attempt has been made yet. Otherwise, returns the timestamp of the last
+ * successful fetch operation.
+ */
+ public long getFetchTimeMillis();
+
+ /**
+ * Gets the status of the most recent fetch attempt.
+ *
+ * @return Will return one of {@link FirebaseRemoteConfig#LAST_FETCH_STATUS_SUCCESS}, {@link
+ * FirebaseRemoteConfig#LAST_FETCH_STATUS_FAILURE}, {@link
+ * FirebaseRemoteConfig#LAST_FETCH_STATUS_THROTTLED}, or {@link
+ * FirebaseRemoteConfig#LAST_FETCH_STATUS_NO_FETCH_YET}.
+ */
+ public int getLastFetchStatus();
+
+ /**
+ * Gets the current settings of the FirebaseRemoteConfig singleton object.
+ *
+ * @return A {@link FirebaseRemoteConfigSettings} object indicating the current settings.
+ */
+ public FirebaseRemoteConfigSettings getConfigSettings();
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java
new file mode 100644
index 00000000000..4cf74401d5f
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java
@@ -0,0 +1,47 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+/**
+ * A Firebase Remote Config internal issue caused by an interaction with the Firebase Remote Config
+ * server.
+ *
+ * @author Miraziz Yusupov
+ */
+public class FirebaseRemoteConfigServerException extends FirebaseRemoteConfigException {
+ private final int httpStatusCode;
+
+ /**
+ * Creates a Firebase Remote Config server exception with the given message and HTTP status code.
+ */
+ public FirebaseRemoteConfigServerException(int httpStatusCode, String detailMessage) {
+ super(detailMessage);
+ this.httpStatusCode = httpStatusCode;
+ }
+
+ /**
+ * Creates a Firebase Remote Config server exception with the given message, HTTP status code and
+ */
+ public FirebaseRemoteConfigServerException(
+ int httpStatusCode, String detailMessage, Throwable cause) {
+ super(detailMessage, cause);
+ this.httpStatusCode = httpStatusCode;
+ }
+
+ /** Gets the HTTP status code of the failed Firebase Remote Config server operation. */
+ public int getHttpStatusCode() {
+ return httpStatusCode;
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettings.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettings.java
new file mode 100644
index 00000000000..0b6908c98ea
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettings.java
@@ -0,0 +1,138 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static com.google.firebase.remoteconfig.RemoteConfigComponent.NETWORK_CONNECTION_TIMEOUT_IN_SECONDS;
+import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS;
+
+/**
+ * Wraps the settings for {@link FirebaseRemoteConfig} operations.
+ *
+ * @author Lucas Png
+ */
+public class FirebaseRemoteConfigSettings {
+ private final boolean enableDeveloperMode;
+ private final long fetchTimeoutInSeconds;
+ private final long minimumFetchInterval;
+
+ private FirebaseRemoteConfigSettings(Builder builder) {
+ enableDeveloperMode = builder.enableDeveloperMode;
+ fetchTimeoutInSeconds = builder.fetchTimeoutInSeconds;
+ minimumFetchInterval = builder.minimumFetchInterval;
+ }
+
+ /**
+ * Indicates the status of the developer mode setting.
+ *
+ * @return true if the developer mode is enabled, false otherwise.
+ * @deprecated Use {@link #getMinimumFetchIntervalInSeconds()} instead.
+ */
+ @Deprecated
+ public boolean isDeveloperModeEnabled() {
+ return enableDeveloperMode;
+ }
+
+ /**
+ * Returns the fetch timeout in seconds.
+ *
+ * The timeout specifies how long the client should wait for a connection to the Firebase
+ * Remote Config servers.
+ */
+ public long getFetchTimeoutInSeconds() {
+ return fetchTimeoutInSeconds;
+ }
+
+ /** Returns the minimum interval between successive fetches calls in seconds. */
+ public long getMinimumFetchIntervalInSeconds() {
+ return minimumFetchInterval;
+ }
+
+ /** Constructs a builder initialized with the current FirebaseRemoteConfigSettings. */
+ public FirebaseRemoteConfigSettings.Builder toBuilder() {
+ FirebaseRemoteConfigSettings.Builder frcBuilder = new FirebaseRemoteConfigSettings.Builder();
+ frcBuilder.setDeveloperModeEnabled(this.isDeveloperModeEnabled());
+ frcBuilder.setFetchTimeoutInSeconds(this.getFetchTimeoutInSeconds());
+ frcBuilder.setMinimumFetchIntervalInSeconds(this.getMinimumFetchIntervalInSeconds());
+ return frcBuilder;
+ }
+
+ /** Builder for a {@link FirebaseRemoteConfigSettings}. */
+ public static class Builder {
+ private boolean enableDeveloperMode = false;
+ // TODO(issues/257): Move constants to Constants file.
+ private long fetchTimeoutInSeconds = NETWORK_CONNECTION_TIMEOUT_IN_SECONDS;
+ private long minimumFetchInterval = DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS;
+
+ /**
+ * Turns the developer mode setting on or off.
+ *
+ * @param enabled Should be true to enable, or false to disable this
+ * setting.
+ * @deprecated Use {@link #setMinimumFetchIntervalInSeconds(long)} instead.
+ */
+ @Deprecated
+ public Builder setDeveloperModeEnabled(boolean enabled) {
+ enableDeveloperMode = enabled;
+ return this;
+ }
+
+ /**
+ * Sets the connection timeout for fetch requests to the Firebase Remote Config servers in
+ * seconds.
+ *
+ *
A fetch call will fail if it takes longer than the specified timeout to connect to the
+ * Remote Config servers.
+ *
+ * @param duration Timeout duration in seconds. Should be a non-negative number.
+ */
+ public Builder setFetchTimeoutInSeconds(long duration) throws IllegalArgumentException {
+ if (duration < 0) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Fetch connection timeout has to be a non-negative number. "
+ + "%d is an invalid argument",
+ duration));
+ }
+ fetchTimeoutInSeconds = duration;
+ return this;
+ }
+
+ /**
+ * Sets the minimum interval between successive fetch calls.
+ *
+ *
Fetches less than {@code duration} seconds after the last fetch from the Firebase Remote
+ * Config server would use values returned during the last fetch.
+ *
+ * @param duration Interval duration in seconds. Should be a non-negative number.
+ */
+ public Builder setMinimumFetchIntervalInSeconds(long duration) {
+ if (duration < 0) {
+ throw new IllegalArgumentException(
+ "Minimum interval between fetches has to be a non-negative number. "
+ + duration
+ + " is an invalid argument");
+ }
+ minimumFetchInterval = duration;
+ return this;
+ }
+
+ /**
+ * Returns a {@link FirebaseRemoteConfigSettings} with the settings provided to this builder.
+ */
+ public FirebaseRemoteConfigSettings build() {
+ return new FirebaseRemoteConfigSettings(this);
+ }
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigValue.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigValue.java
new file mode 100644
index 00000000000..36d23dd8c00
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigValue.java
@@ -0,0 +1,62 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+/** Wrapper for a Remote Config parameter value, with methods to get it as different types. */
+public interface FirebaseRemoteConfigValue {
+ /**
+ * Gets the value as a long.
+ *
+ * @return long representation of this parameter value.
+ * @throws IllegalArgumentException If the value cannot be converted to a long.
+ */
+ public long asLong() throws IllegalArgumentException;
+ /**
+ * Gets the value as a double.
+ *
+ * @return double representation of this parameter value.
+ * @throws IllegalArgumentException If the value cannot be converted to a double.
+ */
+ public double asDouble() throws IllegalArgumentException;
+ /**
+ * Gets the value as a String.
+ *
+ * @return String representation of this parameter value.
+ */
+ public String asString();
+ /**
+ * Gets the value as a byte[].
+ *
+ * @return byte[] representation of this parameter value.
+ */
+ public byte[] asByteArray();
+ /**
+ * Gets the value as a boolean.
+ *
+ * @return boolean representation of this parameter value.
+ * @throws IllegalArgumentException If the value cannot be converted to a boolean.
+ */
+ public boolean asBoolean() throws IllegalArgumentException;
+
+ /**
+ * Indicates at which source this value came from.
+ *
+ * @return {@link FirebaseRemoteConfig#VALUE_SOURCE_REMOTE} if the value was retrieved from the
+ * server, {@link FirebaseRemoteConfig#VALUE_SOURCE_DEFAULT} if the value was set as a
+ * default, or {@link FirebaseRemoteConfig#VALUE_SOURCE_STATIC} if no value was found and a
+ * static default value was returned instead.
+ */
+ public int getSource();
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java
new file mode 100644
index 00000000000..e157b6329ee
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java
@@ -0,0 +1,292 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.gms.common.annotation.KeepForSdk;
+import com.google.android.gms.common.util.Clock;
+import com.google.android.gms.common.util.DefaultClock;
+import com.google.android.gms.tasks.Tasks;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.abt.FirebaseABTesting;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.remoteconfig.internal.ConfigCacheClient;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHttpClient;
+import com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient;
+import com.google.firebase.remoteconfig.internal.ConfigStorageClient;
+import com.google.firebase.remoteconfig.internal.LegacyConfigsHandler;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Component for providing multiple Firebase Remote Config (FRC) instances. Firebase Android
+ * Components uses this class to retrieve instances of FRC for dependency injection.
+ *
+ *
A unique FRC instance is returned for each {{@link FirebaseApp}, {@code namespace}}
+ * combination.
+ *
+ * @author Miraziz Yusupov
+ * @hide
+ */
+@KeepForSdk
+public class RemoteConfigComponent {
+ /** Name of the file where activated configs are stored. */
+ public static final String ACTIVATE_FILE_NAME = "activate";
+ /** Name of the file where fetched configs are stored. */
+ public static final String FETCH_FILE_NAME = "fetch";
+ /** Name of the file where defaults configs are stored. */
+ public static final String DEFAULTS_FILE_NAME = "defaults";
+ /** Timeout for the call to the Firebase Remote Config servers in second. */
+ public static final long NETWORK_CONNECTION_TIMEOUT_IN_SECONDS = 5;
+
+ private static final String FIREBASE_REMOTE_CONFIG_FILE_NAME_PREFIX = "frc";
+ private static final String PREFERENCES_FILE_NAME = "settings";
+
+ @VisibleForTesting public static final String DEFAULT_NAMESPACE = "firebase";
+
+ private static final Clock DEFAULT_CLOCK = DefaultClock.getInstance();
+ private static final Random DEFAULT_RANDOM = new Random();
+
+ @GuardedBy("this")
+ private final Map frcNamespaceInstances = new HashMap<>();
+
+ private final Context context;
+ private final ExecutorService executorService;
+ private final FirebaseApp firebaseApp;
+ private final FirebaseInstanceId firebaseInstanceId;
+ private final FirebaseABTesting firebaseAbt;
+ @Nullable private final AnalyticsConnector analyticsConnector;
+
+ private final String appId;
+
+ @GuardedBy("this")
+ private Map customHeaders = new HashMap<>();
+
+ /** Firebase Remote Config Component constructor. */
+ RemoteConfigComponent(
+ Context context,
+ FirebaseApp firebaseApp,
+ FirebaseInstanceId firebaseInstanceId,
+ FirebaseABTesting firebaseAbt,
+ @Nullable AnalyticsConnector analyticsConnector) {
+ this(
+ context,
+ Executors.newCachedThreadPool(),
+ firebaseApp,
+ firebaseInstanceId,
+ firebaseAbt,
+ analyticsConnector,
+ new LegacyConfigsHandler(context, firebaseApp.getOptions().getApplicationId()),
+ /* loadGetDefault= */ true);
+ }
+
+ /** Firebase Remote Config Component constructor for testing component logic. */
+ @VisibleForTesting
+ protected RemoteConfigComponent(
+ Context context,
+ ExecutorService executorService,
+ FirebaseApp firebaseApp,
+ FirebaseInstanceId firebaseInstanceId,
+ FirebaseABTesting firebaseAbt,
+ @Nullable AnalyticsConnector analyticsConnector,
+ LegacyConfigsHandler legacyConfigsHandler,
+ boolean loadGetDefault) {
+ this.context = context;
+ this.executorService = executorService;
+ this.firebaseApp = firebaseApp;
+ this.firebaseInstanceId = firebaseInstanceId;
+ this.firebaseAbt = firebaseAbt;
+ this.analyticsConnector = analyticsConnector;
+
+ this.appId = firebaseApp.getOptions().getApplicationId();
+
+ // When the component is first loaded, it will use a cached executor.
+ // The getDefault call creates race conditions in tests, where the getDefault might be executing
+ // while another test has already cleared the component but hasn't gotten a new one yet.
+ if (loadGetDefault) {
+ // Loads the default namespace's configs from disk on App startup.
+ Tasks.call(executorService, this::getDefault);
+ Tasks.call(executorService, legacyConfigsHandler::saveLegacyConfigsIfNecessary);
+ }
+ }
+
+ /**
+ * Returns the default Firebase Remote Config instance for this component's {@link FirebaseApp}.
+ */
+ FirebaseRemoteConfig getDefault() {
+ return get(DEFAULT_NAMESPACE);
+ }
+
+ /**
+ * Returns the Firebase Remote Config instance associated with the given {@code namespace} and
+ * this component's {@link FirebaseApp}.
+ *
+ * @param namespace a 2P's namespace, or, for the 3P App, the default namespace.
+ */
+ @VisibleForTesting
+ @KeepForSdk
+ public synchronized FirebaseRemoteConfig get(String namespace) {
+ ConfigCacheClient fetchedCacheClient = getCacheClient(namespace, FETCH_FILE_NAME);
+ ConfigCacheClient activatedCacheClient = getCacheClient(namespace, ACTIVATE_FILE_NAME);
+ ConfigCacheClient defaultsCacheClient = getCacheClient(namespace, DEFAULTS_FILE_NAME);
+ ConfigMetadataClient metadataClient = getMetadataClient(context, appId, namespace);
+ return get(
+ firebaseApp,
+ namespace,
+ firebaseAbt,
+ executorService,
+ fetchedCacheClient,
+ activatedCacheClient,
+ defaultsCacheClient,
+ getFetchHandler(namespace, fetchedCacheClient, metadataClient),
+ getGetHandler(activatedCacheClient, defaultsCacheClient),
+ metadataClient);
+ }
+
+ @VisibleForTesting
+ synchronized FirebaseRemoteConfig get(
+ FirebaseApp firebaseApp,
+ String namespace,
+ FirebaseABTesting firebaseAbt,
+ Executor executor,
+ ConfigCacheClient fetchedClient,
+ ConfigCacheClient activatedClient,
+ ConfigCacheClient defaultsClient,
+ ConfigFetchHandler fetchHandler,
+ ConfigGetParameterHandler getHandler,
+ ConfigMetadataClient metadataClient) {
+ if (!frcNamespaceInstances.containsKey(namespace)) {
+ FirebaseRemoteConfig in =
+ new FirebaseRemoteConfig(
+ context,
+ firebaseApp,
+ isAbtSupported(firebaseApp, namespace) ? firebaseAbt : null,
+ executor,
+ fetchedClient,
+ activatedClient,
+ defaultsClient,
+ fetchHandler,
+ getHandler,
+ metadataClient);
+ in.startLoadingConfigsFromDisk();
+ frcNamespaceInstances.put(namespace, in);
+ }
+ return frcNamespaceInstances.get(namespace);
+ }
+
+ @VisibleForTesting
+ public synchronized void setCustomHeaders(Map customHeaders) {
+ this.customHeaders = customHeaders;
+ }
+
+ private ConfigCacheClient getCacheClient(String namespace, String configStoreType) {
+ return getCacheClient(context, appId, namespace, configStoreType);
+ }
+
+ /**
+ * The {@link LegacyConfigsHandler} needs access to multiple cache clients, and the simplest way
+ * to provide it access is to keep this method public and static.
+ */
+ public static ConfigCacheClient getCacheClient(
+ Context context, String appId, String namespace, String configStoreType) {
+ String fileName =
+ String.format(
+ "%s_%s_%s_%s.json",
+ FIREBASE_REMOTE_CONFIG_FILE_NAME_PREFIX, appId, namespace, configStoreType);
+ return ConfigCacheClient.getInstance(
+ Executors.newCachedThreadPool(), ConfigStorageClient.getInstance(context, fileName));
+ }
+
+ @VisibleForTesting
+ ConfigFetchHttpClient getFrcBackendApiClient(
+ String apiKey, String namespace, ConfigMetadataClient metadataClient) {
+ String appId = firebaseApp.getOptions().getApplicationId();
+ return new ConfigFetchHttpClient(
+ context,
+ appId,
+ apiKey,
+ namespace,
+ metadataClient.getFetchTimeoutInSeconds(),
+ NETWORK_CONNECTION_TIMEOUT_IN_SECONDS);
+ }
+
+ @VisibleForTesting
+ synchronized ConfigFetchHandler getFetchHandler(
+ String namespace, ConfigCacheClient fetchedCacheClient, ConfigMetadataClient metadataClient) {
+ return new ConfigFetchHandler(
+ firebaseInstanceId,
+ isPrimaryApp(firebaseApp) ? analyticsConnector : null,
+ executorService,
+ DEFAULT_CLOCK,
+ DEFAULT_RANDOM,
+ fetchedCacheClient,
+ getFrcBackendApiClient(firebaseApp.getOptions().getApiKey(), namespace, metadataClient),
+ metadataClient,
+ this.customHeaders);
+ }
+
+ private ConfigGetParameterHandler getGetHandler(
+ ConfigCacheClient activatedCacheClient, ConfigCacheClient defaultsCacheClient) {
+ return new ConfigGetParameterHandler(activatedCacheClient, defaultsCacheClient);
+ }
+
+ @VisibleForTesting
+ static ConfigMetadataClient getMetadataClient(Context context, String appId, String namespace) {
+ String fileName =
+ String.format(
+ "%s_%s_%s_%s",
+ FIREBASE_REMOTE_CONFIG_FILE_NAME_PREFIX, appId, namespace, PREFERENCES_FILE_NAME);
+ SharedPreferences preferences = context.getSharedPreferences(fileName, Context.MODE_PRIVATE);
+ return new ConfigMetadataClient(preferences);
+ }
+
+ /**
+ * Checks if ABT can be used in the given {@code firebaseApp} and {@code namespace}.
+ *
+ * The Firebase A/B Testing SDK uses Analytics to update experiments, so, since Analytics does
+ * not work outside the primary {@link FirebaseApp}, ABT should not be used outside the primary
+ * App.
+ *
+ *
The ABT product is only available to 3P developers and does not work for other Firebase
+ * SDKs, so ABT should not be used outside the 3P namespace.
+ *
+ * @return True if {@code firebaseApp} is the main {@link FirebaseApp} and {@code namespace} is
+ * the 3P namespace.
+ */
+ private static boolean isAbtSupported(FirebaseApp firebaseApp, String namespace) {
+ return namespace.equals(DEFAULT_NAMESPACE) && isPrimaryApp(firebaseApp);
+ }
+
+ /**
+ * Returns true if {@code firebaseApp} is the main {@link FirebaseApp}.
+ *
+ *
Analytics and, by extension, Firebase A/B Testing only support the primary {@link
+ * FirebaseApp}.
+ */
+ private static boolean isPrimaryApp(FirebaseApp firebaseApp) {
+ return firebaseApp.getName().equals(FirebaseApp.DEFAULT_APP_NAME);
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java
new file mode 100644
index 00000000000..9a49bf7a59d
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java
@@ -0,0 +1,101 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ExperimentDescriptionFieldKey.EXPERIMENT_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ExperimentDescriptionFieldKey.VARIANT_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.ANALYTICS_USER_PROPERTIES;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_VERSION;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.COUNTRY_CODE;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID_TOKEN;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.LANGUAGE_CODE;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.PACKAGE_NAME;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.PLATFORM_VERSION;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.SDK_VERSION;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.TIME_ZONE;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.ENTRIES;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.EXPERIMENT_DESCRIPTIONS;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.STATE;
+
+import androidx.annotation.StringDef;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Constants used throughout the Firebase Remote Config SDK.
+ *
+ * @author Lucas Png
+ * @hide
+ */
+public class RemoteConfigConstants {
+ public static final String FETCH_REGEX_URL =
+ "https://firebaseremoteconfig.googleapis.com/v1/projects/%s/namespaces/%s:fetch";
+
+ /**
+ * Keys of fields in the Fetch request body that the client sends to the Firebase Remote Config
+ * server.
+ */
+ @StringDef({
+ INSTANCE_ID,
+ INSTANCE_ID_TOKEN,
+ APP_ID,
+ COUNTRY_CODE,
+ LANGUAGE_CODE,
+ PLATFORM_VERSION,
+ TIME_ZONE,
+ APP_VERSION,
+ PACKAGE_NAME,
+ SDK_VERSION,
+ ANALYTICS_USER_PROPERTIES
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface RequestFieldKey {
+ String INSTANCE_ID = "appInstanceId";
+ String INSTANCE_ID_TOKEN = "appInstanceIdToken";
+ String APP_ID = "appId";
+ String COUNTRY_CODE = "countryCode";
+ String LANGUAGE_CODE = "languageCode";
+ String PLATFORM_VERSION = "platformVersion";
+ String TIME_ZONE = "timeZone";
+ String APP_VERSION = "appVersion";
+ String PACKAGE_NAME = "packageName";
+ String SDK_VERSION = "sdkVersion";
+ String ANALYTICS_USER_PROPERTIES = "analyticsUserProperties";
+ }
+
+ /** Keys of fields in the Fetch response body from the Firebase Remote Config server. */
+ @StringDef({ENTRIES, EXPERIMENT_DESCRIPTIONS, STATE})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface ResponseFieldKey {
+ String ENTRIES = "entries";
+ String EXPERIMENT_DESCRIPTIONS = "experimentDescriptions";
+ String STATE = "state";
+ }
+
+ /**
+ * Select keys of fields in the experiment descriptions returned from the Firebase Remote Config
+ * server.
+ */
+ @StringDef({EXPERIMENT_ID, VARIANT_ID})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface ExperimentDescriptionFieldKey {
+ String EXPERIMENT_ID = "experimentId";
+ String VARIANT_ID = "variantId";
+ }
+
+ private RemoteConfigConstants() {}
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigRegistrar.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigRegistrar.java
new file mode 100644
index 00000000000..62bf8087f79
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigRegistrar.java
@@ -0,0 +1,61 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import android.content.Context;
+import androidx.annotation.Keep;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.abt.FirebaseABTesting.OriginService;
+import com.google.firebase.abt.component.AbtComponent;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.components.Component;
+import com.google.firebase.components.ComponentRegistrar;
+import com.google.firebase.components.Dependency;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.platforminfo.LibraryVersionComponent;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Registrar for setting up Firebase Remote Config's dependency injections in Firebase Android
+ * Components.
+ *
+ * @author Miraziz Yusupov
+ * @hide
+ */
+@Keep
+public class RemoteConfigRegistrar implements ComponentRegistrar {
+ @Override
+ public List> getComponents() {
+ return Arrays.asList(
+ Component.builder(RemoteConfigComponent.class)
+ .add(Dependency.required(Context.class))
+ .add(Dependency.required(FirebaseApp.class))
+ .add(Dependency.required(FirebaseInstanceId.class))
+ .add(Dependency.required(AbtComponent.class))
+ .add(Dependency.optional(AnalyticsConnector.class))
+ .factory(
+ container ->
+ new RemoteConfigComponent(
+ container.get(Context.class),
+ container.get(FirebaseApp.class),
+ container.get(FirebaseInstanceId.class),
+ container.get(AbtComponent.class).get(OriginService.REMOTE_CONFIG),
+ container.get(AnalyticsConnector.class)))
+ .alwaysEager()
+ .build(),
+ LibraryVersionComponent.create("fire-rc", BuildConfig.VERSION_NAME));
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/Code.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/Code.java
new file mode 100644
index 00000000000..63d79363ec8
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/Code.java
@@ -0,0 +1,143 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.internal.Code.ABORTED;
+import static com.google.firebase.remoteconfig.internal.Code.ALREADY_EXISTS;
+import static com.google.firebase.remoteconfig.internal.Code.CANCELLED;
+import static com.google.firebase.remoteconfig.internal.Code.DATA_LOSS;
+import static com.google.firebase.remoteconfig.internal.Code.DEADLINE_EXCEEDED;
+import static com.google.firebase.remoteconfig.internal.Code.FAILED_PRECONDITION;
+import static com.google.firebase.remoteconfig.internal.Code.INTERNAL;
+import static com.google.firebase.remoteconfig.internal.Code.INVALID_ARGUMENT;
+import static com.google.firebase.remoteconfig.internal.Code.NOT_FOUND;
+import static com.google.firebase.remoteconfig.internal.Code.OK;
+import static com.google.firebase.remoteconfig.internal.Code.OUT_OF_RANGE;
+import static com.google.firebase.remoteconfig.internal.Code.PERMISSION_DENIED;
+import static com.google.firebase.remoteconfig.internal.Code.RESOURCE_EXHAUSTED;
+import static com.google.firebase.remoteconfig.internal.Code.UNAUTHENTICATED;
+import static com.google.firebase.remoteconfig.internal.Code.UNAVAILABLE;
+import static com.google.firebase.remoteconfig.internal.Code.UNIMPLEMENTED;
+import static com.google.firebase.remoteconfig.internal.Code.UNKNOWN;
+
+import androidx.annotation.IntDef;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * The set of Firebase Remote Config status codes. The codes are based on Canonical error
+ * codes for Google APIs .
+ *
+ * @author Miraziz Yusupov
+ */
+@IntDef({
+ OK,
+ CANCELLED,
+ UNKNOWN,
+ INVALID_ARGUMENT,
+ DEADLINE_EXCEEDED,
+ NOT_FOUND,
+ ALREADY_EXISTS,
+ PERMISSION_DENIED,
+ UNAUTHENTICATED,
+ RESOURCE_EXHAUSTED,
+ FAILED_PRECONDITION,
+ ABORTED,
+ OUT_OF_RANGE,
+ UNIMPLEMENTED,
+ INTERNAL,
+ UNAVAILABLE,
+ DATA_LOSS
+})
+@Retention(RetentionPolicy.SOURCE)
+public @interface Code {
+ /**
+ * The operation completed successfully. FirebaseRemoteConfigServerException will never have a
+ * status of OK.
+ */
+ int OK = 0;
+
+ /** The operation was cancelled (typically by the caller). */
+ int CANCELLED = 1;
+
+ /** Unknown error or an error from a different error domain. */
+ int UNKNOWN = 2;
+
+ /**
+ * Client specified an invalid argument. Note that this differs from FAILED_PRECONDITION.
+ * INVALID_ARGUMENT indicates arguments that are problematic regardless of the state of the system
+ * (e.g., an invalid field name).
+ */
+ int INVALID_ARGUMENT = 3;
+
+ /**
+ * Deadline expired before operation could complete. For operations that change the state of the
+ * system, this error may be returned even if the operation has completed successfully. For
+ * example, a successful response from a server could have been delayed long enough for the
+ * deadline to expire.
+ */
+ int DEADLINE_EXCEEDED = 4;
+
+ /** Some requested resource was not found. */
+ int NOT_FOUND = 5;
+
+ /** Some resource that we attempted to create already exists. */
+ int ALREADY_EXISTS = 6;
+
+ /** The caller does not have permission to execute the specified operation. */
+ int PERMISSION_DENIED = 7;
+
+ /** The request does not have valid authentication credentials for the operation. */
+ int UNAUTHENTICATED = 16;
+
+ /**
+ * Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system
+ * is out of space.
+ */
+ int RESOURCE_EXHAUSTED = 8;
+
+ /**
+ * Operation was rejected because the system is not in a state required for the operation's
+ * execution.
+ */
+ int FAILED_PRECONDITION = 9;
+
+ /**
+ * The operation was aborted, typically due to a concurrency issue like transaction aborts, etc.
+ */
+ int ABORTED = 10;
+
+ /** Operation was attempted past the valid range. */
+ int OUT_OF_RANGE = 11;
+
+ /** Operation is not implemented or not supported/enabled. */
+ int UNIMPLEMENTED = 12;
+
+ /**
+ * Internal errors. Means some invariants expected by underlying system has been broken. If you
+ * see one of these errors, something is very broken.
+ */
+ int INTERNAL = 13;
+
+ /**
+ * The service is currently unavailable. This is a most likely a transient condition and may be
+ * corrected by retrying with a backoff.
+ */
+ int UNAVAILABLE = 14;
+
+ /** Unrecoverable data loss or corruption. */
+ int DATA_LOSS = 15;
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigCacheClient.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigCacheClient.java
new file mode 100644
index 00000000000..35f50bb44e5
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigCacheClient.java
@@ -0,0 +1,287 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.TAG;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.gms.tasks.OnCanceledListener;
+import com.google.android.gms.tasks.OnFailureListener;
+import com.google.android.gms.tasks.OnSuccessListener;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.Tasks;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Cache client for managing an in-memory {@link ConfigContainer} backed by disk.
+ *
+ * The in-memory and file {@link ConfigContainer}s are always synced by the client, so the
+ * in-memory container returned by the client will be the same as the container stored in disk.
+ *
+ *
Since there's a one to one mapping between files and storage clients, as well between files
+ * and cache clients, and every method in both clients is synchronized, two threads in the same
+ * process should never write to the same file simultaneously.
+ *
+ * @author Miraziz Yusupov
+ */
+@AnyThread
+public class ConfigCacheClient {
+ /** How long a method should block on a file read. */
+ static final long DISK_READ_TIMEOUT_IN_SECONDS = 5L;
+
+ @GuardedBy("ConfigCacheClient.class")
+ private static final Map clientInstances = new HashMap<>();
+
+ private final ExecutorService executorService;
+ private final ConfigStorageClient storageClient;
+
+ /**
+ * Represents the {@link ConfigContainer} stored in disk. If the value is null, then there have
+ * been no file reads or writes yet.
+ */
+ @GuardedBy("this")
+ @Nullable
+ private Task cachedContainerTask;
+
+ /**
+ * Creates a new cache client that executes async calls through {@code executorService} and is
+ * backed by {@code storageClient}.
+ */
+ private ConfigCacheClient(ExecutorService executorService, ConfigStorageClient storageClient) {
+ this.executorService = executorService;
+ this.storageClient = storageClient;
+
+ cachedContainerTask = null;
+ }
+
+ /**
+ * Sets the in-memory {@link ConfigContainer} to {@code configContainer} and then starts the file
+ * write to save the new config to disk.
+ *
+ * @return A {@link Task} representing the write to disk.
+ */
+ public Task putWithoutWaitingForDiskWrite(ConfigContainer configContainer) {
+ updateInMemoryConfigContainer(configContainer);
+ return put(configContainer, /*shouldUpdateInMemoryContainer=*/ false);
+ }
+
+ /**
+ * Returns the cached {@link ConfigContainer}, blocking on a file read if necessary.
+ *
+ * If no {@link ConfigContainer} has been read from disk yet, blocks on a {@link #get()} call.
+ * Returns null if the file read does not succeed within {@link #DISK_READ_TIMEOUT_IN_SECONDS}.
+ */
+ @Nullable
+ public ConfigContainer getBlocking() {
+ return getBlocking(DISK_READ_TIMEOUT_IN_SECONDS);
+ }
+
+ @VisibleForTesting
+ @Nullable
+ ConfigContainer getBlocking(long diskReadTimeoutInSeconds) {
+ synchronized (this) {
+ if (cachedContainerTask != null && cachedContainerTask.isSuccessful()) {
+ return cachedContainerTask.getResult();
+ }
+ }
+
+ try {
+ return await(get(), diskReadTimeoutInSeconds, TimeUnit.SECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ Log.d(TAG, "Reading from storage file failed.", e);
+ return null;
+ }
+ }
+
+ /**
+ * Writes {@code configContainer} to disk and caches it to memory if the write is successful.
+ *
+ * @param configContainer the container to write to disk.
+ * @return A {@link Task} with the {@link ConfigContainer} that was written to disk.
+ */
+ public Task put(ConfigContainer configContainer) {
+ return put(configContainer, /*shouldUpdateInMemoryContainer=*/ true);
+ }
+
+ /**
+ * Writes {@code configContainer} to disk and caches it to memory if the write is successful.
+ *
+ * @param configContainer the container to write to disk.
+ * @param shouldUpdateInMemoryContainer whether the in-memory container should be updated on a
+ * successful file write.
+ * @return A {@link Task} with the {@link ConfigContainer} that was written to disk.
+ */
+ public Task put(
+ ConfigContainer configContainer, boolean shouldUpdateInMemoryContainer) {
+ return Tasks.call(executorService, () -> storageClient.write(configContainer))
+ .onSuccessTask(
+ executorService,
+ (unusedVoid) -> {
+ if (shouldUpdateInMemoryContainer) {
+ updateInMemoryConfigContainer(configContainer);
+ }
+ return Tasks.forResult(configContainer);
+ });
+ }
+
+ /**
+ * Returns the cached {@link Task} that contains a {@link ConfigContainer}.
+ *
+ * If no {@link Task} is cached or the cached {@link Task} has failed, makes an async call to
+ * read the container in disk and sets the cache to the resulting {@link Task}.
+ */
+ public synchronized Task get() {
+ /*
+ * The first call to this method will encounter a null cachedContainerTask, so the code below
+ * will start an async task and assign the result to cachedContainerTask. Since this method is
+ * synchronized, all subsequent calls to get() will be blocked on the first call, after which
+ * point cachedContainerTask will be non-null. So, instead of starting their own async tasks,
+ * the subsequent get() calls will simply return the ongoing cachedContainerTask.
+ *
+ * In the case of file I/O failure, the first get() method to recognize that the current
+ * cachedContainerTask failed will start a new async task. All other get() calls will be
+ * blocked on that first get(), after which point cachedContainerTask will be a non-null
+ * non-failing task again.
+ *
+ * If clear() is called, the next get() call will see a null cachedContainerTask and start
+ * an async task as described above.
+ *
+ * If no clears are called, there will never be more than 1 call to storageClient::read from
+ * this instance. Otherwise, in all cases, the number of active async calls to
+ * storageClient::read will be at most one higher than the number of clear() calls made so far.
+ */
+ if (cachedContainerTask == null
+ || (cachedContainerTask.isComplete() && !cachedContainerTask.isSuccessful())) {
+ cachedContainerTask = Tasks.call(executorService, storageClient::read);
+ }
+ return cachedContainerTask;
+ }
+
+ /** Clears the cache and the {@link ConfigContainer} stored in disk. */
+ public void clear() {
+ synchronized (this) {
+ /*
+ * A null Task means the file has not been loaded yet, which will cause the get() method to
+ * start a new file read. So, to prevent unnecessary reads of an empty file, set to a Task
+ * with a null value, which will simply return a null container when get() is called.
+ */
+ cachedContainerTask = Tasks.forResult(null);
+ }
+ storageClient.clear();
+ }
+
+ /** Sets {@link #cachedContainerTask} to a {@link Task} containing {@code configContainer}. */
+ private synchronized void updateInMemoryConfigContainer(ConfigContainer configContainer) {
+ cachedContainerTask = Tasks.forResult(configContainer);
+ }
+
+ @VisibleForTesting
+ @Nullable
+ synchronized Task getCachedContainerTask() {
+ return cachedContainerTask;
+ }
+
+ /**
+ * Returns an instance of {@link ConfigCacheClient} for the given {@link Executor} and {@link
+ * ConfigStorageClient}. The same instance is always returned for all calls with the same
+ * underlying file name.
+ */
+ public static synchronized ConfigCacheClient getInstance(
+ ExecutorService executorService, ConfigStorageClient storageClient) {
+ String fileName = storageClient.getFileName();
+ if (!clientInstances.containsKey(fileName)) {
+ clientInstances.put(fileName, new ConfigCacheClient(executorService, storageClient));
+ }
+ return clientInstances.get(fileName);
+ }
+
+ @VisibleForTesting
+ public static synchronized void clearInstancesForTest() {
+ clientInstances.clear();
+ }
+
+ /**
+ * Reimplementation of {@link Tasks#await(Task, long, TimeUnit)} because that method has a
+ * precondition that fails when run on the main thread.
+ *
+ * This blocking method is required because the current FRC API has synchronous getters that
+ * read from a cache that is loaded from disk. In other words, the synchronous methods rely on an
+ * async task, so the getters have to block at some point.
+ *
+ *
Until the next breaking change in the API, this use case must be implemented, even though it
+ * is against Android best practices.
+ */
+ private static TResult await(Task task, long timeout, TimeUnit unit)
+ throws ExecutionException, InterruptedException, TimeoutException {
+ AwaitListener waiter = new AwaitListener<>();
+
+ task.addOnSuccessListener(DIRECT_EXECUTOR, waiter);
+ task.addOnFailureListener(DIRECT_EXECUTOR, waiter);
+ task.addOnCanceledListener(DIRECT_EXECUTOR, waiter);
+
+ if (!waiter.await(timeout, unit)) {
+ throw new TimeoutException("Task await timed out.");
+ }
+
+ if (task.isSuccessful()) {
+ return task.getResult();
+ } else {
+ throw new ExecutionException(task.getException());
+ }
+ }
+
+ /** An Executor that uses the calling thread. */
+ private static final Executor DIRECT_EXECUTOR = Runnable::run;
+
+ private static class AwaitListener
+ implements OnSuccessListener, OnFailureListener, OnCanceledListener {
+ private final CountDownLatch latch = new CountDownLatch(1);
+
+ @Override
+ public void onSuccess(TResult o) {
+ latch.countDown();
+ }
+
+ @Override
+ public void onFailure(@NonNull Exception e) {
+ latch.countDown();
+ }
+
+ @Override
+ public void onCanceled() {
+ latch.countDown();
+ }
+
+ public void await() throws InterruptedException {
+ latch.await();
+ }
+
+ public boolean await(long timeout, TimeUnit unit) throws InterruptedException {
+ return latch.await(timeout, unit);
+ }
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigContainer.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigContainer.java
new file mode 100644
index 00000000000..daa7afb956d
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigContainer.java
@@ -0,0 +1,195 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import java.util.Date;
+import java.util.Map;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * The wrapper class for a JSON object that contains Firebase Remote Config (FRC) configs as well as
+ * their metadata.
+ *
+ * @author Miraziz Yusupov
+ */
+public class ConfigContainer {
+ private static final String CONFIGS_KEY = "configs_key";
+ private static final String FETCH_TIME_KEY = "fetch_time_key";
+ private static final String ABT_EXPERIMENTS_KEY = "abt_experiments_key";
+
+ private static final Date DEFAULTS_FETCH_TIME = new Date(0L);
+
+ /**
+ * The object stored in disk and wrapped by this class; contains a set of configs and any relevant
+ * metadata.
+ *
+ * Used by the storage client to write this container to file.
+ */
+ private JSONObject containerJson;
+
+ /**
+ * Cached value of the container's config key-value pairs.
+ *
+ *
Used by the FRC client to retrieve config values.
+ */
+ private JSONObject configsJson;
+ /** Cached value of the time when this container's values were fetched. */
+ private Date fetchTime;
+
+ private JSONArray abtExperiments;
+
+ /**
+ * Creates a new container with the specified configs and fetch time.
+ *
+ *
The {@code configsJson} must not be modified.
+ */
+ private ConfigContainer(JSONObject configsJson, Date fetchTime, JSONArray abtExperiments)
+ throws JSONException {
+ JSONObject containerJson = new JSONObject();
+ containerJson.put(CONFIGS_KEY, configsJson);
+ containerJson.put(FETCH_TIME_KEY, fetchTime.getTime());
+ containerJson.put(ABT_EXPERIMENTS_KEY, abtExperiments);
+
+ this.configsJson = configsJson;
+ this.fetchTime = fetchTime;
+ this.abtExperiments = abtExperiments;
+
+ this.containerJson = containerJson;
+ }
+
+ /**
+ * Returns a {@link ConfigContainer} that wraps the {@code containerJson}.
+ *
+ *
The {@code containerJson} must not be modified.
+ */
+ static ConfigContainer copyOf(JSONObject containerJson) throws JSONException {
+ return new ConfigContainer(
+ containerJson.getJSONObject(CONFIGS_KEY),
+ new Date(containerJson.getLong(FETCH_TIME_KEY)),
+ containerJson.getJSONArray(ABT_EXPERIMENTS_KEY));
+ }
+
+ /**
+ * Returns the FRC configs.
+ *
+ *
The returned {@link JSONObject} must not be modified.
+ */
+ public JSONObject getConfigs() {
+ return configsJson;
+ }
+
+ /**
+ * Returns the time the configs of this instance were fetched. The fetch time is epoch for
+ * defaults containers.
+ */
+ public Date getFetchTime() {
+ return fetchTime;
+ }
+
+ public JSONArray getAbtExperiments() {
+ return abtExperiments;
+ }
+
+ @Override
+ public String toString() {
+ return containerJson.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ConfigContainer)) {
+ return false;
+ }
+ ConfigContainer that = (ConfigContainer) o;
+ // TODO(issues/285): Use an equality comparison that is guaranteed to be deterministic.
+ return containerJson.toString().equals(that.toString());
+ }
+
+ @Override
+ public int hashCode() {
+ return containerJson.hashCode();
+ }
+
+ /** Builder for creating an instance of {@link ConfigContainer}. */
+ public static class Builder {
+ private JSONObject builderConfigsJson;
+ private Date builderFetchTime;
+ private JSONArray builderAbtExperiments;
+
+ private Builder() {
+ builderConfigsJson = new JSONObject();
+ builderFetchTime = DEFAULTS_FETCH_TIME;
+ builderAbtExperiments = new JSONArray();
+ }
+
+ public Builder(ConfigContainer otherContainer) {
+ this.builderConfigsJson = otherContainer.getConfigs();
+ this.builderFetchTime = otherContainer.getFetchTime();
+ this.builderAbtExperiments = otherContainer.getAbtExperiments();
+ }
+
+ public Builder replaceConfigsWith(Map configsMap) {
+ this.builderConfigsJson = new JSONObject(configsMap);
+ return this;
+ }
+
+ public Builder replaceConfigsWith(JSONObject configsJson) {
+ try {
+ this.builderConfigsJson = new JSONObject(configsJson.toString());
+ } catch (JSONException e) {
+ // We serialize and deserialize the JSONObject to guarantee that it cannot be mutated after
+ // being set in the builder.
+ // A JSONException should never occur because the JSON that is being deserialized is
+ // guaranteed to be valid.
+ }
+ return this;
+ }
+
+ public Builder withFetchTime(Date fetchTime) {
+ this.builderFetchTime = fetchTime;
+ return this;
+ }
+
+ public Builder withAbtExperiments(JSONArray abtExperiments) {
+ try {
+ this.builderAbtExperiments = new JSONArray(abtExperiments.toString());
+ } catch (JSONException e) {
+ // We serialize and deserialize the JSONArray to guarantee that it cannot be mutated after
+ // being set in the builder.
+ // A JSONException should never occur because the JSON that is being deserialized is
+ // guaranteed to be valid.
+ }
+ return this;
+ }
+
+ /** If a fetch time is not provided, the defaults container fetch time is used. */
+ public ConfigContainer build() throws JSONException {
+ return new ConfigContainer(builderConfigsJson, builderFetchTime, builderAbtExperiments);
+ }
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static Builder newBuilder(ConfigContainer otherContainer) {
+ return new Builder(otherContainer);
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java
new file mode 100644
index 00000000000..138d75a9751
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java
@@ -0,0 +1,569 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.internal.ConfigMetadataClient.LAST_FETCH_TIME_NO_FETCH_YET;
+import static java.net.HttpURLConnection.HTTP_BAD_GATEWAY;
+import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
+import static java.net.HttpURLConnection.HTTP_GATEWAY_TIMEOUT;
+import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
+import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
+import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.text.format.DateUtils;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+import com.google.android.gms.common.util.Clock;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.Tasks;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigClientException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigFetchThrottledException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigServerException;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler.FetchResponse.Status;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient.BackoffMetadata;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.HttpURLConnection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.Executor;
+
+/**
+ * A handler for fetch requests to the Firebase Remote Config backend.
+ *
+ * Checks cache and throttling status before sending a request to the backend.
+ *
+ * @author Miraziz Yusupov
+ */
+public class ConfigFetchHandler {
+ /** The default minimum interval between fetch requests to the Firebase Remote Config server. */
+ public static final long DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS = HOURS.toSeconds(12);
+
+ /**
+ * The exponential backoff intervals, up to ~4 hours.
+ *
+ *
Every value must be even.
+ */
+ @VisibleForTesting
+ static final int[] BACKOFF_TIME_DURATIONS_IN_MINUTES = {2, 4, 8, 16, 32, 64, 128, 256};
+
+ /**
+ * HTTP status code for a throttled request.
+ *
+ *
Defined here since {@link HttpURLConnection} does not provide this code.
+ */
+ @VisibleForTesting static final int HTTP_TOO_MANY_REQUESTS = 429;
+
+ private final FirebaseInstanceId firebaseInstanceId;
+ @Nullable private final AnalyticsConnector analyticsConnector;
+
+ private final Executor executor;
+ private final Clock clock;
+ private final Random randomGenerator;
+ private final ConfigCacheClient fetchedConfigsCache;
+ private final ConfigFetchHttpClient frcBackendApiClient;
+ private final ConfigMetadataClient frcMetadata;
+
+ private final Map customHttpHeaders;
+
+ /** FRC Fetch Handler constructor. */
+ public ConfigFetchHandler(
+ FirebaseInstanceId firebaseInstanceId,
+ @Nullable AnalyticsConnector analyticsConnector,
+ Executor executor,
+ Clock clock,
+ Random randomGenerator,
+ ConfigCacheClient fetchedConfigsCache,
+ ConfigFetchHttpClient frcBackendApiClient,
+ ConfigMetadataClient frcMetadata,
+ Map customHttpHeaders) {
+ this.firebaseInstanceId = firebaseInstanceId;
+ this.analyticsConnector = analyticsConnector;
+ this.executor = executor;
+ this.clock = clock;
+ this.randomGenerator = randomGenerator;
+ this.fetchedConfigsCache = fetchedConfigsCache;
+ this.frcBackendApiClient = frcBackendApiClient;
+ this.frcMetadata = frcMetadata;
+
+ this.customHttpHeaders = customHttpHeaders;
+ }
+
+ /**
+ * Calls {@link #fetch(long)} with the {@link
+ * ConfigMetadataClient#getMinimumFetchIntervalInSeconds()}.
+ */
+ public Task fetch() {
+ return fetch(frcMetadata.getMinimumFetchIntervalInSeconds());
+ }
+
+ /**
+ * Starts fetching configs from the Firebase Remote Config server.
+ *
+ * Guarantees consistency between memory and disk; fetched configs are saved to memory only
+ * after they have been written to disk.
+ *
+ *
Fetches even if the read of the fetch cache fails (assumes there are no cached fetched
+ * configs in that case).
+ *
+ *
If the fetch request could not be created or there was error connecting to the server, the
+ * returned Task throws a {@link FirebaseRemoteConfigClientException}.
+ *
+ *
If the server responds with an error, the returned Task throws a {@link
+ * FirebaseRemoteConfigServerException}.
+ *
+ *
If any of the following is true, then the returned Task throws a {@link
+ * FirebaseRemoteConfigFetchThrottledException}:
+ *
+ *
+ * The backoff duration from a previous throttled exception has not expired,
+ * The backend responded with a throttled error, or
+ * The backend responded with unavailable errors for the last two fetch requests.
+ *
+ *
+ * @return A {@link Task} representing the fetch call that returns a {@link FetchResponse} with
+ * the configs fetched from the backend. If the backend was not called or the backend had no
+ * updates, the {@link FetchResponse}'s configs will be {@code null}.
+ */
+ public Task fetch(long minimumFetchIntervalInSeconds) {
+ long fetchIntervalInSeconds =
+ frcMetadata.isDeveloperModeEnabled() ? 0L : minimumFetchIntervalInSeconds;
+
+ return fetchedConfigsCache
+ .get()
+ .continueWithTask(
+ executor,
+ (cachedFetchConfigsTask) ->
+ fetchIfCacheExpiredAndNotThrottled(cachedFetchConfigsTask, fetchIntervalInSeconds));
+ }
+
+ /**
+ * Fetches from the backend if the fetched configs cache has expired and the client is not
+ * currently throttled.
+ *
+ * If a fetch request is made to the backend, updates the last fetch status, last successful
+ * fetch time and {@link BackoffMetadata} in {@link ConfigMetadataClient}.
+ */
+ private Task fetchIfCacheExpiredAndNotThrottled(
+ Task cachedFetchConfigsTask, long minimumFetchIntervalInSeconds) {
+ Date currentTime = new Date(clock.currentTimeMillis());
+ if (cachedFetchConfigsTask.isSuccessful()
+ && areCachedFetchConfigsValid(minimumFetchIntervalInSeconds, currentTime)) {
+ // Keep the cached fetch values if the cache has not expired yet.
+ return Tasks.forResult(FetchResponse.forLocalStorageUsed(currentTime));
+ }
+
+ Task fetchResponseTask;
+
+ Date backoffEndTime = getBackoffEndTimeInMillis(currentTime);
+ if (backoffEndTime != null) {
+ // TODO(issues/260): Provide a way for users to check for throttled status so exceptions
+ // aren't the only way for users to determine if they're throttled.
+ fetchResponseTask =
+ Tasks.forException(
+ new FirebaseRemoteConfigFetchThrottledException(
+ createThrottledMessage(backoffEndTime.getTime() - currentTime.getTime()),
+ backoffEndTime.getTime()));
+ } else {
+ fetchResponseTask = fetchFromBackendAndCacheResponse(currentTime);
+ }
+
+ return fetchResponseTask.continueWithTask(
+ executor,
+ (completedFetchTask) -> {
+ updateLastFetchStatusAndTime(completedFetchTask, currentTime);
+ return completedFetchTask;
+ });
+ }
+
+ /**
+ * Returns true if the last successfully fetched configs are not stale, or if developer mode is
+ * on.
+ */
+ private boolean areCachedFetchConfigsValid(long cacheExpirationInSeconds, Date newFetchTime) {
+ Date lastSuccessfulFetchTime = frcMetadata.getLastSuccessfulFetchTime();
+ // RC always fetches if the client has not previously had a successful fetch.
+
+ if (lastSuccessfulFetchTime.equals(LAST_FETCH_TIME_NO_FETCH_YET)) {
+ return false;
+ }
+
+ Date cacheExpirationTime =
+ new Date(lastSuccessfulFetchTime.getTime() + SECONDS.toMillis(cacheExpirationInSeconds));
+
+ return newFetchTime.before(cacheExpirationTime);
+ }
+
+ /**
+ * Returns the earliest possible time, in millis since epoch, when a fetch request won't be
+ * throttled by the server, or {@code null} if the client is not currently throttled by the
+ * server.
+ */
+ @Nullable
+ private Date getBackoffEndTimeInMillis(Date currentTime) {
+ Date backoffEndTime = frcMetadata.getBackoffMetadata().getBackoffEndTime();
+ if (currentTime.before(backoffEndTime)) {
+ return backoffEndTime;
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns a human-readable throttled message with how long the client has to wait before fetching
+ * again.
+ */
+ private String createThrottledMessage(long throttledDurationInMillis) {
+ return String.format(
+ "Fetch is throttled. Please wait before calling fetch again: %s",
+ DateUtils.formatElapsedTime(MILLISECONDS.toSeconds(throttledDurationInMillis)));
+ }
+
+ /**
+ * Fetches configs from the FRC backend. If there are any updates, writes the configs to the
+ * {@code fetchedConfigsCache}.
+ */
+ private Task fetchFromBackendAndCacheResponse(Date fetchTime) {
+ try {
+ FetchResponse fetchResponse = fetchFromBackend(fetchTime);
+ if (fetchResponse.getStatus() != Status.BACKEND_UPDATES_FETCHED) {
+ return Tasks.forResult(fetchResponse);
+ }
+ return fetchedConfigsCache
+ .put(fetchResponse.getFetchedConfigs())
+ .onSuccessTask(executor, (putContainer) -> Tasks.forResult(fetchResponse));
+ } catch (FirebaseRemoteConfigException frce) {
+ return Tasks.forException(frce);
+ }
+ }
+
+ /**
+ * Creates a fetch request, sends it to the FRC backend and converts the server's response into a
+ * {@link FetchResponse}.
+ *
+ * @return The {@link FetchResponse} from the FRC backend.
+ * @throws FirebaseRemoteConfigServerException if the server returned an error.
+ * @throws FirebaseRemoteConfigClientException if the request could not be created or there's an
+ * error connecting to the server.
+ */
+ @WorkerThread
+ private FetchResponse fetchFromBackend(Date currentTime) throws FirebaseRemoteConfigException {
+ try {
+ HttpURLConnection urlConnection = frcBackendApiClient.createHttpURLConnection();
+
+ FetchResponse response =
+ frcBackendApiClient.fetch(
+ urlConnection,
+ firebaseInstanceId.getId(),
+ firebaseInstanceId.getToken(),
+ getUserProperties(),
+ frcMetadata.getLastFetchETag(),
+ customHttpHeaders,
+ currentTime);
+
+ if (response.getLastFetchETag() != null) {
+ frcMetadata.setLastFetchETag(response.getLastFetchETag());
+ }
+ // If the execute method did not throw exceptions, then the server sent a successful response
+ // and the client can stop backing off.
+ frcMetadata.resetBackoff();
+
+ return response;
+ } catch (FirebaseRemoteConfigServerException serverHttpError) {
+ BackoffMetadata backoffMetadata =
+ updateAndReturnBackoffMetadata(serverHttpError.getHttpStatusCode(), currentTime);
+
+ if (shouldThrottle(backoffMetadata, serverHttpError.getHttpStatusCode())) {
+ throw new FirebaseRemoteConfigFetchThrottledException(
+ backoffMetadata.getBackoffEndTime().getTime());
+ }
+ // TODO(issues/264): Move the generic message logic to the ConfigFetchHttpClient.
+ throw createExceptionWithGenericMessage(serverHttpError);
+ }
+ }
+
+ /**
+ * Returns a {@link FirebaseRemoteConfigServerException} with a generic message based on the
+ * {@code statusCode}.
+ *
+ * @throws FirebaseRemoteConfigClientException if {@code statusCode} is {@link
+ * #HTTP_TOO_MANY_REQUESTS}. Throttled responses should be handled before calls to this
+ * method.
+ */
+ private FirebaseRemoteConfigServerException createExceptionWithGenericMessage(
+ FirebaseRemoteConfigServerException httpError) throws FirebaseRemoteConfigClientException {
+ String errorMessage;
+ switch (httpError.getHttpStatusCode()) {
+ case HTTP_UNAUTHORIZED:
+ // The 401 HTTP Code is mapped from UNAUTHENTICATED in the gRPC world.
+ errorMessage =
+ "The request did not have the required credentials. "
+ + "Please make sure your google-services.json is valid.";
+ break;
+ case HTTP_FORBIDDEN:
+ errorMessage =
+ "The user is not authorized to access the project. Please make sure "
+ + "you are using the API key that corresponds to your Firebase project.";
+ break;
+ case HTTP_INTERNAL_ERROR:
+ errorMessage = "There was an internal server error.";
+ break;
+ case HTTP_BAD_GATEWAY:
+ case HTTP_UNAVAILABLE:
+ case HTTP_GATEWAY_TIMEOUT:
+ // The 504 HTTP Code is mapped from DEADLINE_EXCEEDED in the gRPC world.
+ errorMessage = "The server is unavailable. Please try again later.";
+ break;
+ case HTTP_TOO_MANY_REQUESTS:
+ // Should never happen.
+ // The throttled response should be handled before the call to this method.
+ throw new FirebaseRemoteConfigClientException(
+ "The throttled response from the server was not handled correctly by the FRC SDK.");
+ default:
+ errorMessage = "The server returned an unexpected error.";
+ break;
+ }
+
+ return new FirebaseRemoteConfigServerException(
+ httpError.getHttpStatusCode(), "Fetch failed: " + errorMessage, httpError);
+ }
+
+ /**
+ * Updates and returns the backoff metadata if the server returned a throttle-able error.
+ *
+ * The list of throttle-able errors:
+ *
+ *
+ * {@link #HTTP_TOO_MANY_REQUESTS},
+ * {@link HttpURLConnection#HTTP_BAD_GATEWAY},
+ * {@link HttpURLConnection#HTTP_UNAVAILABLE},
+ * {@link HttpURLConnection#HTTP_GATEWAY_TIMEOUT}.
+ *
+ */
+ private BackoffMetadata updateAndReturnBackoffMetadata(int statusCode, Date currentTime) {
+ if (isThrottleableServerError(statusCode)) {
+ updateBackoffMetadataWithLastFailedFetchTime(currentTime);
+ }
+ return frcMetadata.getBackoffMetadata();
+ }
+
+ /**
+ * Returns true for server errors that are throttle-able.
+ *
+ * The {@link HttpURLConnection#HTTP_GATEWAY_TIMEOUT} error is included here since it is
+ * similar to the other unavailable errors in the previously linked doc.
+ */
+ private boolean isThrottleableServerError(int httpStatusCode) {
+ return httpStatusCode == HTTP_TOO_MANY_REQUESTS
+ || httpStatusCode == HttpURLConnection.HTTP_BAD_GATEWAY
+ || httpStatusCode == HttpURLConnection.HTTP_UNAVAILABLE
+ || httpStatusCode == HttpURLConnection.HTTP_GATEWAY_TIMEOUT;
+ }
+
+ // TODO(issues/265): Make this an atomic operation within the Metadata class to avoid possible
+ // concurrency issues.
+ /**
+ * Increment the number of failed fetch attempts, increase the backoff duration, set the backoff
+ * end time to "backoff duration" after {@code lastFailedFetchTime} and persist the new values to
+ * disk-backed metadata.
+ */
+ private void updateBackoffMetadataWithLastFailedFetchTime(Date lastFailedFetchTime) {
+ int numFailedFetches = frcMetadata.getBackoffMetadata().getNumFailedFetches();
+
+ numFailedFetches++;
+
+ long backoffDurationInMillis = getRandomizedBackoffDurationInMillis(numFailedFetches);
+ Date backoffEndTime = new Date(lastFailedFetchTime.getTime() + backoffDurationInMillis);
+
+ frcMetadata.setBackoffMetadata(numFailedFetches, backoffEndTime);
+ }
+
+ /**
+ * Returns a random backoff duration from the range {@code timeoutDuration} +/- 50% of {@code
+ * timeoutDuration}, where {@code timeoutDuration = }{@link
+ * #BACKOFF_TIME_DURATIONS_IN_MINUTES}{@code [numFailedFetches-1]}.
+ */
+ private long getRandomizedBackoffDurationInMillis(int numFailedFetches) {
+ // The backoff duration length after numFailedFetches.
+ long timeOutDurationInMillis =
+ MINUTES.toMillis(
+ BACKOFF_TIME_DURATIONS_IN_MINUTES[
+ Math.min(numFailedFetches, BACKOFF_TIME_DURATIONS_IN_MINUTES.length) - 1]);
+
+ // A random duration that is in the range: timeOutDuration +/- 50% of timeOutDuration.
+ return timeOutDurationInMillis / 2 + randomGenerator.nextInt((int) timeOutDurationInMillis);
+ }
+
+ /**
+ * Determines whether a given {@code httpStatusCode} should be throttled based on recent fetch
+ * results.
+ *
+ *
A fetch is considered throttle-able if the {@code httpStatusCode} is {@link
+ * #HTTP_TOO_MANY_REQUESTS}, or if the fetch is the second consecutive request to receive an
+ * unavailable response from the server.
+ *
+ *
The two fetch requirement guards against the possibility of a transient error from the
+ * server. In such cases, an immediate retry should fix the problem. If the retry also fails, then
+ * the error is probably not transient and the client should enter exponential backoff mode.
+ *
+ *
So, unless the server explicitly responds with a throttled error, the client should not
+ * throttle on the first throttle-able error from the server.
+ *
+ * @return True if the current fetch request should be throttled.
+ */
+ private boolean shouldThrottle(BackoffMetadata backoffMetadata, int httpStatusCode) {
+ return backoffMetadata.getNumFailedFetches() > 1 || httpStatusCode == HTTP_TOO_MANY_REQUESTS;
+ }
+
+ /**
+ * Updates last fetch status and last successful fetch time in FRC metadata based on the result of
+ * {@code completedFetchTask}.
+ */
+ private void updateLastFetchStatusAndTime(
+ Task completedFetchTask, Date fetchTime) {
+ if (completedFetchTask.isSuccessful()) {
+ frcMetadata.updateLastFetchAsSuccessfulAt(fetchTime);
+ return;
+ }
+
+ Exception fetchException = completedFetchTask.getException();
+ if (fetchException == null) {
+ // Fetch was cancelled, which should never happen.
+ return;
+ }
+
+ if (fetchException instanceof FirebaseRemoteConfigFetchThrottledException) {
+ frcMetadata.updateLastFetchAsThrottled();
+ } else {
+ frcMetadata.updateLastFetchAsFailed();
+ }
+ }
+
+ /**
+ * Returns the list of user properties in Analytics. If the Analytics SDK is not available,
+ * returns an empty list.
+ */
+ @WorkerThread
+ private Map getUserProperties() {
+ Map userPropertiesMap = new HashMap<>();
+ if (analyticsConnector == null) {
+ return userPropertiesMap;
+ }
+
+ for (Map.Entry userPropertyEntry :
+ analyticsConnector.getUserProperties(/*includeInternal=*/ false).entrySet()) {
+ userPropertiesMap.put(userPropertyEntry.getKey(), userPropertyEntry.getValue().toString());
+ }
+ return userPropertiesMap;
+ }
+
+ /** Used to verify that the fetch handler is getting Analytics as expected. */
+ @VisibleForTesting
+ @Nullable
+ public AnalyticsConnector getAnalyticsConnector() {
+ return analyticsConnector;
+ }
+
+ /**
+ * The response of a fetch call that contains the configs fetched from the backend as well as
+ * metadata about the fetch operation.
+ */
+ public static class FetchResponse {
+ private final Date fetchTime;
+ @Status private final int status;
+ private final ConfigContainer fetchedConfigs;
+ @Nullable private final String lastFetchETag;
+
+ /** Creates a fetch response with the given parameters. */
+ private FetchResponse(
+ Date fetchTime,
+ @Status int status,
+ ConfigContainer fetchedConfigs,
+ @Nullable String lastFetchETag) {
+ this.fetchTime = fetchTime;
+ this.status = status;
+ this.fetchedConfigs = fetchedConfigs;
+ this.lastFetchETag = lastFetchETag;
+ }
+
+ public static FetchResponse forBackendUpdatesFetched(
+ ConfigContainer fetchedConfigs, String lastFetchETag) {
+ return new FetchResponse(
+ fetchedConfigs.getFetchTime(),
+ Status.BACKEND_UPDATES_FETCHED,
+ fetchedConfigs,
+ lastFetchETag);
+ }
+
+ public static FetchResponse forBackendHasNoUpdates(Date fetchTime) {
+ return new FetchResponse(
+ fetchTime,
+ Status.BACKEND_HAS_NO_UPDATES,
+ /*fetchedConfigs=*/ null,
+ /*lastFetchETag=*/ null);
+ }
+
+ public static FetchResponse forLocalStorageUsed(Date fetchTime) {
+ return new FetchResponse(
+ fetchTime, Status.LOCAL_STORAGE_USED, /*fetchedConfigs=*/ null, /*lastFetchETag=*/ null);
+ }
+
+ Date getFetchTime() {
+ return fetchTime;
+ }
+
+ @Nullable
+ String getLastFetchETag() {
+ return lastFetchETag;
+ }
+
+ @Status
+ int getStatus() {
+ return status;
+ }
+
+ /**
+ * Returns the configs fetched from the backend, or {@code null} if the backend wasn't called or
+ * there were no updates from the backend.
+ */
+ public ConfigContainer getFetchedConfigs() {
+ return fetchedConfigs;
+ }
+
+ /** The response status of a fetch operation. */
+ @IntDef({
+ Status.BACKEND_UPDATES_FETCHED,
+ Status.BACKEND_HAS_NO_UPDATES,
+ Status.LOCAL_STORAGE_USED
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface Status {
+ int BACKEND_UPDATES_FETCHED = 0;
+ int BACKEND_HAS_NO_UPDATES = 1;
+ int LOCAL_STORAGE_USED = 2;
+ }
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java
new file mode 100644
index 00000000000..c9124b51226
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java
@@ -0,0 +1,384 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.TAG;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.FETCH_REGEX_URL;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.ANALYTICS_USER_PROPERTIES;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_VERSION;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.COUNTRY_CODE;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID_TOKEN;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.LANGUAGE_CODE;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.PACKAGE_NAME;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.PLATFORM_VERSION;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.SDK_VERSION;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.TIME_ZONE;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.ENTRIES;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.EXPERIMENT_DESCRIPTIONS;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.STATE;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.util.Log;
+import androidx.annotation.Keep;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.gms.common.util.AndroidUtilsLight;
+import com.google.android.gms.common.util.Hex;
+import com.google.firebase.remoteconfig.BuildConfig;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigClientException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigServerException;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler.FetchResponse;
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Lightweight client for fetching data from the Firebase Remote Config server.
+ *
+ * @author Lucas Png
+ */
+public class ConfigFetchHttpClient {
+ private static final String API_KEY_HEADER = "X-Goog-Api-Key";
+ private static final String ETAG_HEADER = "ETag";
+ private static final String IF_NONE_MATCH_HEADER = "If-None-Match";
+ private static final String X_ANDROID_PACKAGE_HEADER = "X-Android-Package";
+ private static final String X_ANDROID_CERT_HEADER = "X-Android-Cert";
+ private static final String X_GOOGLE_GFE_CAN_RETRY = "X-Google-GFE-Can-Retry";
+
+ private final Context context;
+ private final String appId;
+ private final String apiKey;
+ private final String projectNumber;
+ private final String namespace;
+ private final long connectTimeoutInSeconds;
+ private final long readTimeoutInSeconds;
+
+ /** Creates a client for {@link #fetch}ing data from the Firebase Remote Config server. */
+ public ConfigFetchHttpClient(
+ Context context,
+ String appId,
+ String apiKey,
+ String namespace,
+ long connectTimeoutInSeconds,
+ long readTimeoutInSeconds) {
+ this.context = context;
+ this.appId = appId;
+ this.apiKey = apiKey;
+ this.projectNumber = extractProjectNumberFromAppId(appId);
+ this.namespace = namespace;
+ this.connectTimeoutInSeconds = connectTimeoutInSeconds;
+ this.readTimeoutInSeconds = readTimeoutInSeconds;
+ }
+
+ /** Used to verify that the timeout is being set correctly. */
+ @VisibleForTesting
+ public long getConnectTimeoutInSeconds() {
+ return connectTimeoutInSeconds;
+ }
+
+ /** Used to verify that the timeout is being set correctly. */
+ @VisibleForTesting
+ public long getReadTimeoutInSeconds() {
+ return readTimeoutInSeconds;
+ }
+
+ /**
+ * A regular expression for the GMP App Id format. The first group (index 1) is the project
+ * number.
+ */
+ private static final Pattern GMP_APP_ID_PATTERN =
+ Pattern.compile("^[^:]+:([0-9]+):(android|ios|web):([0-9a-f]+)");
+
+ private static String extractProjectNumberFromAppId(String gmpAppId) {
+ Matcher matcher = GMP_APP_ID_PATTERN.matcher(gmpAppId);
+ return matcher.matches() ? matcher.group(1) : null;
+ }
+
+ /**
+ * Initializes a {@link HttpURLConnection} for fetching data from the Firebase Remote Config
+ * server.
+ */
+ HttpURLConnection createHttpURLConnection() throws FirebaseRemoteConfigException {
+ try {
+ URL url = new URL(getFetchUrl(projectNumber, namespace));
+ return (HttpURLConnection) url.openConnection();
+ } catch (IOException e) {
+ throw new FirebaseRemoteConfigException(e.getMessage());
+ }
+ }
+
+ /**
+ * Returns a {@link JSONObject} that contains the latest fetched ETag and a status field that
+ * denotes if the Firebase Remote Config (FRC) server has updated configs or A/B Testing
+ * experiments. If there has been a change since the last fetch, the {@link JSONObject} also
+ * contains an "entries" field with parameters fetched from the FRC server.
+ *
+ * @param urlConnection a {@link HttpURLConnection} created by a call to {@link
+ * #createHttpURLConnection}.
+ * @param instanceId the Firebase Instance ID that identifies a Firebase App Instance.
+ * @param instanceIdToken a valid Firebase Instance ID Token that authenticates a Firebase App
+ * Instance.
+ * @param analyticsUserProperties a map of Google Analytics User Properties and the device's
+ * corresponding values.
+ * @param lastFetchETag the ETag returned by the last successful fetch call to the FRC server. The
+ * server uses this ETag to determine if there has been a change in the response body since
+ * the last fetch.
+ * @param customHeaders custom HTTP headers that will be sent to the FRC server.
+ * @param currentTime the current time on the device that is performing the fetch.
+ */
+ // TODO(issues/263): Set custom headers in ConfigFetchHttpClient's constructor.
+ @Keep
+ FetchResponse fetch(
+ HttpURLConnection urlConnection,
+ String instanceId,
+ String instanceIdToken,
+ Map analyticsUserProperties,
+ String lastFetchETag,
+ Map customHeaders,
+ Date currentTime)
+ throws FirebaseRemoteConfigException {
+ setUpUrlConnection(urlConnection, lastFetchETag, customHeaders);
+
+ String fetchResponseETag;
+ JSONObject fetchResponse;
+ try {
+ byte[] requestBody =
+ createFetchRequestBody(instanceId, instanceIdToken, analyticsUserProperties)
+ .toString()
+ .getBytes();
+ setFetchRequestBody(urlConnection, requestBody);
+
+ urlConnection.connect();
+
+ int responseCode = urlConnection.getResponseCode();
+ if (responseCode != 200) {
+ throw new FirebaseRemoteConfigServerException(
+ responseCode, urlConnection.getResponseMessage());
+ }
+ fetchResponseETag = urlConnection.getHeaderField(ETAG_HEADER);
+ fetchResponse = getFetchResponseBody(urlConnection);
+ } catch (IOException | JSONException e) {
+ throw new FirebaseRemoteConfigClientException(
+ "The client had an error while calling the backend!", e);
+ } finally {
+ urlConnection.disconnect();
+ }
+
+ if (!backendHasUpdates(fetchResponse)) {
+ return FetchResponse.forBackendHasNoUpdates(currentTime);
+ }
+
+ ConfigContainer fetchedConfigs = extractConfigs(fetchResponse, currentTime);
+ return FetchResponse.forBackendUpdatesFetched(fetchedConfigs, fetchResponseETag);
+ }
+
+ private void setUpUrlConnection(
+ HttpURLConnection urlConnection, String lastFetchEtag, Map customHeaders) {
+ urlConnection.setDoOutput(true);
+ urlConnection.setConnectTimeout((int) SECONDS.toMillis(connectTimeoutInSeconds));
+ urlConnection.setReadTimeout((int) SECONDS.toMillis(readTimeoutInSeconds));
+
+ // Send the last successful Fetch ETag to the FRC Server to calculate if there has been any
+ // change in the Fetch Response since the last fetch call.
+ urlConnection.setRequestProperty(IF_NONE_MATCH_HEADER, lastFetchEtag);
+
+ setCommonRequestHeaders(urlConnection);
+ setCustomRequestHeaders(urlConnection, customHeaders);
+ }
+
+ private String getFetchUrl(String projectNumber, String namespace) {
+ return String.format(FETCH_REGEX_URL, projectNumber, namespace);
+ }
+
+ private void setCommonRequestHeaders(HttpURLConnection urlConnection) {
+ urlConnection.setRequestProperty(API_KEY_HEADER, apiKey);
+
+ // Headers required for Android API Key Restrictions.
+ urlConnection.setRequestProperty(X_ANDROID_PACKAGE_HEADER, context.getPackageName());
+ urlConnection.setRequestProperty(X_ANDROID_CERT_HEADER, getFingerprintHashForPackage());
+
+ // Header to denote request is retryable on the server.
+ urlConnection.setRequestProperty(X_GOOGLE_GFE_CAN_RETRY, "yes");
+
+ // Headers to denote that the request body is a JSONObject.
+ urlConnection.setRequestProperty("Content-Type", "application/json");
+ urlConnection.setRequestProperty("Accept", "application/json");
+ }
+
+ /** Sends developer specified custom headers to the Remote Config server. */
+ private void setCustomRequestHeaders(
+ HttpURLConnection urlConnection, Map customHeaders) {
+ for (Map.Entry customHeaderEntry : customHeaders.entrySet()) {
+ urlConnection.setRequestProperty(customHeaderEntry.getKey(), customHeaderEntry.getValue());
+ }
+ }
+
+ /** Gets the Android package's SHA-1 fingerprint. */
+ private String getFingerprintHashForPackage() {
+ byte[] hash;
+
+ try {
+ hash = AndroidUtilsLight.getPackageCertificateHashBytes(context, context.getPackageName());
+
+ if (hash == null) {
+ Log.e(TAG, "Could not get fingerprint hash for package: " + context.getPackageName());
+ return null;
+ } else {
+ return Hex.bytesToStringUppercase(hash, /* zeroTerminated= */ false);
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.e(TAG, "No such package: " + context.getPackageName(), e);
+ return null;
+ }
+ }
+
+ /**
+ * Returns a request body serialized as a {@link JSONObject}.
+ *
+ * The FRC server's fetch endpoint expects a POST request with a request body, which can be
+ * serialized as a JSON.
+ */
+ private JSONObject createFetchRequestBody(
+ String instanceId, String instanceIdToken, Map analyticsUserProperties)
+ throws FirebaseRemoteConfigClientException {
+ Map requestBodyMap = new HashMap<>();
+
+ if (instanceId == null) {
+ throw new FirebaseRemoteConfigClientException("Fetch failed: Firebase instance id is null.");
+ }
+ requestBodyMap.put(INSTANCE_ID, instanceId);
+
+ requestBodyMap.put(INSTANCE_ID_TOKEN, instanceIdToken);
+ requestBodyMap.put(APP_ID, appId);
+
+ Locale locale = context.getResources().getConfiguration().locale;
+ requestBodyMap.put(COUNTRY_CODE, locale.getCountry());
+ requestBodyMap.put(LANGUAGE_CODE, locale.toString());
+
+ requestBodyMap.put(PLATFORM_VERSION, Integer.toString(android.os.Build.VERSION.SDK_INT));
+
+ requestBodyMap.put(TIME_ZONE, TimeZone.getDefault().getID());
+
+ try {
+ PackageInfo packageInfo =
+ context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
+ if (packageInfo != null) {
+ requestBodyMap.put(APP_VERSION, packageInfo.versionName);
+ }
+ } catch (NameNotFoundException e) {
+ // Leave app version blank if package cannot be found.
+ }
+
+ requestBodyMap.put(PACKAGE_NAME, context.getPackageName());
+ requestBodyMap.put(SDK_VERSION, BuildConfig.VERSION_NAME);
+
+ requestBodyMap.put(ANALYTICS_USER_PROPERTIES, analyticsUserProperties);
+
+ return new JSONObject(requestBodyMap);
+ }
+
+ private void setFetchRequestBody(HttpURLConnection urlConnection, byte[] requestBody)
+ throws IOException {
+ urlConnection.setFixedLengthStreamingMode(requestBody.length);
+ OutputStream out = new BufferedOutputStream(urlConnection.getOutputStream());
+ out.write(requestBody);
+ out.flush();
+ out.close();
+ }
+
+ private JSONObject getFetchResponseBody(URLConnection urlConnection)
+ throws IOException, JSONException {
+ InputStream in = new BufferedInputStream(urlConnection.getInputStream());
+ StringBuilder responseStringBuilder = new StringBuilder();
+ int current = 0;
+ while ((current = in.read()) != -1) {
+ responseStringBuilder.append((char) current);
+ }
+
+ return new JSONObject(responseStringBuilder.toString());
+ }
+
+ /** Returns true if the backend has updated fetch values. */
+ private boolean backendHasUpdates(JSONObject response) {
+ try {
+ return !response.get(STATE).equals("NO_CHANGE");
+ } catch (JSONException e) {
+ // The V2 server does not return a state, so assume a null state means there is a valid
+ // update.
+ return true;
+ }
+ }
+
+ /**
+ * Converts the given {@link JSONObject} Fetch response into a {@link ConfigContainer}.
+ *
+ * @param fetchResponse The fetch response from the FRC server.
+ * @param fetchTime The time, in millis since epoch, when the fetch request was made.
+ * @return A {@link ConfigContainer} representing the fetch response from the server.
+ */
+ private static ConfigContainer extractConfigs(JSONObject fetchResponse, Date fetchTime)
+ throws FirebaseRemoteConfigClientException {
+ try {
+ ConfigContainer.Builder containerBuilder =
+ ConfigContainer.newBuilder().withFetchTime(fetchTime);
+
+ JSONObject entries = null;
+ try {
+ entries = fetchResponse.getJSONObject(ENTRIES);
+ } catch (JSONException e) {
+ // Do nothing if entries do not exist.
+ }
+ if (entries != null) {
+ containerBuilder.replaceConfigsWith(entries);
+ }
+
+ JSONArray experimentDescriptions = null;
+ try {
+ experimentDescriptions = fetchResponse.getJSONArray(EXPERIMENT_DESCRIPTIONS);
+ } catch (JSONException e) {
+ // Do nothing if entries do not exist.
+ }
+ if (experimentDescriptions != null) {
+ containerBuilder.withAbtExperiments(experimentDescriptions);
+ }
+
+ return containerBuilder.build();
+ } catch (JSONException e) {
+ throw new FirebaseRemoteConfigClientException(
+ "Fetch failed: fetch response could not be parsed.", e);
+ }
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigGetParameterHandler.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigGetParameterHandler.java
new file mode 100644
index 00000000000..284adc39248
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigGetParameterHandler.java
@@ -0,0 +1,414 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_BOOLEAN;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_BYTE_ARRAY;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_DOUBLE;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_LONG;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_STRING;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.TAG;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.VALUE_SOURCE_DEFAULT;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.VALUE_SOURCE_REMOTE;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.VALUE_SOURCE_STATIC;
+
+import android.util.Log;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfig;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigValue;
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Pattern;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A handler for getting values stored in the Firebase Remote Config (FRC) SDK.
+ *
+ * Provides methods to return parameter values as one of the six types supported by FRC: {@code
+ * booolean}, {@code byte[]}, {@code double}, {@code long}, {@link FirebaseRemoteConfigValue}, and
+ * {@link String}.
+ *
+ *
Evaluates the value of a parameter in the following order:
+ *
+ *
+ * The activated value, if the activated {@link ConfigCacheClient} contains the key.
+ * The default value, if the defaults {@link ConfigCacheClient} contains the key.
+ * The static default value for the given type, as defined in the static constants of {@link
+ * FirebaseRemoteConfig}.
+ *
+ *
+ * @author Miraziz Yusupov
+ */
+public class ConfigGetParameterHandler {
+ /** Byte arrays in FRC are encoded as UTF-8 Strings. */
+ @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+ public static final Charset FRC_BYTE_ARRAY_ENCODING = Charset.forName("UTF-8");
+ /** Regular expressions that will evaluate to a "true" boolean. */
+ static final Pattern TRUE_REGEX =
+ Pattern.compile("^(1|true|t|yes|y|on)$", Pattern.CASE_INSENSITIVE);
+ /** Regular expressions that will evaluate to a "false" boolean. */
+ static final Pattern FALSE_REGEX =
+ Pattern.compile("^(0|false|f|no|n|off|)$", Pattern.CASE_INSENSITIVE);
+
+ private final ConfigCacheClient activatedConfigsCache;
+ private final ConfigCacheClient defaultConfigsCache;
+
+ public ConfigGetParameterHandler(
+ ConfigCacheClient activatedConfigsCache, ConfigCacheClient defaultConfigsCache) {
+ this.activatedConfigsCache = activatedConfigsCache;
+ this.defaultConfigsCache = defaultConfigsCache;
+ }
+
+ /**
+ * Returns the parameter value of the given parameter key as a {@link String}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The value in the activated cache, if the key exists.
+ * The value in the defaults cache, if the key exists.
+ * {@link FirebaseRemoteConfig#DEFAULT_VALUE_FOR_STRING}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key.
+ */
+ public String getString(String key) {
+ String activatedString = getStringFromCache(activatedConfigsCache, key);
+ if (activatedString != null) {
+ return activatedString;
+ }
+
+ String defaultsString = getStringFromCache(defaultConfigsCache, key);
+ if (defaultsString != null) {
+ return defaultsString;
+ }
+
+ logParameterValueDoesNotExist(key, "String");
+ return DEFAULT_VALUE_FOR_STRING;
+ }
+
+ /**
+ * Returns the parameter value of the given parameter key as a {@code boolean}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The value in the activated cache, if the key exists and the value can be converted into a
+ * {@code boolean}.
+ * The value in the defaults cache, if the key exists and the value can be converted into a
+ * {@code boolean}.
+ * {@link FirebaseRemoteConfig#DEFAULT_VALUE_FOR_BOOLEAN}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key with a {@code boolean} parameter value.
+ */
+ public boolean getBoolean(String key) {
+ String activatedString = getStringFromCache(activatedConfigsCache, key);
+ if (activatedString != null) {
+ if (TRUE_REGEX.matcher(activatedString).matches()) {
+ return true;
+ } else if (FALSE_REGEX.matcher(activatedString).matches()) {
+ return false;
+ }
+ }
+
+ String defaultsString = getStringFromCache(defaultConfigsCache, key);
+ if (defaultsString != null) {
+ if (TRUE_REGEX.matcher(defaultsString).matches()) {
+ return true;
+ } else if (FALSE_REGEX.matcher(defaultsString).matches()) {
+ return false;
+ }
+ }
+
+ logParameterValueDoesNotExist(key, "Boolean");
+ return DEFAULT_VALUE_FOR_BOOLEAN;
+ }
+
+ /**
+ * Returns the parameter value of the given parameter key as a {@code byte[]}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The value in the activated cache, if the key exists.
+ * The value in the defaults cache, if the key exists.
+ * {@link FirebaseRemoteConfig#DEFAULT_VALUE_FOR_BYTE_ARRAY}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key.
+ */
+ public byte[] getByteArray(String key) {
+ String activatedString = getStringFromCache(activatedConfigsCache, key);
+ if (activatedString != null) {
+ return activatedString.getBytes(FRC_BYTE_ARRAY_ENCODING);
+ }
+
+ String defaultsString = getStringFromCache(defaultConfigsCache, key);
+ if (defaultsString != null) {
+ return defaultsString.getBytes(FRC_BYTE_ARRAY_ENCODING);
+ }
+
+ logParameterValueDoesNotExist(key, "ByteArray");
+ return DEFAULT_VALUE_FOR_BYTE_ARRAY;
+ }
+
+ /**
+ * Returns the parameter value of the given parameter key as a {@code double}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The value in the activated cache, if the key exists and the value can be converted into a
+ * {@code double}.
+ * The value in the defaults cache, if the key exists and the value can be converted into a
+ * {@code double}.
+ * {@link FirebaseRemoteConfig#DEFAULT_VALUE_FOR_DOUBLE}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key with a {@code double} parameter value.
+ */
+ public double getDouble(String key) {
+ Double activatedDouble = getDoubleFromCache(activatedConfigsCache, key);
+ if (activatedDouble != null) {
+ return activatedDouble;
+ }
+
+ Double defaultsDouble = getDoubleFromCache(defaultConfigsCache, key);
+ if (defaultsDouble != null) {
+ return defaultsDouble;
+ }
+
+ logParameterValueDoesNotExist(key, "Double");
+ return DEFAULT_VALUE_FOR_DOUBLE;
+ }
+
+ /**
+ * Returns the parameter value of the given parameter key as a {@code long}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The value in the activated cache, if the key exists and the value can be converted into a
+ * {@code long}.
+ * The value in the defaults cache, if the key exists and the value can be converted into a
+ * {@code long}.
+ * {@link FirebaseRemoteConfig#DEFAULT_VALUE_FOR_LONG}.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key with a {@code long} parameter value.
+ */
+ public long getLong(String key) {
+ Long activatedLong = getLongFromCache(activatedConfigsCache, key);
+ if (activatedLong != null) {
+ return activatedLong;
+ }
+
+ Long defaultsLong = getLongFromCache(defaultConfigsCache, key);
+ if (defaultsLong != null) {
+ return defaultsLong;
+ }
+
+ logParameterValueDoesNotExist(key, "Long");
+ return DEFAULT_VALUE_FOR_LONG;
+ }
+
+ /**
+ * Returns the parameter value of the given parameter key as a {@link FirebaseRemoteConfigValue}.
+ *
+ * Evaluates the value of the parameter in the following order:
+ *
+ *
+ * The value in the activated cache, if the key exists.
+ * The value in the defaults cache, if the key exists.
+ * A {@link FirebaseRemoteConfigValue} that returns the static value for each type.
+ *
+ *
+ * @param key A Firebase Remote Config parameter key.
+ */
+ public FirebaseRemoteConfigValue getValue(String key) {
+ String activatedString = getStringFromCache(activatedConfigsCache, key);
+ if (activatedString != null) {
+ return new FirebaseRemoteConfigValueImpl(activatedString, VALUE_SOURCE_REMOTE);
+ }
+
+ String defaultsString = getStringFromCache(defaultConfigsCache, key);
+ if (defaultsString != null) {
+ return new FirebaseRemoteConfigValueImpl(defaultsString, VALUE_SOURCE_DEFAULT);
+ }
+
+ logParameterValueDoesNotExist(key, "FirebaseRemoteConfigValue");
+ return new FirebaseRemoteConfigValueImpl(DEFAULT_VALUE_FOR_STRING, VALUE_SOURCE_STATIC);
+ }
+
+ /**
+ * Returns an ordered {@link Set} of all the FRC keys with the given prefix.
+ *
+ * The set will contain all the keys in the activated and defaults configs with the given
+ * prefix.
+ *
+ * @param prefix A prefix for FRC keys. If the prefix is empty, all keys are returned.
+ */
+ public Set getKeysByPrefix(String prefix) {
+ if (prefix == null) {
+ prefix = "";
+ }
+
+ TreeSet keysWithPrefix = new TreeSet<>();
+
+ ConfigContainer activatedConfigs = getConfigsFromCache(activatedConfigsCache);
+ if (activatedConfigs != null) {
+ keysWithPrefix.addAll(getKeysByPrefix(prefix, activatedConfigs));
+ }
+
+ ConfigContainer defaultsConfigs = getConfigsFromCache(defaultConfigsCache);
+ if (defaultsConfigs != null) {
+ keysWithPrefix.addAll(getKeysByPrefix(prefix, defaultsConfigs));
+ }
+
+ return keysWithPrefix;
+ }
+
+ /** Returns a {@link TreeSet} of the keys in {@code configs} with the given prefix. */
+ private static TreeSet getKeysByPrefix(String prefix, ConfigContainer configs) {
+ TreeSet keysWithPrefix = new TreeSet<>();
+
+ Iterator stringIterator = configs.getConfigs().keys();
+ while (stringIterator.hasNext()) {
+ String currentKey = stringIterator.next();
+ if (currentKey.startsWith(prefix)) {
+ keysWithPrefix.add(currentKey);
+ }
+ }
+
+ return keysWithPrefix;
+ }
+
+ /**
+ * Returns {@link Map} of FRC key value pairs.
+ *
+ * Evaluates the values of the parameters in the following order:
+ *
+ *
+ * The value in the activated cache, if the key exists.
+ * The value in the defaults cache, if the key exists.
+ *
+ */
+ public Map getAll() {
+ Set keySet = new HashSet<>();
+ keySet.addAll(getKeySetFromCache(activatedConfigsCache));
+ keySet.addAll(getKeySetFromCache(defaultConfigsCache));
+
+ HashMap allConfigs = new HashMap<>();
+ for (String key : keySet) {
+ allConfigs.put(key, getValue(key));
+ }
+ return allConfigs;
+ }
+
+ /**
+ * Returns the FRC parameter value for the given key in the given cache as a {@link String}, or
+ * {@code null} if the key does not exist in the cache.
+ *
+ * @param cacheClient the cache client the parameter is stored in.
+ * @param key the FRC parameter key.
+ */
+ @Nullable
+ private static String getStringFromCache(ConfigCacheClient cacheClient, String key) {
+ ConfigContainer cachedContainer = getConfigsFromCache(cacheClient);
+ if (cachedContainer == null) {
+ return null;
+ }
+
+ try {
+ return cachedContainer.getConfigs().getString(key);
+ } catch (JSONException ignored) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns the FRC parameter value for the given key in the given cache as a {@link Double}, or
+ * {@code null} if the key does not have a {@code double} value in the cache.
+ */
+ @Nullable
+ private static Double getDoubleFromCache(ConfigCacheClient cacheClient, String key) {
+ ConfigContainer cachedContainer = getConfigsFromCache(cacheClient);
+ if (cachedContainer == null) {
+ return null;
+ }
+
+ try {
+ return cachedContainer.getConfigs().getDouble(key);
+ } catch (JSONException ignored) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns the FRC parameter value for the given key in the given cache as a {@link Long}, or
+ * {@code null} if the key does not have a {@code long} value in the cache.
+ */
+ @Nullable
+ private static Long getLongFromCache(ConfigCacheClient cacheClient, String key) {
+ ConfigContainer cachedContainer = getConfigsFromCache(cacheClient);
+ if (cachedContainer == null) {
+ return null;
+ }
+
+ try {
+ return cachedContainer.getConfigs().getLong(key);
+ } catch (JSONException ignored) {
+ return null;
+ }
+ }
+
+ /** Returns all FRC parameter keys in the given cache. */
+ private static Set getKeySetFromCache(ConfigCacheClient cacheClient) {
+ Set keySet = new HashSet<>();
+ ConfigContainer configContainer = getConfigsFromCache(cacheClient);
+ if (configContainer == null) {
+ return keySet;
+ }
+
+ JSONObject configs = configContainer.getConfigs();
+ Iterator keyIterator = configs.keys();
+ while (keyIterator.hasNext()) {
+ keySet.add(keyIterator.next());
+ }
+ return keySet;
+ }
+
+ /**
+ * Returns the FRC configs in the given cache as {@link ConfigContainer} or {@code null} if there
+ * are no configs in the cache.
+ */
+ @Nullable
+ private static ConfigContainer getConfigsFromCache(ConfigCacheClient cacheClient) {
+ return cacheClient.getBlocking();
+ }
+
+ private static void logParameterValueDoesNotExist(String key, String valueType) {
+ Log.w(
+ TAG, String.format("No value of type '%s' exists for parameter key '%s'.", valueType, key));
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigMetadataClient.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigMetadataClient.java
new file mode 100644
index 00000000000..50c91b41a5b
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigMetadataClient.java
@@ -0,0 +1,269 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_FAILURE;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_THROTTLED;
+import static com.google.firebase.remoteconfig.RemoteConfigComponent.NETWORK_CONNECTION_TIMEOUT_IN_SECONDS;
+import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS;
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.content.SharedPreferences;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigInfo;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings;
+import java.lang.annotation.Retention;
+import java.util.Date;
+
+/**
+ * Client for handling Firebase Remote Config (FRC) metadata that is saved to disk and persisted
+ * across App life cycles.
+ *
+ * @author Miraziz Yusupov
+ */
+public class ConfigMetadataClient {
+ @Retention(SOURCE)
+ @IntDef({
+ LAST_FETCH_STATUS_SUCCESS,
+ LAST_FETCH_STATUS_NO_FETCH_YET,
+ LAST_FETCH_STATUS_FAILURE,
+ LAST_FETCH_STATUS_THROTTLED
+ })
+ @interface LastFetchStatus {}
+
+ /** Indicates that there have been no successful fetch attempts yet. */
+ @VisibleForTesting public static final long LAST_FETCH_TIME_IN_MILLIS_NO_FETCH_YET = -1L;
+
+ static final Date LAST_FETCH_TIME_NO_FETCH_YET = new Date(LAST_FETCH_TIME_IN_MILLIS_NO_FETCH_YET);
+
+ @VisibleForTesting static final int NO_FAILED_FETCHES = 0;
+ private static final long NO_BACKOFF_TIME_IN_MILLIS = -1L;
+ @VisibleForTesting static final Date NO_BACKOFF_TIME = new Date(NO_BACKOFF_TIME_IN_MILLIS);
+
+ private static final String DEVELOPER_MODE_KEY = "is_developer_mode_enabled";
+ private static final String FETCH_TIMEOUT_IN_SECONDS_KEY = "fetch_timeout_in_seconds";
+ private static final String MINIMUM_FETCH_INTERVAL_IN_SECONDS_KEY =
+ "minimum_fetch_interval_in_seconds";
+ private static final String LAST_FETCH_STATUS_KEY = "last_fetch_status";
+ private static final String LAST_SUCCESSFUL_FETCH_TIME_IN_MILLIS_KEY =
+ "last_fetch_time_in_millis";
+ private static final String LAST_FETCH_ETAG_KEY = "last_fetch_etag";
+ private static final String BACKOFF_END_TIME_IN_MILLIS_KEY = "backoff_end_time_in_millis";
+ private static final String NUM_FAILED_FETCHES_KEY = "num_failed_fetches";
+
+ private final SharedPreferences frcMetadata;
+
+ private final Object frcInfoLock;
+ private final Object backoffMetadataLock;
+
+ public ConfigMetadataClient(SharedPreferences frcMetadata) {
+ this.frcMetadata = frcMetadata;
+ this.frcInfoLock = new Object();
+ this.backoffMetadataLock = new Object();
+ }
+
+ public boolean isDeveloperModeEnabled() {
+ return frcMetadata.getBoolean(DEVELOPER_MODE_KEY, false);
+ }
+
+ public long getFetchTimeoutInSeconds() {
+ return frcMetadata.getLong(FETCH_TIMEOUT_IN_SECONDS_KEY, NETWORK_CONNECTION_TIMEOUT_IN_SECONDS);
+ }
+
+ public long getMinimumFetchIntervalInSeconds() {
+ return frcMetadata.getLong(
+ MINIMUM_FETCH_INTERVAL_IN_SECONDS_KEY, DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS);
+ }
+
+ @LastFetchStatus
+ int getLastFetchStatus() {
+ return frcMetadata.getInt(LAST_FETCH_STATUS_KEY, LAST_FETCH_STATUS_NO_FETCH_YET);
+ }
+
+ Date getLastSuccessfulFetchTime() {
+ return new Date(
+ frcMetadata.getLong(
+ LAST_SUCCESSFUL_FETCH_TIME_IN_MILLIS_KEY, LAST_FETCH_TIME_IN_MILLIS_NO_FETCH_YET));
+ }
+
+ @Nullable
+ String getLastFetchETag() {
+ return frcMetadata.getString(LAST_FETCH_ETAG_KEY, null);
+ }
+
+ public FirebaseRemoteConfigInfo getInfo() {
+ // A lock is used here to prevent the setters in this class from changing the state of
+ // frcMetadata during a getInfo call.
+ synchronized (frcInfoLock) {
+ long lastSuccessfulFetchTimeInMillis =
+ frcMetadata.getLong(
+ LAST_SUCCESSFUL_FETCH_TIME_IN_MILLIS_KEY, LAST_FETCH_TIME_IN_MILLIS_NO_FETCH_YET);
+ @LastFetchStatus
+ int lastFetchStatus =
+ frcMetadata.getInt(LAST_FETCH_STATUS_KEY, LAST_FETCH_STATUS_NO_FETCH_YET);
+
+ FirebaseRemoteConfigSettings settings =
+ new FirebaseRemoteConfigSettings.Builder()
+ .setDeveloperModeEnabled(frcMetadata.getBoolean(DEVELOPER_MODE_KEY, false))
+ .setFetchTimeoutInSeconds(
+ frcMetadata.getLong(
+ FETCH_TIMEOUT_IN_SECONDS_KEY, NETWORK_CONNECTION_TIMEOUT_IN_SECONDS))
+ .setMinimumFetchIntervalInSeconds(
+ frcMetadata.getLong(
+ MINIMUM_FETCH_INTERVAL_IN_SECONDS_KEY,
+ DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS))
+ .build();
+
+ return FirebaseRemoteConfigInfoImpl.newBuilder()
+ .withLastFetchStatus(lastFetchStatus)
+ .withLastSuccessfulFetchTimeInMillis(lastSuccessfulFetchTimeInMillis)
+ .withConfigSettings(settings)
+ .build();
+ }
+ }
+
+ /**
+ * Clears all metadata values from memory and disk.
+ *
+ * The method is blocking and returns only when the values in disk are also cleared.
+ */
+ @WorkerThread
+ public void clear() {
+ synchronized (frcInfoLock) {
+ frcMetadata.edit().clear().commit();
+ }
+ }
+
+ /**
+ * Updates the stored settings with the given {@link FirebaseRemoteConfigSettings}, and blocks
+ * until the changes are persisted to disk.
+ *
+ * @param settings the new settings to apply.
+ */
+ @WorkerThread
+ public void setConfigSettings(FirebaseRemoteConfigSettings settings) {
+ synchronized (frcInfoLock) {
+ frcMetadata
+ .edit()
+ .putBoolean(DEVELOPER_MODE_KEY, settings.isDeveloperModeEnabled())
+ .putLong(FETCH_TIMEOUT_IN_SECONDS_KEY, settings.getFetchTimeoutInSeconds())
+ .putLong(
+ MINIMUM_FETCH_INTERVAL_IN_SECONDS_KEY, settings.getMinimumFetchIntervalInSeconds())
+ .commit();
+ }
+ }
+
+ /**
+ * Updates the stored settings with the given {@link FirebaseRemoteConfigSettings} and returns
+ * before waiting on the disk write to complete.
+ *
+ * @param settings the new settings to apply.
+ */
+ public void setConfigSettingsWithoutWaitingOnDiskWrite(FirebaseRemoteConfigSettings settings) {
+ synchronized (frcInfoLock) {
+ frcMetadata
+ .edit()
+ .putBoolean(DEVELOPER_MODE_KEY, settings.isDeveloperModeEnabled())
+ .putLong(FETCH_TIMEOUT_IN_SECONDS_KEY, settings.getFetchTimeoutInSeconds())
+ .putLong(
+ MINIMUM_FETCH_INTERVAL_IN_SECONDS_KEY, settings.getMinimumFetchIntervalInSeconds())
+ .apply();
+ }
+ }
+
+ void updateLastFetchAsSuccessfulAt(Date fetchTime) {
+ synchronized (frcInfoLock) {
+ frcMetadata
+ .edit()
+ .putInt(LAST_FETCH_STATUS_KEY, LAST_FETCH_STATUS_SUCCESS)
+ .putLong(LAST_SUCCESSFUL_FETCH_TIME_IN_MILLIS_KEY, fetchTime.getTime())
+ .apply();
+ }
+ }
+
+ void updateLastFetchAsFailed() {
+ synchronized (frcInfoLock) {
+ frcMetadata.edit().putInt(LAST_FETCH_STATUS_KEY, LAST_FETCH_STATUS_FAILURE).apply();
+ }
+ }
+
+ void updateLastFetchAsThrottled() {
+ synchronized (frcInfoLock) {
+ frcMetadata.edit().putInt(LAST_FETCH_STATUS_KEY, LAST_FETCH_STATUS_THROTTLED).apply();
+ }
+ }
+
+ void setLastFetchETag(String eTag) {
+ synchronized (frcInfoLock) {
+ frcMetadata.edit().putString(LAST_FETCH_ETAG_KEY, eTag).apply();
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Exponential backoff logic.
+ // -----------------------------------------------------------------
+
+ BackoffMetadata getBackoffMetadata() {
+ synchronized (backoffMetadataLock) {
+ return new BackoffMetadata(
+ frcMetadata.getInt(NUM_FAILED_FETCHES_KEY, NO_FAILED_FETCHES),
+ new Date(frcMetadata.getLong(BACKOFF_END_TIME_IN_MILLIS_KEY, NO_BACKOFF_TIME_IN_MILLIS)));
+ }
+ }
+
+ void setBackoffMetadata(int numFailedFetches, Date backoffEndTime) {
+ synchronized (backoffMetadataLock) {
+ frcMetadata
+ .edit()
+ .putInt(NUM_FAILED_FETCHES_KEY, numFailedFetches)
+ .putLong(BACKOFF_END_TIME_IN_MILLIS_KEY, backoffEndTime.getTime())
+ .apply();
+ }
+ }
+
+ void resetBackoff() {
+ setBackoffMetadata(NO_FAILED_FETCHES, NO_BACKOFF_TIME);
+ }
+
+ /**
+ * Container for backoff metadata values such as the number of failed fetches and the backoff end
+ * time.
+ *
+ *
The purpose of this class is to avoid race conditions when retrieving backoff metadata
+ * values separately.
+ */
+ static class BackoffMetadata {
+ private int numFailedFetches;
+ private Date backoffEndTime;
+
+ BackoffMetadata(int numFailedFetches, Date backoffEndTime) {
+ this.numFailedFetches = numFailedFetches;
+ this.backoffEndTime = backoffEndTime;
+ }
+
+ int getNumFailedFetches() {
+ return numFailedFetches;
+ }
+
+ Date getBackoffEndTime() {
+ return backoffEndTime;
+ }
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigStorageClient.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigStorageClient.java
new file mode 100644
index 00000000000..b9fbda05504
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigStorageClient.java
@@ -0,0 +1,137 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import android.content.Context;
+import androidx.annotation.AnyThread;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.VisibleForTesting;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import javax.annotation.Nullable;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * File-backed storage client for managing {@link ConfigContainer}s in disk.
+ *
+ *
At most one instance of this class exists for any given file, so all calls to {@link
+ * #getInstance(Context, String)} with the same context and file name will return the same instance.
+ *
+ *
Since there's a one to one mapping between files and storage clients, and every method in the
+ * client is synchronized, two threads in the same process should never write to the same file
+ * simultaneously.
+ *
+ * @author Miraziz Yusupov
+ */
+@AnyThread
+public class ConfigStorageClient {
+ @GuardedBy("ConfigStorageClient.class")
+ private static final Map clientInstances = new HashMap<>();
+
+ private static final String JSON_STRING_ENCODING = "UTF-8";
+
+ private final Context context;
+ private final String fileName;
+
+ /** Creates a new storage client backed by the specified file. */
+ private ConfigStorageClient(Context context, String fileName) {
+ this.context = context;
+ this.fileName = fileName;
+ }
+
+ /**
+ * Writes the {@link ConfigContainer} to disk.
+ *
+ * Writes are non-atomic, so, if the write fails, the disk will likely be corrupted.
+ *
+ *
Possible reasons for failures while writing include:
+ *
+ *
+ * Out of disk space
+ * Power outage
+ * Process termination
+ *
+ *
+ * @return {@link Void} because {@link com.google.android.gms.tasks.Tasks#call(Callable)} requires
+ * a non-void return value.
+ * @throws IOException if the file write fails.
+ */
+ public synchronized Void write(ConfigContainer container) throws IOException {
+ // TODO(issues/262): Consider using the AtomicFile class instead.
+ try (FileOutputStream outputStream = context.openFileOutput(fileName, Context.MODE_PRIVATE)) {
+ outputStream.write(container.toString().getBytes(JSON_STRING_ENCODING));
+ }
+ return null;
+ }
+
+ /**
+ * Reads and returns the {@link ConfigContainer} stored in disk.
+ *
+ * @return a valid {@link ConfigContainer} or null if the file was corrupt or not found.
+ * @throws IOException if the file read fails.
+ */
+ @Nullable
+ public synchronized ConfigContainer read() throws IOException {
+ try (FileInputStream fileInputStream = context.openFileInput(fileName)) {
+ byte[] bytes = new byte[fileInputStream.available()];
+ fileInputStream.read(bytes, 0, bytes.length);
+ String containerJsonString = new String(bytes, JSON_STRING_ENCODING);
+
+ JSONObject containerJson = new JSONObject(containerJsonString);
+ return ConfigContainer.copyOf(containerJson);
+ } catch (JSONException | FileNotFoundException e) {
+ // File might not have been written to yet, so this not an irrecoverable error.
+ return null;
+ }
+ }
+
+ /**
+ * Clears the {@link ConfigContainer} in disk.
+ *
+ * @return {@link Void} because {@link com.google.android.gms.tasks.Tasks#call(Callable)} requires
+ * a non-void return value.
+ */
+ public synchronized Void clear() {
+ context.deleteFile(fileName);
+ return null;
+ }
+
+ /**
+ * Returns an instance of {@link ConfigStorageClient} for the given context and file name. The
+ * same instance is always returned for all calls with the same file name.
+ */
+ public static synchronized ConfigStorageClient getInstance(Context context, String fileName) {
+ if (!clientInstances.containsKey(fileName)) {
+ clientInstances.put(fileName, new ConfigStorageClient(context, fileName));
+ }
+ return clientInstances.get(fileName);
+ }
+
+ @VisibleForTesting
+ public static synchronized void clearInstancesForTest() {
+ clientInstances.clear();
+ }
+
+ /** Returns the name of the file associated with this storage client. */
+ String getFileName() {
+ return fileName;
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/DefaultsXmlParser.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/DefaultsXmlParser.java
new file mode 100644
index 00000000000..e7107441977
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/DefaultsXmlParser.java
@@ -0,0 +1,132 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.TAG;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import android.util.Log;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.xmlpull.v1.XmlPullParserException;
+
+/**
+ * Parser for the defaults XML file.
+ *
+ * Firebase Remote Config (FRC) users can provide an XML file with a map of default values to be
+ * used when no fetched values are available. This class helps parse that XML into a Java {@link
+ * Map}.
+ *
+ *
The parser saves the texts of the {@code XML_TAG_KEY} and {@code XML_TAG_VALUE} tags inside
+ * each {@code XML_TAG_ENTRY} as a key-value pair and returns a map of all such pairs.
+ *
+ *
For example, consider the following XML file:
+ *
+ *
{@code
+ *
+ *
+ * first_default_key
+ * first_default_value
+ *
+ *
+ * second_default_key
+ * second_default_value
+ *
+ *
+ * third_default_key
+ * third_default_value
+ *
+ *
+ * fourth_default_key
+ * fourth_default_value
+ *
+ * }
+ *
+ * Only the "second_default_key, second_default_value" pair would be recorded, since the remaining
+ * tags are malformed.
+ *
+ * @author Miraziz Yusupov
+ */
+public class DefaultsXmlParser {
+ private static final String XML_TAG_ENTRY = "entry";
+ private static final String XML_TAG_KEY = "key";
+ private static final String XML_TAG_VALUE = "value";
+
+ /**
+ * Returns a {@link Map} of default FRC values parsed from the defaults XML file.
+ *
+ * @param context the application context.
+ * @param resourceId the resource id of the defaults XML file.
+ */
+ public static Map getDefaultsFromXml(Context context, int resourceId) {
+ Map defaultsMap = new HashMap<>();
+
+ try {
+ Resources resources = context.getResources();
+ if (resources == null) {
+ Log.e(
+ TAG,
+ "Could not find the resources of the current context "
+ + "while trying to set defaults from an XML.");
+ return defaultsMap;
+ }
+
+ XmlResourceParser xmlParser = resources.getXml(resourceId);
+
+ String curTag = null;
+ String key = null;
+ String value = null;
+
+ int eventType = xmlParser.getEventType();
+ while (eventType != XmlResourceParser.END_DOCUMENT) {
+ if (eventType == XmlResourceParser.START_TAG) {
+ curTag = xmlParser.getName();
+ } else if (eventType == XmlResourceParser.END_TAG) {
+ if (xmlParser.getName().equals(XML_TAG_ENTRY)) {
+ if (key != null && value != null) {
+ defaultsMap.put(key, value);
+ } else {
+ Log.w(TAG, "An entry in the defaults XML has an invalid key and/or value tag.");
+ }
+ key = null;
+ value = null;
+ }
+ curTag = null;
+ } else if (eventType == XmlResourceParser.TEXT) {
+ if (curTag != null) {
+ switch (curTag) {
+ case XML_TAG_KEY:
+ key = xmlParser.getText();
+ break;
+ case XML_TAG_VALUE:
+ value = xmlParser.getText();
+ break;
+ default:
+ Log.w(TAG, "Encountered an unexpected tag while parsing the defaults XML.");
+ break;
+ }
+ }
+ }
+ eventType = xmlParser.next();
+ }
+ } catch (XmlPullParserException | IOException e) {
+ Log.e(TAG, "Encountered an error while parsing the defaults XML file.", e);
+ }
+ return defaultsMap;
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigInfoImpl.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigInfoImpl.java
new file mode 100644
index 00000000000..15f0bb00028
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigInfoImpl.java
@@ -0,0 +1,88 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigInfo;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient.LastFetchStatus;
+
+/**
+ * Impl class for FirebaseRemoteConfigInfo.
+ *
+ * @author Miraziz Yusupov
+ * @hide
+ */
+public class FirebaseRemoteConfigInfoImpl implements FirebaseRemoteConfigInfo {
+ private final long lastSuccessfulFetchTimeInMillis;
+ @LastFetchStatus private final int lastFetchStatus;
+ private final FirebaseRemoteConfigSettings configSettings;
+
+ private FirebaseRemoteConfigInfoImpl(
+ long lastSuccessfulFetchTimeInMillis,
+ int lastFetchStatus,
+ FirebaseRemoteConfigSettings configSettings) {
+ this.lastSuccessfulFetchTimeInMillis = lastSuccessfulFetchTimeInMillis;
+ this.lastFetchStatus = lastFetchStatus;
+ this.configSettings = configSettings;
+ }
+
+ @Override
+ public long getFetchTimeMillis() {
+ return lastSuccessfulFetchTimeInMillis;
+ }
+
+ @Override
+ public int getLastFetchStatus() {
+ return lastFetchStatus;
+ }
+
+ @Override
+ public FirebaseRemoteConfigSettings getConfigSettings() {
+ return configSettings;
+ }
+
+ /** Builder for creating an instance of {@link FirebaseRemoteConfigInfo}. */
+ public static class Builder {
+ private Builder() {}
+
+ private long builderLastSuccessfulFetchTimeInMillis;
+ @LastFetchStatus private int builderLastFetchStatus;
+ private FirebaseRemoteConfigSettings builderConfigSettings;
+
+ public Builder withLastSuccessfulFetchTimeInMillis(long fetchTimeInMillis) {
+ this.builderLastSuccessfulFetchTimeInMillis = fetchTimeInMillis;
+ return this;
+ }
+
+ Builder withLastFetchStatus(@LastFetchStatus int lastFetchStatus) {
+ this.builderLastFetchStatus = lastFetchStatus;
+ return this;
+ }
+
+ Builder withConfigSettings(FirebaseRemoteConfigSettings configSettings) {
+ this.builderConfigSettings = configSettings;
+ return this;
+ }
+
+ public FirebaseRemoteConfigInfoImpl build() {
+ return new FirebaseRemoteConfigInfoImpl(
+ builderLastSuccessfulFetchTimeInMillis, builderLastFetchStatus, builderConfigSettings);
+ }
+ }
+
+ static Builder newBuilder() {
+ return new Builder();
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigValueImpl.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigValueImpl.java
new file mode 100644
index 00000000000..1326165f731
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigValueImpl.java
@@ -0,0 +1,121 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler.FALSE_REGEX;
+import static com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler.FRC_BYTE_ARRAY_ENCODING;
+import static com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler.TRUE_REGEX;
+
+import com.google.firebase.remoteconfig.FirebaseRemoteConfig;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigValue;
+
+/**
+ * Implementation of {@link FirebaseRemoteConfigValue}.
+ *
+ * @author Miraziz Yusupov
+ */
+public class FirebaseRemoteConfigValueImpl implements FirebaseRemoteConfigValue {
+ private static final String ILLEGAL_ARGUMENT_STRING_FORMAT =
+ "[Value: %s] cannot be converted to a %s.";
+
+ private final String value;
+ private final int source;
+
+ FirebaseRemoteConfigValueImpl(String value, int source) {
+ this.value = value;
+ this.source = source;
+ }
+
+ @Override
+ public long asLong() {
+ if (source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) {
+ return FirebaseRemoteConfig.DEFAULT_VALUE_FOR_LONG;
+ }
+
+ String valueAsString = asTrimmedString();
+ try {
+ return Long.valueOf(valueAsString);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ String.format(ILLEGAL_ARGUMENT_STRING_FORMAT, valueAsString, "long"), e);
+ }
+ }
+
+ @Override
+ public double asDouble() {
+ if (source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) {
+ return FirebaseRemoteConfig.DEFAULT_VALUE_FOR_DOUBLE;
+ }
+
+ String valueAsString = asTrimmedString();
+ try {
+ return Double.valueOf(valueAsString);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ String.format(ILLEGAL_ARGUMENT_STRING_FORMAT, valueAsString, "double"), e);
+ }
+ }
+
+ @Override
+ public String asString() {
+ if (source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) {
+ return FirebaseRemoteConfig.DEFAULT_VALUE_FOR_STRING;
+ }
+
+ throwIfNullValue();
+ return value;
+ }
+
+ @Override
+ public byte[] asByteArray() {
+ if (source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) {
+ return FirebaseRemoteConfig.DEFAULT_VALUE_FOR_BYTE_ARRAY;
+ }
+ return value.getBytes(FRC_BYTE_ARRAY_ENCODING);
+ }
+
+ @Override
+ public boolean asBoolean() throws IllegalArgumentException {
+ if (source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) {
+ return FirebaseRemoteConfig.DEFAULT_VALUE_FOR_BOOLEAN;
+ }
+
+ String valueAsString = asTrimmedString();
+ if (TRUE_REGEX.matcher(valueAsString).matches()) {
+ return true;
+ } else if (FALSE_REGEX.matcher(valueAsString).matches()) {
+ return false;
+ }
+ throw new IllegalArgumentException(
+ String.format(ILLEGAL_ARGUMENT_STRING_FORMAT, valueAsString, "boolean"));
+ }
+
+ @Override
+ public int getSource() {
+ return source;
+ }
+
+ private void throwIfNullValue() {
+ if (value == null) {
+ throw new IllegalArgumentException(
+ "Value is null, and cannot be converted to the desired type.");
+ }
+ }
+
+ /** Returns a trimmed version of {@link #asString}. */
+ private String asTrimmedString() {
+ return asString().trim();
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/LegacyConfigsHandler.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/LegacyConfigsHandler.java
new file mode 100644
index 00000000000..74515ccf71a
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/LegacyConfigsHandler.java
@@ -0,0 +1,399 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// TODO(issues/261): Remove with the next major change release of FRC.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.TAG;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+import com.google.firebase.remoteconfig.RemoteConfigComponent;
+import com.google.firebase.remoteconfig.proto.ConfigPersistence.ConfigHolder;
+import com.google.firebase.remoteconfig.proto.ConfigPersistence.KeyValue;
+import com.google.firebase.remoteconfig.proto.ConfigPersistence.NamespaceKeyValue;
+import com.google.firebase.remoteconfig.proto.ConfigPersistence.PersistedConfig;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.InvalidProtocolBufferException;
+import developers.mobile.abt.FirebaseAbt.ExperimentPayload;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Handler for reading and converting configs stored as protos by the older versions of the FRC SDK,
+ * as well as writing those protos to the appropriate {@link ConfigCacheClient}s.
+ *
+ * The legacy SDK stores configs in the App's disk space as a {@link PersistedConfig}. The new
+ * SDK stores values as JSON, so clients will lose their state after updating to the new SDK. To
+ * avoid such a breaking change, this class reads the proto in the old SDK's file and writes it to
+ * the new SDK's files.
+ *
+ *
To prevent the legacy configs from overwriting new fetched values, the legacy configs will
+ * only be written to the new SDK once. After the first write in the new SDK, legacy configs will be
+ * permanently ignored.
+ *
+ * @author Miraziz Yusupov
+ */
+public class LegacyConfigsHandler {
+ @VisibleForTesting public static final String EXPERIMENT_ID_KEY = "experimentId";
+ @VisibleForTesting public static final String EXPERIMENT_VARIANT_ID_KEY = "variantId";
+ @VisibleForTesting public static final String EXPERIMENT_START_TIME_KEY = "experimentStartTime";
+ @VisibleForTesting public static final String EXPERIMENT_TRIGGER_EVENT_KEY = "triggerEvent";
+
+ @VisibleForTesting
+ public static final String EXPERIMENT_TRIGGER_TIMEOUT_KEY = "triggerTimeoutMillis";
+
+ @VisibleForTesting public static final String EXPERIMENT_TIME_TO_LIVE_KEY = "timeToLiveMillis";
+
+ /** Name of the file with the legacy configs proto. */
+ @VisibleForTesting static final String LEGACY_CONFIGS_FILE_NAME = "persisted_config";
+ /**
+ * Name of the file with the flag to determine if legacy configs should be read and saved to the
+ * new SDK.
+ */
+ private static final String LEGACY_SETTINGS_FILE_NAME =
+ "com.google.firebase.remoteconfig_legacy_settings";
+
+ private static final String SAVE_LEGACY_CONFIGS_FLAG_NAME = "save_legacy_configs";
+
+ /**
+ * The legacy FRC server prepended all namespaces with "configns:", while the current FRC server
+ * does not. The legacy proto will have namespaces with the legacy format, but the converted
+ * configs need to be saved to namespaces without the prefix.
+ */
+ @VisibleForTesting static final String LEGACY_FRC_NAMESPACE_PREFIX = "configns:";
+
+ /** The default namespace for all 3P configs. */
+ private static final String FRC_3P_NAMESPACE = "firebase";
+
+ /** The encoding used to serialize the legacy FRC and ABT protos. */
+ private static final Charset PROTO_BYTE_ARRAY_ENCODING = Charset.forName("UTF-8");
+
+ /** Name of the file where activate configs are stored. */
+ @VisibleForTesting
+ static final String ACTIVATE_FILE_NAME = RemoteConfigComponent.ACTIVATE_FILE_NAME;
+
+ /** Name of the file where fetched configs are stored. */
+ @VisibleForTesting static final String FETCH_FILE_NAME = RemoteConfigComponent.FETCH_FILE_NAME;
+
+ /** Name of the file where defaults configs are stored. */
+ @VisibleForTesting
+ static final String DEFAULTS_FILE_NAME = RemoteConfigComponent.DEFAULTS_FILE_NAME;
+
+ /**
+ * The String format of a protobuf Timestamp; the format is ISO 8601 compliant.
+ *
+ *
The protobuf Timestamp field gets converted to an ISO 8601 string when returned as JSON. For
+ * example, the Firebase Remote Config backend sends experiment start time as a Timestamp field,
+ * which gets converted to an ISO 8601 string when sent as JSON.
+ */
+ @VisibleForTesting
+ static final ThreadLocal protoTimestampStringParser =
+ new ThreadLocal() {
+ @Override
+ protected DateFormat initialValue() {
+ return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US);
+ }
+ };
+
+ private final Context context;
+ private final String appId;
+ private final SharedPreferences legacySettings;
+
+ /** The Legacy Configs Handler constructor. */
+ public LegacyConfigsHandler(Context context, String appId) {
+ this.context = context;
+ this.appId = appId;
+
+ this.legacySettings =
+ context.getSharedPreferences(LEGACY_SETTINGS_FILE_NAME, Context.MODE_PRIVATE);
+ }
+
+ /**
+ * The first time this method is ever called, any existing legacy configs are read and saved to
+ * disk. At the end of the first invocation of this method, a persisted flag will be switched to
+ * false and all subsequent calls to this method will be ignored.
+ */
+ @WorkerThread
+ public boolean saveLegacyConfigsIfNecessary() {
+ if (legacySettings.getBoolean(SAVE_LEGACY_CONFIGS_FLAG_NAME, true)) {
+ saveLegacyConfigs(getConvertedLegacyConfigs());
+ legacySettings.edit().putBoolean(SAVE_LEGACY_CONFIGS_FLAG_NAME, false).commit();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Saves all the configs in {@code legacyConfigsByNamespace} to disk.
+ *
+ * @param legacyConfigsByNamespace a map from namespaces to {@link NamespaceLegacyConfigs}, each
+ * of which contains the activated, fetched and defaults legacy configs for a single
+ * namespace.
+ */
+ @WorkerThread
+ private void saveLegacyConfigs(Map legacyConfigsByNamespace) {
+ for (Map.Entry legacyConfigsByNamespaceEntry :
+ legacyConfigsByNamespace.entrySet()) {
+ String namespace = legacyConfigsByNamespaceEntry.getKey();
+ NamespaceLegacyConfigs legacyConfigs = legacyConfigsByNamespaceEntry.getValue();
+
+ ConfigCacheClient fetchedCacheClient = getCacheClient(namespace, FETCH_FILE_NAME);
+ ConfigCacheClient activatedCacheClient = getCacheClient(namespace, ACTIVATE_FILE_NAME);
+ ConfigCacheClient defaultsCacheClient = getCacheClient(namespace, DEFAULTS_FILE_NAME);
+
+ if (legacyConfigs.getFetchedConfigs() != null) {
+ fetchedCacheClient.put(legacyConfigs.getFetchedConfigs());
+ }
+ if (legacyConfigs.getActivatedConfigs() != null) {
+ activatedCacheClient.put(legacyConfigs.getActivatedConfigs());
+ }
+ if (legacyConfigs.getDefaultsConfigs() != null) {
+ defaultsCacheClient.put(legacyConfigs.getDefaultsConfigs());
+ }
+ }
+ }
+
+ /**
+ * Reads all legacy configs from disk and converts them into {@link ConfigContainer}s.
+ *
+ * @return A {@link Map} from namespaces to {@link NamespaceLegacyConfigs}, each of which contains
+ * the activated, fetched and defaults legacy configs for a single namespace.
+ */
+ @WorkerThread
+ private Map getConvertedLegacyConfigs() {
+ PersistedConfig allLegacyConfigs = readPersistedConfig();
+
+ Map allConfigsMap = new HashMap<>();
+ if (allLegacyConfigs == null) {
+ return allConfigsMap;
+ }
+
+ Map activatedConfigsByNamespace =
+ convertConfigHolder(allLegacyConfigs.getActiveConfigHolder());
+ Map fetchedConfigsByNamespace =
+ convertConfigHolder(allLegacyConfigs.getFetchedConfigHolder());
+ Map defaultsConfigsByNamespace =
+ convertConfigHolder(allLegacyConfigs.getDefaultsConfigHolder());
+
+ Set allNamespaces = new HashSet<>();
+ allNamespaces.addAll(activatedConfigsByNamespace.keySet());
+ allNamespaces.addAll(fetchedConfigsByNamespace.keySet());
+ allNamespaces.addAll(defaultsConfigsByNamespace.keySet());
+
+ for (String namespace : allNamespaces) {
+ NamespaceLegacyConfigs namespaceLegacyConfigs = new NamespaceLegacyConfigs();
+ if (activatedConfigsByNamespace.containsKey(namespace)) {
+ namespaceLegacyConfigs.setActivatedConfigs(activatedConfigsByNamespace.get(namespace));
+ }
+ if (fetchedConfigsByNamespace.containsKey(namespace)) {
+ namespaceLegacyConfigs.setFetchedConfigs(fetchedConfigsByNamespace.get(namespace));
+ }
+ if (defaultsConfigsByNamespace.containsKey(namespace)) {
+ namespaceLegacyConfigs.setDefaultsConfigs(defaultsConfigsByNamespace.get(namespace));
+ }
+ allConfigsMap.put(namespace, namespaceLegacyConfigs);
+ }
+ return allConfigsMap;
+ }
+
+ /** Converts {@link ConfigHolder} into a map from namespaces to their corresponding configs. */
+ private Map convertConfigHolder(ConfigHolder allNamespaceLegacyConfigs) {
+ Map convertedLegacyConfigs = new HashMap<>();
+
+ Date fetchTime = new Date(allNamespaceLegacyConfigs.getTimestamp());
+ JSONArray abtExperiments =
+ convertLegacyAbtExperiments(allNamespaceLegacyConfigs.getExperimentPayloadList());
+
+ List namespaceLegacyConfigsArray =
+ allNamespaceLegacyConfigs.getNamespaceKeyValueList();
+ for (NamespaceKeyValue namespaceLegacyConfigs : namespaceLegacyConfigsArray) {
+ String namespace = namespaceLegacyConfigs.getNamespace();
+ if (namespace.startsWith(LEGACY_FRC_NAMESPACE_PREFIX)) {
+ namespace = namespace.substring(LEGACY_FRC_NAMESPACE_PREFIX.length());
+ }
+
+ ConfigContainer.Builder configsBuilder =
+ ConfigContainer.newBuilder()
+ .replaceConfigsWith(convertKeyValueList(namespaceLegacyConfigs.getKeyValueList()))
+ .withFetchTime(fetchTime);
+ if (namespace.equals(FRC_3P_NAMESPACE)) {
+ configsBuilder.withAbtExperiments(abtExperiments);
+ }
+
+ try {
+ convertedLegacyConfigs.put(namespace, configsBuilder.build());
+ } catch (JSONException e) {
+ // Skip configs that cannot be parsed.
+ Log.d(TAG, "A set of legacy configs could not be converted.");
+ }
+ }
+ return convertedLegacyConfigs;
+ }
+
+ /**
+ * Deserializes ABT experiment payloads and converts them into a {@link JSONArray} of {@link
+ * JSONObject}s.
+ */
+ private JSONArray convertLegacyAbtExperiments(List legacyExperimentPayloads) {
+ JSONArray abtExperiments = new JSONArray();
+ for (ByteString legacyExperimentPayload : legacyExperimentPayloads) {
+ ExperimentPayload deserializedPayload = deserializePayload(legacyExperimentPayload);
+ if (deserializedPayload != null) {
+ try {
+ abtExperiments.put(convertLegacyAbtExperiment(deserializedPayload));
+ } catch (JSONException e) {
+ // Ignore ABT experiments that cannot be parsed.
+ Log.d(TAG, "A legacy ABT experiment could not be parsed.", e);
+ }
+ }
+ }
+ return abtExperiments;
+ }
+
+ @Nullable
+ private ExperimentPayload deserializePayload(ByteString legacyExperimentPayload) {
+ try {
+ Iterator byteIterator = legacyExperimentPayload.iterator();
+ byte[] payloadArray = new byte[legacyExperimentPayload.size()];
+ for (int index = 0; index < payloadArray.length; index++) {
+ payloadArray[index] = byteIterator.next();
+ }
+ return ExperimentPayload.parseFrom(payloadArray);
+ } catch (InvalidProtocolBufferException e) {
+ Log.d(TAG, "Payload was not defined or could not be deserialized.", e);
+ return null;
+ }
+ }
+
+ /** Converts {@link ExperimentPayload} into a {@link JSONObject}. */
+ private JSONObject convertLegacyAbtExperiment(ExperimentPayload deserializedLegacyPayload)
+ throws JSONException {
+ JSONObject abtExperiment = new JSONObject();
+
+ abtExperiment.put(EXPERIMENT_ID_KEY, deserializedLegacyPayload.getExperimentId());
+ abtExperiment.put(EXPERIMENT_VARIANT_ID_KEY, deserializedLegacyPayload.getVariantId());
+ abtExperiment.put(
+ EXPERIMENT_START_TIME_KEY,
+ protoTimestampStringParser
+ .get()
+ .format(new Date(deserializedLegacyPayload.getExperimentStartTimeMillis())));
+ abtExperiment.put(EXPERIMENT_TRIGGER_EVENT_KEY, deserializedLegacyPayload.getTriggerEvent());
+ abtExperiment.put(
+ EXPERIMENT_TRIGGER_TIMEOUT_KEY, deserializedLegacyPayload.getTriggerTimeoutMillis());
+ abtExperiment.put(EXPERIMENT_TIME_TO_LIVE_KEY, deserializedLegacyPayload.getTimeToLiveMillis());
+
+ return abtExperiment;
+ }
+
+ /** Converts a {@link List} of {@link KeyValue}s into a {@link Map} of {@link String} configs. */
+ private Map convertKeyValueList(List legacyConfigs) {
+ Map legacyConfigsMap = new HashMap<>();
+ for (KeyValue legacyConfig : legacyConfigs) {
+ legacyConfigsMap.put(
+ legacyConfig.getKey(), legacyConfig.getValue().toString(PROTO_BYTE_ARRAY_ENCODING));
+ }
+ return legacyConfigsMap;
+ }
+
+ /** Reads the legacy configs, converts them into a proto, and returns the result. */
+ @WorkerThread
+ private PersistedConfig readPersistedConfig() {
+ if (context == null) {
+ return null;
+ }
+ PersistedConfig persistedConfig;
+ FileInputStream fileInputStream = null;
+ try {
+ fileInputStream = context.openFileInput(LEGACY_CONFIGS_FILE_NAME);
+ persistedConfig = PersistedConfig.parseFrom(fileInputStream);
+ } catch (FileNotFoundException fileNotFoundException) {
+ Log.d(TAG, "Persisted config file was not found.", fileNotFoundException);
+ return null;
+ } catch (IOException ioException) {
+ Log.d(TAG, "Cannot initialize from persisted config.", ioException);
+ return null;
+ } finally {
+ try {
+ if (fileInputStream != null) {
+ fileInputStream.close();
+ }
+ } catch (IOException ioException) {
+ Log.d(TAG, "Failed to close persisted config file.", ioException);
+ }
+ }
+ return persistedConfig;
+ }
+
+ /**
+ * Gets the cache client for the given {@code namespace} and config storage type (one of {@link
+ * #ACTIVATE_FILE_NAME}, {@link #FETCH_FILE_NAME} or {@link #DEFAULTS_FILE_NAME}).
+ */
+ ConfigCacheClient getCacheClient(String namespace, String configStoreType) {
+ return RemoteConfigComponent.getCacheClient(context, appId, namespace, configStoreType);
+ }
+
+ /** Container for all the configs in a single namespace. */
+ private static class NamespaceLegacyConfigs {
+ private ConfigContainer fetchedConfigs;
+ private ConfigContainer activatedConfigs;
+ private ConfigContainer defaultsConfigs;
+
+ private NamespaceLegacyConfigs() {}
+
+ private void setFetchedConfigs(ConfigContainer fetchedConfigs) {
+ this.fetchedConfigs = fetchedConfigs;
+ }
+
+ private void setActivatedConfigs(ConfigContainer activatedConfigs) {
+ this.activatedConfigs = activatedConfigs;
+ }
+
+ private void setDefaultsConfigs(ConfigContainer defaultsConfigs) {
+ this.defaultsConfigs = defaultsConfigs;
+ }
+
+ private ConfigContainer getFetchedConfigs() {
+ return fetchedConfigs;
+ }
+
+ private ConfigContainer getActivatedConfigs() {
+ return activatedConfigs;
+ }
+
+ private ConfigContainer getDefaultsConfigs() {
+ return defaultsConfigs;
+ }
+ }
+}
diff --git a/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/package-info.java b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/package-info.java
new file mode 100644
index 00000000000..e9c77b42518
--- /dev/null
+++ b/firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/package-info.java
@@ -0,0 +1,19 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/** @hide */
+
+package com.google.firebase.remoteconfig.internal;
+
+
diff --git a/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config.proto b/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config.proto
new file mode 100644
index 00000000000..4dc91056fcb
--- /dev/null
+++ b/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config.proto
@@ -0,0 +1,402 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+syntax = "proto2";
+
+package com.google.android.gms.config.proto;
+
+import "com/google/android/gms/config/proto/config_logs.proto";
+
+option java_package = "com.google.android.gms.config.proto";
+option java_outer_classname = "Config";
+
+// This proto defines the request and response objects of a config fetch from
+// the Config Service backend.
+
+// The model of a config fetch has changed in GmsCore-Tala. We consider the
+// original config fetch model as v1, and the new model in Tala as v2. Fields
+// that were added in v2 and fields that were deprecated in v2 are marked
+// correspondingly.
+
+// The ConfigFetchRequest proto has a client_version field, which will indicate
+// if it is v2. Lack of that field indicates that the request is coming from
+// a v1 client.
+
+message PackageData {
+ ////////////////////
+ // v1 only fields //
+ ////////////////////
+
+ // version code of the app/package. (as it appears in the app manifest with
+ // tag) deprecated - v1 only - should be populated
+ // for v1 - android only.
+ optional int32 version_code = 2;
+
+ // deprecated - v1 only - should be populated for v1
+ // digest of the config table on the device. will be used to diff against
+ // the digest at the server, to see if server needs to send an updated table.
+ optional bytes digest = 3;
+
+ // hash of the certificate with which this package was signed.
+ // android only - v1 only - ignored by server
+ optional bytes cert_hash = 4;
+
+ // deprecated - v1 only - server ignores this
+ optional string config_id = 5;
+
+ /////////////////////////
+ // v1&v2 common fields //
+ /////////////////////////
+
+ // name of the package for which the device is fetching config from the
+ // backend.
+ // for v1: this field must be populated, otherwise the server
+ // will reject the request.
+ // for v2:
+ // this field must be populated for first party clients.
+ // this field should be populated for third party clients. without that,
+ // client will not be able to get rules based on the package name.
+ // for clients other than android, this should be populated with
+ // corresponding entity, such as bundle name in ios.
+ optional string package_name = 1;
+
+ ////////////////////
+ // v2 only fields //
+ ////////////////////
+
+ // project id retrieved from GMP, if the app is tied to a GMP project id.
+ // v2 only.
+ // should be populated in v2, as without it, client won't be able to get
+ // configuration from the gmp namespace.
+ optional string gmp_project_id = 6;
+
+ // project id retrieved using the games-project-id tag (in android,
+ // from the manifest file), if the app is tied to a games app id.
+ // v2 only.
+ // should be populated in v2 (if the app has it), as without it, the client
+ // won't be able to get configuration from the games namespace.
+ optional string games_project_id = 7;
+
+ // per namespace digests of the local config table of the app, in
+ // the format of: NamedValue(name=namespace, value=digest)
+ // v2 only.
+ // must be populated for v2, as lack of it would cause the server to send
+ // the whole config table back, even when there are no changes.
+ repeated NamedValue namespace_digest = 8;
+
+ // custom variables as defined by the client app.
+ // v2 only.
+ // if not populated, client won't be able to get rules defined using their
+ // custom variables.
+ repeated NamedValue custom_variable = 9;
+
+ // hash of the certificate with which this app/package was signed.
+ // not in use right now, but should still be populated, as in the future, we
+ // will start to use this to authenticate the app.
+ // android only, as we don't have a way to validate ios signatures.
+ optional bytes app_cert_hash = 10;
+
+ // version code of the app
+ // for android, it is the value of the tag within
+ // an app's manifest.
+ // must be populated in v2, otherwise the config rules against app versions
+ // will not work.
+ //
+ // This field is now deprecated. New version of the client should not populate
+ // this and populate app_version instead. For backward compatability with
+ // older
+ // clients, if the server doesn't find a value for app_version and finds a
+ // value > 0 here, it will use the decimal of the value here as the
+ // app version (e.g. 123 -> "123").
+ optional int32 app_version_code = 11;
+
+ // App version, using the native app version format on the client's OS.
+ // The recommended version format is in decimal dot notation
+ // \d{1,6}(\.\d{1,6})*
+ //
+ // Valid examples:
+ // 0, 123, 0123, 1.2.3, 0001.33.043.321.55
+ optional string app_version = 13;
+
+ // The instance id of the app (the client app, not gmscore).
+ optional string app_instance_id = 12;
+
+ // The instance id token of the app, that is retrieved by using
+ // the default scope.
+ optional string app_instance_id_token = 14;
+
+ // Requested hidden namespaces.
+ // This is a list of namespaces that are hidden from the developer.
+ // Configuration in these namespaces are for configuring SDKs.
+ // Regular API methods won't return the values in these namespaces.
+ // When the server receives a fetch request with this field set, it should
+ // only reply for the namespaces in this field.
+ // Example value: "confighns:firebase-system"
+ repeated string requested_hidden_namespace = 15;
+
+ // version of the firebase remote config sdk. constructed by a major version,
+ // a minor version, and a patch version, using the formula:
+ // (major * 10000) + (minor * 100) + patch
+ // must be set by each client.
+ // the major version will indicate the API version, and has to be the same for
+ // both Android and iOS.
+ // the minor version will indicate the feature set. in other words, it needs
+ // to be incremented with each feature we add. must be the same for both
+ // Android and iOS.
+ // the patch version needs to be updated each time we release a change. can
+ // be different between Android and iOS.
+ // these versions will be hardcoded constants in both clients.
+ optional int32 sdk_version = 16;
+
+ // list of all scion (analytics) external properties set on this app. clients
+ // need to report these at each config fetch, so that the server can use user
+ // properties as a targeting condition, enabling more integration with scion.
+ repeated NamedValue analytics_user_property = 17;
+
+ // the cache expiration seconds specified while calling fetch()
+ // in seconds
+ optional int32 requested_cache_expiration_seconds = 18;
+
+ // the age of the fetched config: now() - last time fetch() was called
+ // in seconds
+ // if there was no fetched config, the value will be set to -1
+ optional int32 fetched_config_age_seconds = 19;
+
+ // the age of the active config:
+ // now() - last time activateFetched() was called
+ // in seconds
+ // if there was no active config, the value will be set to -1
+ optional int32 active_config_age_seconds = 20;
+}
+
+// Corresponds to each entry in a config table. common for v1 & v2.
+message KeyValue {
+ optional string key = 1;
+ optional bytes value = 2;
+}
+
+/////////////////
+// added in v2 //
+/////////////////
+message NamedValue {
+ optional string name = 1;
+ optional string value = 2;
+}
+
+// represents a config fetch request from a single device for the config(s) of
+// one or more apps.
+// in v2, the fetch request is only for a single app.
+// in v1, the fetch request can be batched for multiple apps.
+message ConfigFetchRequest {
+ ////////////////////
+ // v1-only fields //
+ ////////////////////
+
+ // from logs proto, tracking fetch reason.
+ // v1 only - android only
+ optional AndroidConfigFetchProto config = 5;
+
+ /////////////////////////
+ // v1&v2 common fields //
+ /////////////////////////
+
+ // android_id of the device.
+ // must be populated in v1: for v1 requests, server fetches all data from
+ // checkin, so the fetch operation won't work at all without an android id.
+ // for v2, this field is only necessary if any of the rules for this package
+ // needs checkin data. (for instance, first party clients using the extended
+ // atoms within the perforce namespace).
+ // if populated, security_token field should also be populated, as the server
+ // will authenticate the android_id if it needs to access Kansas data.
+ optional fixed64 android_id = 1;
+
+ // holds package specific information. for v1, it can be a repeated set of
+ // packages.
+ // in v2, there will only be a single entry, as we only do individual fetches
+ // per package.
+ // must be populated in both v1 and v2.
+ repeated PackageData package_data = 2;
+
+ // corresponds to the Kansas version info for the android checkin columns,
+ // which can be used by
+ // the server to retrieve the most up-to-date data from Kansas.
+ // not needed for third party clients.
+ // for first party clients (which might have rules depending on checkin data),
+ // this should be populated for both v1 and v2, if possible. without it, the
+ // server is subject to reading stale checkin data from Kansas.
+ optional string device_data_version_info = 3;
+
+ // password of the android id.
+ // for v1, this field must be populated.
+ // for v2, if the android_id field is populated, this needs to be populated
+ // as well, as the server will authenticate the android id if it needs to
+ // access Kansas data.
+ optional fixed64 security_token = 4;
+
+ ////////////////////
+ // v2-only fields //
+ ////////////////////
+
+ // version of the config fetch client on the device. must be populated in v2.
+ optional int32 client_version = 6; // 1 is GmsCore-Orla, 2 is GmsCore-Tala
+
+ // version of the Google Play Services on the device.
+ // must be populated in v2, android only.
+ optional int32 gms_core_version = 7;
+
+ // API level of the device (for android, 19, 20, 21, etc.).
+ // must be populated in v2.
+ // android only for now. ios (or others) can either reuse this field, or add
+ // another.
+ optional int32 api_level = 8;
+
+ // the country which this device resides in, according to checkin.
+ // all country codes should be 2 lowercase letters, such as us, tr, or gb.
+ // must be populated in v2.
+ optional string device_country = 9;
+
+ // the default locale of the device.
+ // format is 'en_US'.
+ // must be populated in v2, otherwise rules written against locale or locale
+ // language will not work.
+ optional string device_locale = 10;
+
+ // indicates the type of the device, such as android, ios, chromeos, etc.
+ // must be populated in v2
+ optional int32 device_type = 11;
+
+ // indicates the device subtype, such as phone, tablet, wearable, etc.
+ // must be populated in v2
+ optional int32 device_subtype = 12;
+
+ // The version string of the device.
+ // Android devices will have a version corresponding to the bandwagoner version
+ // code. For example "21" for Lollipop.
+ // iOS devices will have a string version containing three non-negative,
+ // period seperated integers. eg. "9.0.0". See CFBundleVersion for more info.
+ optional string os_version = 13;
+
+ // The timezone id of the device in Olson Id format.
+ // Example "America/Los_Angeles"
+ // Android => getID: Returns the ID of this TimeZone, such as
+ // America/Los_Angeles, GMT-08:00 or UTC.
+ optional string device_timezone_id = 14;
+}
+
+// Holds the configuration data withing the v1 server response.
+message PackageTable {
+ // android only - the name of the package to which the configuration belongs
+ // to. must be filled in, so that the client can identify the owner package.
+ optional string package_name = 1;
+
+ // holds configuration key-value pairs, belonging to the app with the
+ // specified package name.
+ repeated KeyValue entry = 2;
+
+ // ignored by the server - unused
+ optional string config_id = 3;
+}
+
+//////////////////
+// Added in V2. //
+//////////////////
+// Holds configuration data belonging to a single namespace of an app.
+message AppNamespaceConfigTable {
+ // namespace is the source of the configuration included in this message.
+ // can be "configns:gmp", "configns:games", or "configns:p4".
+ // must be populated.
+ optional string namespace = 1;
+
+ // server computed digest of the config entries.
+ // will be stored by the client to be included in future requests, so that
+ // the server can tell if there are any changes and the device needs an
+ // update.
+ optional string digest = 2;
+
+ // holds configuration key value pairs. each entry is a pair within a
+ // package-namespace realm.
+ repeated KeyValue entry = 3;
+
+ enum NamespaceStatus {
+ UPDATE = 0;
+ NO_TEMPLATE = 1;
+ NO_CHANGE = 2;
+ EMPTY_CONFIG = 3;
+ NOT_AUTHORIZED = 4;
+ }
+
+ // holds the status for the namespace, which tells the client how the
+ // response should be handled.
+ optional NamespaceStatus status = 4;
+}
+
+//////////////////
+// Added in V2. //
+//////////////////
+// Holds configuration data within the v2 server response.
+message AppConfigTable {
+ // in android, app name is the package name of the app.
+ // for ios, it is the bundle name.
+ // must be populated, so that the client can identify which package the
+ // included config belongs to.
+ optional string app_name = 1;
+
+ // holds per namespace configuration for this app. if the app has
+ // configuration coming from multiple sources, then there will be more than
+ // one entry in this field.
+ // if the app has no configuration defined, then this field will be empty.
+ repeated AppNamespaceConfigTable namespace_config = 2;
+
+ // each experiment payload represents a single experiment and its variant,
+ // along with metadata specific to this experiment. this field holds the
+ // serialized bytes of an experiment payload proto object.
+ // the experiment payload can be found at:
+ // developers/mobile/abt/proto/experiment_payload.proto
+ repeated bytes experiment_payload = 3;
+}
+
+// represents the response of the config backend server to the device's config
+// fetch request.
+message ConfigFetchResponse {
+ /////////////////////////////
+ // v1-only response fields //
+ /////////////////////////////
+
+ // holds the config table of the package, as a separate PackageTable per
+ // config key-value pair in a namespace.
+ repeated PackageTable package_table = 1;
+
+ /////////////////////////////
+ // v2-only response fields //
+ /////////////////////////////
+
+ enum ResponseStatus {
+ SUCCESS = 0;
+ NO_PACKAGES_IN_REQUEST = 1;
+ }
+
+ // indicates the server's response status
+ optional ResponseStatus status = 2;
+
+ // holds information that is sent by the server, but won't be visible to
+ // the client. will be used to configure connection timeouts, or throttling
+ // parameters from the server.
+ // added in v2.
+ repeated KeyValue internal_metadata = 3;
+
+ // holds all configuration data to be sent to the fetching device.
+ // in v2, there should only be one entry. however, the field is marked as
+ // repeated, in case we handle more than one package within the same
+ // request-response in the future.
+ repeated AppConfigTable app_config = 4;
+}
\ No newline at end of file
diff --git a/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_logs.proto b/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_logs.proto
new file mode 100644
index 00000000000..8c704a217b1
--- /dev/null
+++ b/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_logs.proto
@@ -0,0 +1,40 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto2";
+
+package com.google.android.gms.config.proto;
+
+option java_package = "com.google.android.gms.config.proto";
+option java_outer_classname = "Logs";
+
+message ConfigFetchReason {
+ enum AndroidConfigFetchType {
+ UNKNOWN = 0;
+ SCHEDULED = 1;
+ BOOT_COMPLETED = 2;
+ PACKAGE_ADDED = 3;
+ PACKAGE_REMOVED = 4;
+ GMS_CORE_UPDATED = 5;
+ SECRET_CODE = 6;
+ }
+
+ optional AndroidConfigFetchType type = 1;
+}
+
+// Information sent by the device in a ConfigFetch request.
+message AndroidConfigFetchProto {
+ // The reason why this ConfigFetch is triggered.
+ optional ConfigFetchReason reason = 1;
+}
\ No newline at end of file
diff --git a/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_persistence.proto b/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_persistence.proto
new file mode 100644
index 00000000000..d5dfd91c6a5
--- /dev/null
+++ b/firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_persistence.proto
@@ -0,0 +1,55 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+syntax = "proto2";
+
+package com.google.firebase.remoteconfig.proto;
+
+option java_package = "com.google.firebase.remoteconfig.proto";
+option java_outer_classname = "ConfigPersistence";
+
+message PersistedConfig {
+ optional ConfigHolder fetched_config_holder = 1;
+ optional ConfigHolder active_config_holder = 2;
+ optional ConfigHolder defaults_config_holder = 3;
+ optional Metadata metadata = 4;
+ repeated Resource applied_resource = 5;
+}
+
+message KeyValue {
+ optional string key = 1;
+ optional bytes value = 2;
+}
+
+message NamespaceKeyValue {
+ optional string namespace = 1;
+ repeated KeyValue key_value = 2;
+}
+
+message ConfigHolder {
+ repeated NamespaceKeyValue namespace_key_value = 1;
+ optional fixed64 timestamp = 2;
+ repeated bytes experiment_payload = 3;
+}
+
+message Metadata {
+ optional int32 last_fetch_status = 1;
+ optional bool developer_mode_enabled = 2;
+ optional fixed64 last_known_experiment_start_time = 3;
+}
+
+message Resource {
+ optional int32 resource_id = 1;
+ optional fixed64 app_update_time = 2;
+ optional string namespace = 3;
+}
\ No newline at end of file
diff --git a/firebase-remote-config/src/proto/com/google/protos/developers/mobile/abt/proto/experiment_payload.proto b/firebase-remote-config/src/proto/com/google/protos/developers/mobile/abt/proto/experiment_payload.proto
new file mode 100644
index 00000000000..9ff2153b42d
--- /dev/null
+++ b/firebase-remote-config/src/proto/com/google/protos/developers/mobile/abt/proto/experiment_payload.proto
@@ -0,0 +1,109 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+package developers.mobile.abt;
+
+option java_outer_classname = "FirebaseAbt";
+
+// A lighter version of ExperimentPayload that describes an experiment
+// by only its experimentId. To be used when we don't need
+// to know all the details about the experiment, such as when sending a
+// list of all ongoing experiments.
+message ExperimentLite {
+ // A string of max length 22 characters.
+ // Format: _exp__
+ // Required field.
+ string experiment_id = 1;
+}
+
+// ABT Payload for Firebase Namespace.
+message ExperimentPayload {
+ // A string of max length 22 characters.
+ // Format: _exp__
+ // This is referred to as the tracking id and is different from the
+ // experiment id which is used internally by ABT.
+ // Required field.
+ string experiment_id = 1;
+
+ // A string which has numbers from 0-10.
+ // Required field.
+ string variant_id = 2;
+
+ // Epoch time in milliseconds when the experiment was started; > 0.
+ // Required field.
+ int64 experiment_start_time_millis = 3;
+
+ // The Scion event that causes the experiment to transition to ON state.
+ string trigger_event = 4;
+
+ // Duration in milliseconds that the experiment can stay in STANDBY state.
+ // Valid range is from 1ms to 6 months (current max defined by Scion).
+ // If the value is outside this range the setExperiment call on the client
+ // will fail.
+ // Required field
+ int64 trigger_timeout_millis = 5;
+
+ // Duration in milliseconds that the experiment can stay in ON state (an
+ // experiment becomes ON when it has been triggered, or when it has no
+ // trigger event).
+ // Corresponds to the attribution time in the ABT UI.
+ // Valid range is from 1ms to 6 months (current max defined by Scion).
+ // If the value is outside this range the setExperiment call on the client
+ // will fail.
+ // Required field
+ int64 time_to_live_millis = 6;
+
+ // The event logged when impact service sets the experiment.
+ // Max length = 32 chars
+ string set_event_to_log = 7;
+
+ // The event logged when an experiment goes to the ON state.
+ // Max length = 32 chars
+ string activate_event_to_log = 8;
+
+ // The event logged when an experiment is cleared.
+ // Max length = 32 chars
+ string clear_event_to_log = 9;
+
+ // The event logged when an experiment times out after trigger_timeout_millis
+ // milliseconds.
+ // Max length = 32 chars
+ string timeout_event_to_log = 10;
+
+ // The event logged when an experiment times out after time_to_live_millis
+ // milliseconds.
+ // Max length = 32 chars
+ string ttl_expiry_event_to_log = 11;
+
+ // Policy to use when there are more experiments than the Scion client can
+ // support on a single App for a single service.
+ // As of October 2016, we allow 3 experiments per instance per service
+ // (i.e. 3 each for Config and Notifications).
+ enum ExperimentOverflowPolicy {
+ POLICY_UNSPECIFIED = 0;
+ // Discard oldest experiments (by experiment_start_time) first.
+ DISCARD_OLDEST = 1;
+ // Ignore newest experiments first.
+ IGNORE_NEWEST = 2;
+ }
+ // The overflow policy enum for this experiment payload.
+ ExperimentOverflowPolicy overflow_policy = 12;
+
+ // A list of all other ongoing (started, and not yet stopped) experiments
+ // at the time this experiment was started.
+ // Does not include this experiment; only the others.
+ repeated ExperimentLite ongoing_experiments = 13;
+}
+
diff --git a/firebase-remote-config/src/test/AndroidManifest.xml b/firebase-remote-config/src/test/AndroidManifest.xml
new file mode 100644
index 00000000000..c9e66890a4f
--- /dev/null
+++ b/firebase-remote-config/src/test/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-remote-config/src/test/java/com/google/android/gms/common/util/MockClock.java b/firebase-remote-config/src/test/java/com/google/android/gms/common/util/MockClock.java
new file mode 100644
index 00000000000..eb4423d067f
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/android/gms/common/util/MockClock.java
@@ -0,0 +1,73 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.gms.common.util;
+
+import android.os.SystemClock;
+import com.google.android.gms.common.internal.Preconditions;
+
+/**
+ * Simple clock implementation that returns a controllable time value.
+ *
+ * @author tomwilson@google.com (Tom Wilson)
+ */
+public class MockClock implements Clock {
+ private long mCurrentTimeMs;
+ private long mCurrentElapsedRealtime;
+ private long mNanoTime;
+
+ public MockClock(long currentTimeMs) {
+ setCurrentTime(currentTimeMs);
+ }
+
+ @Override
+ public long currentTimeMillis() {
+ return mCurrentTimeMs;
+ }
+
+ public void setCurrentTime(long currentTimeMs) {
+ Preconditions.checkState(currentTimeMs >= 0);
+ mCurrentTimeMs = currentTimeMs;
+ }
+
+ @Override
+ public long elapsedRealtime() {
+ return mCurrentElapsedRealtime;
+ }
+
+ public void setElapsedRealtime(long timeInMillis) {
+ mCurrentElapsedRealtime = timeInMillis;
+ }
+
+ public void advance(long incrementMillis) {
+ setCurrentTime(currentTimeMillis() + incrementMillis);
+ setElapsedRealtime(elapsedRealtime() + incrementMillis);
+ }
+
+ @Override
+ public long nanoTime() {
+ return mNanoTime;
+ }
+
+ public void setNanoTime(long nanoTime) {
+ Preconditions.checkState(nanoTime >= 0);
+ mNanoTime = nanoTime;
+ }
+
+ @SuppressWarnings("StaticOrDefaultInterfaceMethod")
+ @Override
+ public long currentThreadTimeMillis() {
+ return SystemClock.currentThreadTimeMillis();
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/android/gms/shadows/common/internal/ShadowPreconditions.java b/firebase-remote-config/src/test/java/com/google/android/gms/shadows/common/internal/ShadowPreconditions.java
new file mode 100644
index 00000000000..0c8695bc71e
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/android/gms/shadows/common/internal/ShadowPreconditions.java
@@ -0,0 +1,47 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.gms.shadows.common.internal;
+
+import android.os.Handler;
+import com.google.android.gms.common.internal.Preconditions;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow for {@link Preconditions} that disables threading checks. Since Robolectric fakes various
+ * threading constructs, these would otherwise cause tests to fail.
+ */
+@Implements(Preconditions.class)
+public class ShadowPreconditions {
+ @Implementation
+ public static void checkNotMainThread() {
+ // Do nothing
+ }
+
+ @Implementation
+ public static void checkNotMainThread(String errorMessage) {
+ // Do nothing
+ }
+
+ @Implementation
+ public static void checkHandlerThread(Handler handler) {
+ // Do nothing
+ }
+
+ @Implementation
+ public static void checkHandlerThread(Handler handler, String errorMessage) {
+ // Do nothing
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/AbtExperimentHelper.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/AbtExperimentHelper.java
new file mode 100644
index 00000000000..ebad3418793
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/AbtExperimentHelper.java
@@ -0,0 +1,57 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static com.google.firebase.remoteconfig.internal.LegacyConfigsHandler.EXPERIMENT_ID_KEY;
+import static com.google.firebase.remoteconfig.internal.LegacyConfigsHandler.EXPERIMENT_START_TIME_KEY;
+import static com.google.firebase.remoteconfig.internal.LegacyConfigsHandler.EXPERIMENT_TIME_TO_LIVE_KEY;
+import static com.google.firebase.remoteconfig.internal.LegacyConfigsHandler.EXPERIMENT_TRIGGER_EVENT_KEY;
+import static com.google.firebase.remoteconfig.internal.LegacyConfigsHandler.EXPERIMENT_TRIGGER_TIMEOUT_KEY;
+import static com.google.firebase.remoteconfig.internal.LegacyConfigsHandler.EXPERIMENT_VARIANT_ID_KEY;
+
+import java.sql.Date;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Set of utility methods for dealing with Firebase A/B Testing (ABT) experiments in tests.
+ *
+ * @author Miraziz Yusupov
+ */
+public class AbtExperimentHelper {
+ /**
+ * Returns a {@link JSONArray} containing a list of {@link JSONObject}s representing ABT
+ * experiments.
+ */
+ static JSONArray createAbtExperiments(JSONObject... abtExperiments) throws JSONException {
+ return new JSONArray(abtExperiments);
+ }
+
+ /**
+ * Returns a {@link JSONObject} representing an ABT Experiment with the given experiment id and
+ * variant id.
+ */
+ static JSONObject createAbtExperiment(String experimentId) throws JSONException {
+ JSONObject abtExperiment = new JSONObject();
+ abtExperiment.put(EXPERIMENT_ID_KEY, experimentId);
+ abtExperiment.put(EXPERIMENT_VARIANT_ID_KEY, "var1");
+ abtExperiment.put(EXPERIMENT_START_TIME_KEY, new Date(1L));
+ abtExperiment.put(EXPERIMENT_TRIGGER_EVENT_KEY, "trigger event");
+ abtExperiment.put(EXPERIMENT_TRIGGER_TIMEOUT_KEY, 5000L);
+ abtExperiment.put(EXPERIMENT_TIME_TO_LIVE_KEY, 10000L);
+ return abtExperiment;
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettingsTest.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettingsTest.java
new file mode 100644
index 00000000000..f6bb219935f
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettingsTest.java
@@ -0,0 +1,41 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for FRC settings.
+ *
+ * @author Lucas Png
+ */
+@RunWith(JUnit4.class)
+public final class FirebaseRemoteConfigSettingsTest {
+ @Test
+ public void toBuilder_withFieldsSet_buildsObjectWithFieldsSet() {
+ FirebaseRemoteConfigSettings.Builder expectedBuilder =
+ new FirebaseRemoteConfigSettings.Builder().setDeveloperModeEnabled(true);
+ FirebaseRemoteConfigSettings settings = expectedBuilder.build();
+
+ FirebaseRemoteConfigSettings.Builder actualBuilder = settings.toBuilder();
+
+ assertThat(actualBuilder.build().isDeveloperModeEnabled())
+ .isEqualTo(settings.isDeveloperModeEnabled());
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
new file mode 100644
index 00000000000..714b8b4b81f
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
@@ -0,0 +1,1195 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.firebase.remoteconfig.AbtExperimentHelper.createAbtExperiment;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_BOOLEAN;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_BYTE_ARRAY;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_DOUBLE;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_LONG;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.DEFAULT_VALUE_FOR_STRING;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_THROTTLED;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.toExperimentInfoMaps;
+import static com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler.FRC_BYTE_ARRAY_ENCODING;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.res.Resources;
+import com.google.android.gms.shadows.common.internal.ShadowPreconditions;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.TaskCompletionSource;
+import com.google.android.gms.tasks.Tasks;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.abt.AbtException;
+import com.google.firebase.abt.FirebaseABTesting;
+import com.google.firebase.remoteconfig.internal.ConfigCacheClient;
+import com.google.firebase.remoteconfig.internal.ConfigContainer;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler.FetchResponse;
+import com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient;
+import java.io.IOException;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.skyscreamer.jsonassert.JSONAssert;
+
+/**
+ * Unit tests for the Firebase Remote Config API.
+ *
+ * @author Miraziz Yusupov
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ manifest = Config.NONE,
+ shadows = {ShadowPreconditions.class})
+public final class FirebaseRemoteConfigTest {
+ private static final String APP_ID = "1:14368190084:android:09cb977358c6f241";
+ private static final String API_KEY = "api_key";
+
+ private static final String FIREPERF_NAMESPACE = "fireperf";
+
+ private static final String STRING_KEY = "string_key";
+ private static final String BOOLEAN_KEY = "boolean_key";
+ private static final String BYTE_ARRAY_KEY = "byte_array_key";
+ private static final String DOUBLE_KEY = "double_key";
+ private static final String LONG_KEY = "long_key";
+
+ private static final String ETAG = "ETag";
+
+ // We use a HashMap so that Mocking is easier.
+ private static final HashMap DEFAULTS_MAP = new HashMap<>();
+
+ @Mock private ConfigCacheClient mockFetchedCache;
+ @Mock private ConfigCacheClient mockActivatedCache;
+ @Mock private ConfigCacheClient mockDefaultsCache;
+ @Mock private ConfigFetchHandler mockFetchHandler;
+ @Mock private ConfigGetParameterHandler mockGetHandler;
+ @Mock private ConfigMetadataClient metadataClient;
+
+ @Mock private ConfigCacheClient mockFireperfFetchedCache;
+ @Mock private ConfigCacheClient mockFireperfActivatedCache;
+ @Mock private ConfigCacheClient mockFireperfDefaultsCache;
+ @Mock private ConfigFetchHandler mockFireperfFetchHandler;
+ @Mock private ConfigGetParameterHandler mockFireperfGetHandler;
+
+ @Mock private FirebaseRemoteConfigInfo mockFrcInfo;
+
+ @Mock private FirebaseABTesting mockFirebaseAbt;
+
+ private FirebaseRemoteConfig frc;
+ private FirebaseRemoteConfig fireperfFrc;
+ private ConfigContainer firstFetchedContainer;
+ private ConfigContainer secondFetchedContainer;
+
+ private FetchResponse firstFetchedContainerResponse;
+
+ @Before
+ public void setUp() throws Exception {
+ DEFAULTS_MAP.put("first_default_key", "first_default_value");
+ DEFAULTS_MAP.put("second_default_key", "second_default_value");
+ DEFAULTS_MAP.put("third_default_key", "third_default_value");
+
+ MockitoAnnotations.initMocks(this);
+
+ Executor directExecutor = MoreExecutors.directExecutor();
+ Context context = RuntimeEnvironment.application;
+ FirebaseApp firebaseApp = initializeFirebaseApp(context);
+
+ // Catch all to avoid NPEs (the getters should never return null).
+ when(mockFetchedCache.get()).thenReturn(Tasks.forResult(null));
+ when(mockActivatedCache.get()).thenReturn(Tasks.forResult(null));
+ when(mockFireperfFetchedCache.get()).thenReturn(Tasks.forResult(null));
+ when(mockFireperfActivatedCache.get()).thenReturn(Tasks.forResult(null));
+
+ frc =
+ new FirebaseRemoteConfig(
+ context,
+ firebaseApp,
+ mockFirebaseAbt,
+ directExecutor,
+ mockFetchedCache,
+ mockActivatedCache,
+ mockDefaultsCache,
+ mockFetchHandler,
+ mockGetHandler,
+ metadataClient);
+
+ // Set up an FRC instance for the Fireperf namespace that uses mocked clients.
+ fireperfFrc =
+ FirebaseApp.getInstance()
+ .get(RemoteConfigComponent.class)
+ .get(
+ firebaseApp,
+ FIREPERF_NAMESPACE,
+ /*firebaseAbt=*/ null,
+ directExecutor,
+ mockFireperfFetchedCache,
+ mockFireperfActivatedCache,
+ mockFireperfDefaultsCache,
+ mockFireperfFetchHandler,
+ mockFireperfGetHandler,
+ RemoteConfigComponent.getMetadataClient(context, APP_ID, FIREPERF_NAMESPACE));
+
+ firstFetchedContainer =
+ ConfigContainer.newBuilder()
+ .replaceConfigsWith(ImmutableMap.of("long_param", "1L", "string_param", "string_value"))
+ .withFetchTime(new Date(1000L))
+ .build();
+
+ secondFetchedContainer =
+ ConfigContainer.newBuilder()
+ .replaceConfigsWith(
+ ImmutableMap.of("string_param", "string_value", "double_param", "0.1"))
+ .withFetchTime(new Date(5000L))
+ .build();
+
+ firstFetchedContainerResponse =
+ FetchResponse.forBackendUpdatesFetched(firstFetchedContainer, ETAG);
+ }
+
+ @Test
+ public void ensureInitialized_notInitialized_isNotComplete() {
+ loadCacheWithConfig(mockFetchedCache, /*container=*/ null);
+ loadCacheWithConfig(mockDefaultsCache, /*container=*/ null);
+ loadActivatedCacheWithIncompleteTask();
+
+ Task initStatus = frc.ensureInitialized();
+
+ assertWithMessage("FRC is initialized even though activated configs have not loaded!")
+ .that(initStatus.isComplete())
+ .isFalse();
+ }
+
+ @Test
+ public void ensureInitialized_initialized_returnsCorrectFrcInfo() {
+ loadCacheWithConfig(mockFetchedCache, /*container=*/ null);
+ loadCacheWithConfig(mockDefaultsCache, /*container=*/ null);
+ loadCacheWithConfig(mockActivatedCache, /*container=*/ null);
+
+ Task initStatus = frc.ensureInitialized();
+
+ assertWithMessage("FRC is not initialized even though everything is loaded!")
+ .that(initStatus.isComplete())
+ .isTrue();
+ }
+
+ @Test
+ public void fetchAndActivate_hasNetworkError_taskReturnsException() {
+ when(mockFetchHandler.fetch())
+ .thenReturn(Tasks.forException(new IOException("Network call failed.")));
+
+ Task task = frc.fetchAndActivate();
+
+ assertThat(task.isComplete()).isTrue();
+ assertWithMessage("Fetch succeeded even though there's a network error!")
+ .that(task.getException())
+ .isNotNull();
+ }
+
+ @Test
+ public void fetchAndActivate_getFetchedFailed_returnsFalse() {
+ loadFetchHandlerWithResponse();
+ loadCacheWithIoException(mockFetchedCache);
+ loadCacheWithConfig(mockActivatedCache, null);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() succeeded with no fetched values!")
+ .that(getTaskResult(task))
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void fetchAndActivate_noFetchedConfigs_returnsFalse() {
+ loadFetchHandlerWithResponse();
+ loadCacheWithConfig(mockFetchedCache, null);
+ loadCacheWithConfig(mockActivatedCache, null);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() succeeded with no fetched values!")
+ .that(getTaskResult(task))
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void fetchAndActivate_staleFetchedConfigs_returnsFalse() {
+ loadFetchHandlerWithResponse();
+ loadCacheWithConfig(mockFetchedCache, firstFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() succeeded with stale values!")
+ .that(getTaskResult(task))
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void fetchAndActivate_noActivatedConfigs_activatesAndClearsFetched() {
+ loadFetchHandlerWithResponse();
+ loadCacheWithConfig(mockFetchedCache, firstFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, null);
+
+ cachePutReturnsConfig(mockActivatedCache, firstFetchedContainer);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() failed with no activated values!")
+ .that(getTaskResult(task))
+ .isTrue();
+
+ verify(mockActivatedCache).put(firstFetchedContainer);
+ verify(mockFetchedCache).clear();
+ }
+
+ @Test
+ public void fetchAndActivate_getActivatedFailed_activatesAndClearsFetched() {
+ loadFetchHandlerWithResponse();
+ loadCacheWithConfig(mockFetchedCache, firstFetchedContainer);
+ loadCacheWithIoException(mockActivatedCache);
+
+ cachePutReturnsConfig(mockActivatedCache, firstFetchedContainer);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() failed with no activated values!")
+ .that(getTaskResult(task))
+ .isTrue();
+
+ verify(mockActivatedCache).put(firstFetchedContainer);
+ verify(mockFetchedCache).clear();
+ }
+
+ @Test
+ public void fetchAndActivate_freshFetchedConfigs_activatesAndClearsFetched() {
+ loadFetchHandlerWithResponse();
+ loadCacheWithConfig(mockFetchedCache, secondFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+
+ cachePutReturnsConfig(mockActivatedCache, secondFetchedContainer);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() failed!").that(getTaskResult(task)).isTrue();
+
+ verify(mockActivatedCache).put(secondFetchedContainer);
+ verify(mockFetchedCache).clear();
+ }
+
+ @Test
+ public void fetchAndActivate_fileWriteFails_doesNotClearFetchedAndReturnsFalse() {
+ loadFetchHandlerWithResponse();
+ loadCacheWithConfig(mockFetchedCache, secondFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+
+ when(mockActivatedCache.put(secondFetchedContainer))
+ .thenReturn(Tasks.forException(new IOException("Should have handled disk error.")));
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() succeeded even though file write failed!")
+ .that(getTaskResult(task))
+ .isFalse();
+
+ verify(mockActivatedCache).put(secondFetchedContainer);
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void fetchAndActivate_hasNoAbtExperiments_sendsEmptyListToAbt() throws Exception {
+ loadFetchHandlerWithResponse();
+ ConfigContainer containerWithNoAbtExperiments =
+ ConfigContainer.newBuilder().withFetchTime(new Date(1000L)).build();
+
+ loadCacheWithConfig(mockFetchedCache, containerWithNoAbtExperiments);
+ cachePutReturnsConfig(mockActivatedCache, containerWithNoAbtExperiments);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() failed!").that(getTaskResult(task)).isTrue();
+
+ verify(mockFirebaseAbt).replaceAllExperiments(ImmutableList.of());
+ }
+
+ @Test
+ public void fetchAndActivate_callToAbtFails_activateStillSucceeds() throws Exception {
+ loadFetchHandlerWithResponse();
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+
+ loadCacheWithConfig(mockFetchedCache, containerWithAbtExperiments);
+ cachePutReturnsConfig(mockActivatedCache, containerWithAbtExperiments);
+
+ doThrow(new AbtException("Abt failure!")).when(mockFirebaseAbt).replaceAllExperiments(any());
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() failed!").that(getTaskResult(task)).isTrue();
+ }
+
+ @Test
+ public void fetchAndActivate_hasAbtExperiments_sendsExperimentsToAbt() throws Exception {
+ loadFetchHandlerWithResponse();
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+
+ loadCacheWithConfig(mockFetchedCache, containerWithAbtExperiments);
+ cachePutReturnsConfig(mockActivatedCache, containerWithAbtExperiments);
+
+ Task task = frc.fetchAndActivate();
+
+ assertWithMessage("fetchAndActivate() failed!").that(getTaskResult(task)).isTrue();
+
+ List> expectedExperimentInfoMaps =
+ toExperimentInfoMaps(containerWithAbtExperiments.getAbtExperiments());
+ verify(mockFirebaseAbt).replaceAllExperiments(expectedExperimentInfoMaps);
+ }
+
+ @Test
+ public void fetchAndActivate2p_hasNoAbtExperiments_doesNotCallAbt() throws Exception {
+ load2pFetchHandlerWithResponse();
+ ConfigContainer containerWithNoAbtExperiments =
+ ConfigContainer.newBuilder().withFetchTime(new Date(1000L)).build();
+
+ loadCacheWithConfig(mockFireperfFetchedCache, containerWithNoAbtExperiments);
+ cachePutReturnsConfig(mockFireperfActivatedCache, containerWithNoAbtExperiments);
+
+ Task task = fireperfFrc.fetchAndActivate();
+
+ assertWithMessage("2p fetchAndActivate() failed!").that(getTaskResult(task)).isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @Test
+ public void fetchAndActivate2p_hasAbtExperiments_doesNotCallAbt() throws Exception {
+ load2pFetchHandlerWithResponse();
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+
+ loadCacheWithConfig(mockFireperfFetchedCache, containerWithAbtExperiments);
+ cachePutReturnsConfig(mockFireperfActivatedCache, containerWithAbtExperiments);
+
+ Task task = fireperfFrc.fetchAndActivate();
+
+ assertWithMessage("2p fetchAndActivate() failed!").that(getTaskResult(task)).isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_noFetchedConfigs_returnsFalse() {
+ loadCacheWithConfig(mockFetchedCache, /*container=*/ null);
+ loadCacheWithConfig(mockActivatedCache, /*container=*/ null);
+
+ assertWithMessage("activateFetched() succeeded with no fetched values!")
+ .that(frc.activateFetched())
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_staleFetchedConfigs_returnsFalse() {
+ loadCacheWithConfig(mockFetchedCache, firstFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+
+ assertWithMessage("activateFetched() succeeded with stale fetched values!")
+ .that(frc.activateFetched())
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_freshFetchedConfigs_activatesAndClearsFetched() {
+ loadCacheWithConfig(mockFetchedCache, secondFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+ // When the fetched values are activated, they should be put into the activated cache.
+ when(mockActivatedCache.putWithoutWaitingForDiskWrite(secondFetchedContainer))
+ .thenReturn(Tasks.forResult(secondFetchedContainer));
+
+ assertWithMessage("activateFetched() failed!").that(frc.activateFetched()).isTrue();
+
+ verify(mockActivatedCache).putWithoutWaitingForDiskWrite(secondFetchedContainer);
+ verify(mockFetchedCache).clear();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_fileWriteFails_doesNotClearFetchedAndReturnsTrue() {
+ loadCacheWithConfig(mockFetchedCache, secondFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+ when(mockActivatedCache.putWithoutWaitingForDiskWrite(secondFetchedContainer))
+ .thenReturn(Tasks.forException(new IOException("Should have handled disk error.")));
+
+ assertWithMessage("activateFetched() failed!").that(frc.activateFetched()).isTrue();
+
+ verify(mockActivatedCache).putWithoutWaitingForDiskWrite(secondFetchedContainer);
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_hasNoAbtExperiments_sendsEmptyListToAbt() throws Exception {
+ ConfigContainer containerWithNoAbtExperiments =
+ ConfigContainer.newBuilder().withFetchTime(new Date(1000L)).build();
+ loadCacheWithConfig(mockFetchedCache, containerWithNoAbtExperiments);
+
+ // When the fetched values are activated, they should be put into the activated cache.
+ when(mockActivatedCache.putWithoutWaitingForDiskWrite(containerWithNoAbtExperiments))
+ .thenReturn(Tasks.forResult(containerWithNoAbtExperiments));
+
+ assertWithMessage("activateFetched() failed!").that(frc.activateFetched()).isTrue();
+
+ verify(mockFirebaseAbt).replaceAllExperiments(ImmutableList.of());
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_callToAbtFails_activateStillSucceeds() throws Exception {
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+ loadCacheWithConfig(mockFetchedCache, containerWithAbtExperiments);
+ loadCacheWithConfig(mockActivatedCache, /*container=*/ null);
+
+ // When the fetched values are activated, they should be put into the activated cache.
+ when(mockActivatedCache.putWithoutWaitingForDiskWrite(containerWithAbtExperiments))
+ .thenReturn(Tasks.forResult(containerWithAbtExperiments));
+
+ doThrow(new AbtException("Abt failure!")).when(mockFirebaseAbt).replaceAllExperiments(any());
+
+ assertWithMessage("activateFetched() failed!").that(frc.activateFetched()).isTrue();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_hasAbtExperiments_sendsExperimentsToAbt() throws Exception {
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+ loadCacheWithConfig(mockFetchedCache, containerWithAbtExperiments);
+
+ // When the fetched values are activated, they should be put into the activated cache.
+ when(mockActivatedCache.putWithoutWaitingForDiskWrite(containerWithAbtExperiments))
+ .thenReturn(Tasks.forResult(containerWithAbtExperiments));
+
+ assertWithMessage("activateFetched() failed!").that(frc.activateFetched()).isTrue();
+
+ List> expectedExperimentInfoMaps =
+ toExperimentInfoMaps(containerWithAbtExperiments.getAbtExperiments());
+ verify(mockFirebaseAbt).replaceAllExperiments(expectedExperimentInfoMaps);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_fireperfNamespace_noFetchedConfigs_returnsFalse() {
+ loadCacheWithConfig(mockFireperfFetchedCache, /*container=*/ null);
+ loadCacheWithConfig(mockFireperfActivatedCache, /*container=*/ null);
+
+ assertWithMessage("activateFetched(fireperf) succeeded with no fetched values!")
+ .that(fireperfFrc.activateFetched())
+ .isFalse();
+
+ verify(mockFireperfActivatedCache, never()).put(any());
+ verify(mockFireperfFetchedCache, never()).clear();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched_fireperfNamespace_freshFetchedConfigs_activatesAndClearsFetched() {
+ loadCacheWithConfig(mockFireperfFetchedCache, secondFetchedContainer);
+ loadCacheWithConfig(mockFireperfActivatedCache, firstFetchedContainer);
+ // When the fetched values are activated, they should be put into the activated cache.
+ when(mockFireperfActivatedCache.putWithoutWaitingForDiskWrite(secondFetchedContainer))
+ .thenReturn(Tasks.forResult(secondFetchedContainer));
+
+ assertWithMessage("activateFetched(fireperf) failed!")
+ .that(fireperfFrc.activateFetched())
+ .isTrue();
+
+ verify(mockFireperfActivatedCache).putWithoutWaitingForDiskWrite(secondFetchedContainer);
+ verify(mockFireperfFetchedCache).clear();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched2p_hasNoAbtExperiments_doesNotCallAbt() throws Exception {
+ ConfigContainer containerWithNoAbtExperiments =
+ ConfigContainer.newBuilder().withFetchTime(new Date(1000L)).build();
+ loadCacheWithConfig(mockFireperfFetchedCache, containerWithNoAbtExperiments);
+
+ // When the fetched values are activated, they should be put into the activated cache.
+ when(mockFireperfActivatedCache.putWithoutWaitingForDiskWrite(containerWithNoAbtExperiments))
+ .thenReturn(Tasks.forResult(containerWithNoAbtExperiments));
+
+ assertWithMessage("activateFetched(fireperf) failed!")
+ .that(fireperfFrc.activateFetched())
+ .isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void activateFetched2p_hasAbtExperiments_doesNotCallAbt() throws Exception {
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+ loadCacheWithConfig(mockFireperfFetchedCache, containerWithAbtExperiments);
+
+ // When the fetched values are activated, they should be put into the activated cache.
+ when(mockFireperfActivatedCache.putWithoutWaitingForDiskWrite(containerWithAbtExperiments))
+ .thenReturn(Tasks.forResult(containerWithAbtExperiments));
+
+ assertWithMessage("activateFetched(fireperf) failed!")
+ .that(fireperfFrc.activateFetched())
+ .isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @Test
+ public void activate_getFetchedFailed_returnsFalse() {
+ loadCacheWithIoException(mockFetchedCache);
+ loadCacheWithConfig(mockActivatedCache, null);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() succeeded with no fetched values!")
+ .that(activateTask.getResult())
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void activate_noFetchedConfigs_returnsFalse() {
+ loadCacheWithConfig(mockFetchedCache, null);
+ loadCacheWithConfig(mockActivatedCache, null);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() succeeded with no fetched values!")
+ .that(activateTask.getResult())
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void activate_staleFetchedConfigs_returnsFalse() {
+ loadCacheWithConfig(mockFetchedCache, firstFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() succeeded with stale values!")
+ .that(activateTask.getResult())
+ .isFalse();
+
+ verify(mockActivatedCache, never()).put(any());
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void activate_noActivatedConfigs_activatesAndClearsFetched() {
+ loadCacheWithConfig(mockFetchedCache, firstFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, null);
+
+ cachePutReturnsConfig(mockActivatedCache, firstFetchedContainer);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() failed with no activated values!")
+ .that(activateTask.getResult())
+ .isTrue();
+
+ verify(mockActivatedCache).put(firstFetchedContainer);
+ verify(mockFetchedCache).clear();
+ }
+
+ @Test
+ public void activate_getActivatedFailed_activatesAndClearsFetched() {
+ loadCacheWithConfig(mockFetchedCache, firstFetchedContainer);
+ loadCacheWithIoException(mockActivatedCache);
+
+ cachePutReturnsConfig(mockActivatedCache, firstFetchedContainer);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() failed with no activated values!")
+ .that(activateTask.getResult())
+ .isTrue();
+
+ verify(mockActivatedCache).put(firstFetchedContainer);
+ verify(mockFetchedCache).clear();
+ }
+
+ @Test
+ public void activate_freshFetchedConfigs_activatesAndClearsFetched() {
+ loadCacheWithConfig(mockFetchedCache, secondFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+
+ cachePutReturnsConfig(mockActivatedCache, secondFetchedContainer);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() failed!").that(activateTask.getResult()).isTrue();
+
+ verify(mockActivatedCache).put(secondFetchedContainer);
+ verify(mockFetchedCache).clear();
+ }
+
+ @Test
+ public void activate_fileWriteFails_doesNotClearFetchedAndReturnsFalse() {
+ loadCacheWithConfig(mockFetchedCache, secondFetchedContainer);
+ loadCacheWithConfig(mockActivatedCache, firstFetchedContainer);
+
+ when(mockActivatedCache.put(secondFetchedContainer))
+ .thenReturn(Tasks.forException(new IOException("Should have handled disk error.")));
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() succeeded even though file write failed!")
+ .that(activateTask.getResult())
+ .isFalse();
+
+ verify(mockActivatedCache).put(secondFetchedContainer);
+ verify(mockFetchedCache, never()).clear();
+ }
+
+ @Test
+ public void activate_hasNoAbtExperiments_sendsEmptyListToAbt() throws Exception {
+ ConfigContainer containerWithNoAbtExperiments =
+ ConfigContainer.newBuilder().withFetchTime(new Date(1000L)).build();
+
+ loadCacheWithConfig(mockFetchedCache, containerWithNoAbtExperiments);
+ cachePutReturnsConfig(mockActivatedCache, containerWithNoAbtExperiments);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() failed!").that(activateTask.getResult()).isTrue();
+
+ verify(mockFirebaseAbt).replaceAllExperiments(ImmutableList.of());
+ }
+
+ @Test
+ public void activate_callToAbtFails_activateStillSucceeds() throws Exception {
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+
+ loadCacheWithConfig(mockFetchedCache, containerWithAbtExperiments);
+ cachePutReturnsConfig(mockActivatedCache, containerWithAbtExperiments);
+
+ doThrow(new AbtException("Abt failure!")).when(mockFirebaseAbt).replaceAllExperiments(any());
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() failed!").that(activateTask.getResult()).isTrue();
+ }
+
+ @Test
+ public void activate_hasAbtExperiments_sendsExperimentsToAbt() throws Exception {
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+
+ loadCacheWithConfig(mockFetchedCache, containerWithAbtExperiments);
+ cachePutReturnsConfig(mockActivatedCache, containerWithAbtExperiments);
+
+ Task activateTask = frc.activate();
+
+ assertWithMessage("activate() failed!").that(activateTask.getResult()).isTrue();
+
+ List> expectedExperimentInfoMaps =
+ toExperimentInfoMaps(containerWithAbtExperiments.getAbtExperiments());
+ verify(mockFirebaseAbt).replaceAllExperiments(expectedExperimentInfoMaps);
+ }
+
+ @Test
+ public void activate2p_hasNoAbtExperiments_doesNotCallAbt() throws Exception {
+ ConfigContainer containerWithNoAbtExperiments =
+ ConfigContainer.newBuilder().withFetchTime(new Date(1000L)).build();
+
+ loadCacheWithConfig(mockFireperfFetchedCache, containerWithNoAbtExperiments);
+ cachePutReturnsConfig(mockFireperfActivatedCache, containerWithNoAbtExperiments);
+
+ Task activateTask = fireperfFrc.activate();
+
+ assertWithMessage("Fireperf activate() failed!").that(activateTask.getResult()).isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @Test
+ public void activate2p_hasAbtExperiments_doesNotCallAbt() throws Exception {
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments())
+ .build();
+
+ loadCacheWithConfig(mockFireperfFetchedCache, containerWithAbtExperiments);
+ cachePutReturnsConfig(mockFireperfActivatedCache, containerWithAbtExperiments);
+
+ Task activateTask = fireperfFrc.activate();
+
+ assertWithMessage("Fireperf activate() failed!").that(activateTask.getResult()).isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @Test
+ public void fetch_hasNoErrors_taskReturnsSuccess() {
+ when(mockFetchHandler.fetch()).thenReturn(Tasks.forResult(firstFetchedContainerResponse));
+
+ Task fetchTask = frc.fetch();
+
+ assertWithMessage("Fetch failed!").that(fetchTask.isSuccessful()).isTrue();
+ }
+
+ @Test
+ public void fetch_hasNetworkError_taskReturnsException() {
+ when(mockFetchHandler.fetch())
+ .thenReturn(
+ Tasks.forException(new FirebaseRemoteConfigClientException("Network call failed.")));
+
+ Task fetchTask = frc.fetch();
+
+ assertWithMessage("Fetch succeeded even though there's a network error!")
+ .that(fetchTask.isSuccessful())
+ .isFalse();
+ }
+
+ @Test
+ public void fetchWithInterval_hasNoErrors_taskReturnsSuccess() {
+ long minimumFetchIntervalInSeconds = 600L;
+ when(mockFetchHandler.fetch(minimumFetchIntervalInSeconds))
+ .thenReturn(Tasks.forResult(firstFetchedContainerResponse));
+
+ Task fetchTask = frc.fetch(minimumFetchIntervalInSeconds);
+
+ assertWithMessage("Fetch failed!").that(fetchTask.isSuccessful()).isTrue();
+ }
+
+ @Test
+ public void fetchWithInterval_hasNetworkError_taskReturnsException() {
+ long minimumFetchIntervalInSeconds = 600L;
+ when(mockFetchHandler.fetch(minimumFetchIntervalInSeconds))
+ .thenReturn(
+ Tasks.forException(new FirebaseRemoteConfigClientException("Network call failed.")));
+
+ Task fetchTask = frc.fetch(minimumFetchIntervalInSeconds);
+
+ assertWithMessage("Fetch succeeded even though there's a network error!")
+ .that(fetchTask.isSuccessful())
+ .isFalse();
+ }
+
+ @Test
+ public void getKeysByPrefix_noKeysWithPrefix_returnsEmptySet() {
+ when(mockGetHandler.getKeysByPrefix("pre")).thenReturn(ImmutableSet.of());
+
+ assertThat(frc.getKeysByPrefix("pre")).isEmpty();
+ }
+
+ @Test
+ public void getKeysByPrefix_hasKeysWithPrefix_returnsKeysWithPrefix() {
+ Set keysWithPrefix = ImmutableSet.of("pre11", "pre12");
+ when(mockGetHandler.getKeysByPrefix("pre")).thenReturn(keysWithPrefix);
+
+ assertThat(frc.getKeysByPrefix("pre")).containsExactlyElementsIn(keysWithPrefix);
+ }
+
+ @Test
+ public void getString_keyDoesNotExist_returnsDefaultValue() {
+ when(mockGetHandler.getString(STRING_KEY)).thenReturn(DEFAULT_VALUE_FOR_STRING);
+
+ assertThat(frc.getString(STRING_KEY)).isEqualTo(DEFAULT_VALUE_FOR_STRING);
+ }
+
+ @Test
+ public void getString_keyExists_returnsRemoteValue() {
+ String remoteValue = "remote value";
+ when(mockGetHandler.getString(STRING_KEY)).thenReturn(remoteValue);
+
+ assertThat(frc.getString(STRING_KEY)).isEqualTo(remoteValue);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getString_fireperfNamespace_keyDoesNotExist_returnsDefaultValue() {
+ when(mockFireperfGetHandler.getString(STRING_KEY)).thenReturn(DEFAULT_VALUE_FOR_STRING);
+
+ assertThat(fireperfFrc.getString(STRING_KEY)).isEqualTo(DEFAULT_VALUE_FOR_STRING);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getString_fireperfNamespace_keyExists_returnsRemoteValue() {
+ String remoteValue = "remote value";
+ when(mockFireperfGetHandler.getString(STRING_KEY)).thenReturn(remoteValue);
+
+ assertThat(fireperfFrc.getString(STRING_KEY)).isEqualTo(remoteValue);
+ }
+
+ @Test
+ public void getBoolean_keyDoesNotExist_returnsDefaultValue() {
+ when(mockGetHandler.getBoolean(BOOLEAN_KEY)).thenReturn(DEFAULT_VALUE_FOR_BOOLEAN);
+
+ assertThat(frc.getBoolean(BOOLEAN_KEY)).isEqualTo(DEFAULT_VALUE_FOR_BOOLEAN);
+ }
+
+ @Test
+ public void getBoolean_keyExists_returnsRemoteValue() {
+ when(mockGetHandler.getBoolean(BOOLEAN_KEY)).thenReturn(true);
+
+ assertThat(frc.getBoolean(BOOLEAN_KEY)).isTrue();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getBoolean_fireperfNamespace_keyDoesNotExist_returnsDefaultValue() {
+ when(mockFireperfGetHandler.getBoolean(BOOLEAN_KEY)).thenReturn(DEFAULT_VALUE_FOR_BOOLEAN);
+
+ assertThat(fireperfFrc.getBoolean(BOOLEAN_KEY)).isEqualTo(DEFAULT_VALUE_FOR_BOOLEAN);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getBoolean_fireperfNamespace_keyExists_returnsRemoteValue() {
+ when(mockFireperfGetHandler.getBoolean(BOOLEAN_KEY)).thenReturn(true);
+
+ assertThat(fireperfFrc.getBoolean(BOOLEAN_KEY)).isTrue();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getByteArray_keyDoesNotExist_returnsDefaultValue() {
+ when(mockGetHandler.getByteArray(BYTE_ARRAY_KEY)).thenReturn(DEFAULT_VALUE_FOR_BYTE_ARRAY);
+
+ assertThat(frc.getByteArray(BYTE_ARRAY_KEY)).isEqualTo(DEFAULT_VALUE_FOR_BYTE_ARRAY);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getByteArray_keyExists_returnsRemoteValue() {
+ byte[] remoteValue = "remote value".getBytes(FRC_BYTE_ARRAY_ENCODING);
+ when(mockGetHandler.getByteArray(BYTE_ARRAY_KEY)).thenReturn(remoteValue);
+
+ assertThat(frc.getByteArray(BYTE_ARRAY_KEY)).isEqualTo(remoteValue);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getByteArray_fireperfNamespace_keyDoesNotExist_returnsDefaultValue() {
+ when(mockFireperfGetHandler.getByteArray(BYTE_ARRAY_KEY))
+ .thenReturn(DEFAULT_VALUE_FOR_BYTE_ARRAY);
+
+ assertThat(fireperfFrc.getByteArray(BYTE_ARRAY_KEY)).isEqualTo(DEFAULT_VALUE_FOR_BYTE_ARRAY);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getByteArray_fireperfNamespace_keyExists_returnsRemoteValue() {
+ byte[] remoteValue = "remote value".getBytes(FRC_BYTE_ARRAY_ENCODING);
+ when(mockFireperfGetHandler.getByteArray(BYTE_ARRAY_KEY)).thenReturn(remoteValue);
+
+ assertThat(fireperfFrc.getByteArray(BYTE_ARRAY_KEY)).isEqualTo(remoteValue);
+ }
+
+ @Test
+ public void getDouble_keyDoesNotExist_returnsDefaultValue() {
+ when(mockGetHandler.getDouble(DOUBLE_KEY)).thenReturn(DEFAULT_VALUE_FOR_DOUBLE);
+
+ assertThat(frc.getDouble(DOUBLE_KEY)).isEqualTo(DEFAULT_VALUE_FOR_DOUBLE);
+ }
+
+ @Test
+ public void getDouble_keyExists_returnsRemoteValue() {
+ double remoteValue = 555.5;
+ when(mockGetHandler.getDouble(DOUBLE_KEY)).thenReturn(remoteValue);
+
+ assertThat(frc.getDouble(DOUBLE_KEY)).isEqualTo(remoteValue);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getDouble_fireperfNamespace_keyDoesNotExist_returnsDefaultValue() {
+ when(mockFireperfGetHandler.getDouble(DOUBLE_KEY)).thenReturn(DEFAULT_VALUE_FOR_DOUBLE);
+
+ assertThat(fireperfFrc.getDouble(DOUBLE_KEY)).isEqualTo(DEFAULT_VALUE_FOR_DOUBLE);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getDouble_fireperfNamespace_keyExists_returnsRemoteValue() {
+ double remoteValue = 555.5;
+ when(mockFireperfGetHandler.getDouble(DOUBLE_KEY)).thenReturn(remoteValue);
+
+ assertThat(fireperfFrc.getDouble(DOUBLE_KEY)).isEqualTo(remoteValue);
+ }
+
+ @Test
+ public void getLong_keyDoesNotExist_returnsDefaultValue() {
+ when(mockGetHandler.getLong(LONG_KEY)).thenReturn(DEFAULT_VALUE_FOR_LONG);
+
+ assertThat(frc.getLong(LONG_KEY)).isEqualTo(DEFAULT_VALUE_FOR_LONG);
+ }
+
+ @Test
+ public void getLong_keyExists_returnsRemoteValue() {
+ long remoteValue = 555L;
+ when(mockGetHandler.getLong(LONG_KEY)).thenReturn(remoteValue);
+
+ assertThat(frc.getLong(LONG_KEY)).isEqualTo(remoteValue);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getLong_fireperfNamespace_keyDoesNotExist_returnsDefaultValue() {
+ when(mockFireperfGetHandler.getLong(LONG_KEY)).thenReturn(DEFAULT_VALUE_FOR_LONG);
+
+ assertThat(fireperfFrc.getLong(LONG_KEY)).isEqualTo(DEFAULT_VALUE_FOR_LONG);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void getLong_fireperfNamespace_keyExists_returnsRemoteValue() {
+ long remoteValue = 555L;
+ when(mockFireperfGetHandler.getLong(LONG_KEY)).thenReturn(remoteValue);
+
+ assertThat(fireperfFrc.getLong(LONG_KEY)).isEqualTo(remoteValue);
+ }
+
+ @Test
+ public void getInfo_returnsInfo() {
+ when(metadataClient.getInfo()).thenReturn(mockFrcInfo);
+
+ long fetchTimeInMillis = 100L;
+ int lastFetchStatus = LAST_FETCH_STATUS_THROTTLED;
+ long fetchTimeoutInSeconds = 10L;
+ long minimumFetchIntervalInSeconds = 100L;
+ when(mockFrcInfo.getFetchTimeMillis()).thenReturn(fetchTimeInMillis);
+ when(mockFrcInfo.getLastFetchStatus()).thenReturn(lastFetchStatus);
+ when(mockFrcInfo.getConfigSettings())
+ .thenReturn(
+ new FirebaseRemoteConfigSettings.Builder()
+ .setDeveloperModeEnabled(true)
+ .setFetchTimeoutInSeconds(fetchTimeoutInSeconds)
+ .setMinimumFetchIntervalInSeconds(minimumFetchIntervalInSeconds)
+ .build());
+
+ FirebaseRemoteConfigInfo info = frc.getInfo();
+
+ assertThat(info.getFetchTimeMillis()).isEqualTo(fetchTimeInMillis);
+ assertThat(info.getLastFetchStatus()).isEqualTo(lastFetchStatus);
+ assertThat(info.getConfigSettings().isDeveloperModeEnabled()).isEqualTo(true);
+ assertThat(info.getConfigSettings().getFetchTimeoutInSeconds())
+ .isEqualTo(fetchTimeoutInSeconds);
+ assertThat(info.getConfigSettings().getMinimumFetchIntervalInSeconds())
+ .isEqualTo(minimumFetchIntervalInSeconds);
+ }
+
+ @Test
+ public void setDefaults_withMap_setsDefaults() throws Exception {
+ frc.setDefaults(ImmutableMap.copyOf(DEFAULTS_MAP));
+
+ ConfigContainer defaultsContainer = newDefaultsContainer(DEFAULTS_MAP);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+
+ verify(mockDefaultsCache).putWithoutWaitingForDiskWrite(captor.capture());
+ JSONAssert.assertEquals(defaultsContainer.toString(), captor.getValue().toString(), false);
+ }
+
+ @Test
+ public void setDefaultsAsync_withMap_setsDefaults() throws Exception {
+ ConfigContainer defaultsContainer = newDefaultsContainer(DEFAULTS_MAP);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+ cachePutReturnsConfig(mockDefaultsCache, defaultsContainer);
+
+ boolean isComplete = frc.setDefaultsAsync(ImmutableMap.copyOf(DEFAULTS_MAP)).isComplete();
+
+ assertThat(isComplete).isTrue();
+ // Assert defaults were set correctly.
+ verify(mockDefaultsCache).put(captor.capture());
+
+ JSONAssert.assertEquals(defaultsContainer.toString(), captor.getValue().toString(), false);
+ }
+
+ @Test
+ public void clear_hasSettings_clearsEverything() {
+ frc.reset();
+
+ verify(mockActivatedCache).clear();
+ verify(mockFetchedCache).clear();
+ verify(mockDefaultsCache).clear();
+ verify(metadataClient).clear();
+ }
+
+ @Test
+ public void setConfigSettings_updatesMetadata() {
+ long fetchTimeout = 13L;
+ long minimumFetchInterval = 666L;
+ FirebaseRemoteConfigSettings frcSettings =
+ new FirebaseRemoteConfigSettings.Builder()
+ .setDeveloperModeEnabled(true)
+ .setFetchTimeoutInSeconds(fetchTimeout)
+ .setMinimumFetchIntervalInSeconds(minimumFetchInterval)
+ .build();
+
+ frc.setConfigSettings(frcSettings);
+
+ verify(metadataClient).setConfigSettingsWithoutWaitingOnDiskWrite(frcSettings);
+ }
+
+ @Test
+ public void setConfigSettingsAsync_updatesMetadata() {
+ long fetchTimeout = 13L;
+ long minimumFetchInterval = 666L;
+ FirebaseRemoteConfigSettings frcSettings =
+ new FirebaseRemoteConfigSettings.Builder()
+ .setDeveloperModeEnabled(true)
+ .setFetchTimeoutInSeconds(fetchTimeout)
+ .setMinimumFetchIntervalInSeconds(minimumFetchInterval)
+ .build();
+
+ Task setterTask = frc.setConfigSettingsAsync(frcSettings);
+
+ assertThat(setterTask.isSuccessful()).isTrue();
+ verify(metadataClient).setConfigSettings(frcSettings);
+ }
+
+ private static void loadCacheWithConfig(
+ ConfigCacheClient cacheClient, ConfigContainer container) {
+ when(cacheClient.getBlocking()).thenReturn(container);
+ when(cacheClient.get()).thenReturn(Tasks.forResult(container));
+ }
+
+ private static void loadCacheWithIoException(ConfigCacheClient cacheClient) {
+ when(cacheClient.getBlocking()).thenReturn(null);
+ when(cacheClient.get())
+ .thenReturn(Tasks.forException(new IOException("Should have handled disk error.")));
+ }
+
+ private void loadActivatedCacheWithIncompleteTask() {
+ TaskCompletionSource taskSource = new TaskCompletionSource<>();
+ when(mockActivatedCache.get()).thenReturn(taskSource.getTask());
+ }
+
+ private static void cachePutReturnsConfig(
+ ConfigCacheClient cacheClient, ConfigContainer container) {
+ when(cacheClient.put(container)).thenReturn(Tasks.forResult(container));
+ }
+
+ private void loadFetchHandlerWithResponse() {
+ when(mockFetchHandler.fetch()).thenReturn(Tasks.forResult(firstFetchedContainerResponse));
+ }
+
+ private void load2pFetchHandlerWithResponse() {
+ when(mockFireperfFetchHandler.fetch())
+ .thenReturn(Tasks.forResult(firstFetchedContainerResponse));
+ }
+
+ private static int getResourceId(String xmlResourceName) {
+ Resources r = RuntimeEnvironment.application.getResources();
+ return r.getIdentifier(xmlResourceName, "xml", RuntimeEnvironment.application.getPackageName());
+ }
+
+ private static ConfigContainer newDefaultsContainer(Map configsMap)
+ throws Exception {
+ return ConfigContainer.newBuilder()
+ .replaceConfigsWith(configsMap)
+ .withFetchTime(new Date(0L))
+ .build();
+ }
+
+ private T getTaskResult(Task task) {
+ assertThat(task.isComplete()).isTrue();
+ assertThat(task.getResult()).isNotNull();
+ return task.getResult();
+ }
+
+ private static JSONArray generateAbtExperiments() throws JSONException {
+ JSONArray experiments = new JSONArray();
+ for (int experimentNum = 1; experimentNum <= 5; experimentNum++) {
+ experiments.put(createAbtExperiment("exp" + experimentNum));
+ }
+ return experiments;
+ }
+
+ private static FirebaseApp initializeFirebaseApp(Context context) {
+ FirebaseApp.clearInstancesForTest();
+
+ return FirebaseApp.initializeApp(
+ context, new FirebaseOptions.Builder().setApiKey(API_KEY).setApplicationId(APP_ID).build());
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockAnalyticsConnectorRegistrar.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockAnalyticsConnectorRegistrar.java
new file mode 100644
index 00000000000..2cd7b7ef899
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockAnalyticsConnectorRegistrar.java
@@ -0,0 +1,40 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static org.mockito.Mockito.mock;
+
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.components.Component;
+import com.google.firebase.components.ComponentRegistrar;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Mock {@link AnalyticsConnector} for testing purposes.
+ *
+ * @author Miraziz Yusupov
+ */
+public class MockAnalyticsConnectorRegistrar implements ComponentRegistrar {
+ @Override
+ public List> getComponents() {
+ Component mockAnalyticsConnector =
+ Component.builder(AnalyticsConnector.class)
+ .factory(container -> mock(AnalyticsConnector.class))
+ .build();
+
+ return Arrays.asList(mockAnalyticsConnector);
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseAbtRegistrar.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseAbtRegistrar.java
new file mode 100644
index 00000000000..ea298c6f8cb
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseAbtRegistrar.java
@@ -0,0 +1,43 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import com.google.firebase.abt.FirebaseABTesting;
+import com.google.firebase.abt.component.AbtComponent;
+import com.google.firebase.components.Component;
+import com.google.firebase.components.ComponentRegistrar;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Mock {@link FirebaseABTesting} for testing purposes.
+ *
+ * @author Miraziz Yusupov
+ */
+public class MockFirebaseAbtRegistrar implements ComponentRegistrar {
+ @Override
+ public List> getComponents() {
+ Component mockFirebaseAbt =
+ Component.builder(AbtComponent.class).factory(container -> new FakeAbtComponent()).build();
+
+ return Arrays.asList(mockFirebaseAbt);
+ }
+
+ private static class FakeAbtComponent extends AbtComponent {
+ FakeAbtComponent() {
+ super(/*appContext=*/ null, /*analyticsConnector=*/ null);
+ }
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseIidRegistrar.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseIidRegistrar.java
new file mode 100644
index 00000000000..6af49e4bcd5
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseIidRegistrar.java
@@ -0,0 +1,40 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static org.mockito.Mockito.mock;
+
+import com.google.firebase.components.Component;
+import com.google.firebase.components.ComponentRegistrar;
+import com.google.firebase.iid.FirebaseInstanceId;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Mock {@link FirebaseInstanceId} for testing purposes.
+ *
+ * @author Miraziz Yusupov
+ */
+public class MockFirebaseIidRegistrar implements ComponentRegistrar {
+ @Override
+ public List> getComponents() {
+ Component mockFirebaseInstanceId =
+ Component.builder(FirebaseInstanceId.class)
+ .factory(container -> mock(FirebaseInstanceId.class))
+ .build();
+
+ return Arrays.asList(mockFirebaseInstanceId);
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java
new file mode 100644
index 00000000000..fa21b7184d8
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java
@@ -0,0 +1,242 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.firebase.remoteconfig.AbtExperimentHelper.createAbtExperiment;
+import static com.google.firebase.remoteconfig.AbtExperimentHelper.createAbtExperiments;
+import static com.google.firebase.remoteconfig.RemoteConfigComponent.DEFAULT_NAMESPACE;
+import static com.google.firebase.remoteconfig.RemoteConfigComponent.NETWORK_CONNECTION_TIMEOUT_IN_SECONDS;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import com.google.android.gms.tasks.Tasks;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.abt.FirebaseABTesting;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.remoteconfig.internal.ConfigCacheClient;
+import com.google.firebase.remoteconfig.internal.ConfigContainer;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHttpClient;
+import com.google.firebase.remoteconfig.internal.ConfigGetParameterHandler;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient;
+import com.google.firebase.remoteconfig.internal.LegacyConfigsHandler;
+import java.util.concurrent.ExecutorService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/**
+ * Unit tests for the Firebase Remote Config Component.
+ *
+ * @author Miraziz Yusupov
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class RemoteConfigComponentTest {
+ private static final String API_KEY = "api_key";
+ private static final String APP_ID = "1:14368190084:android:09cb977358c6f241";
+ private static final String DUMMY_API_KEY = "api_key";
+
+ @Mock private FirebaseApp mockFirebaseApp;
+ @Mock private FirebaseInstanceId mockFirebaseIid;
+ @Mock private FirebaseABTesting mockFirebaseAbt;
+ @Mock private AnalyticsConnector mockAnalyticsConnector;
+ @Mock private LegacyConfigsHandler mockLegacyConfigsHandler;
+ @Mock private ConfigCacheClient mockFetchedCache;
+ @Mock private ConfigCacheClient mockActivatedCache;
+ @Mock private ConfigCacheClient mockDefaultsCache;
+ @Mock private ConfigFetchHandler mockFetchHandler;
+ @Mock private ConfigGetParameterHandler mockGetParameterHandler;
+ @Mock private ConfigMetadataClient mockMetadataClient;
+
+ private Context context;
+ private ExecutorService directExecutor;
+ private FirebaseApp defaultApp;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ context = RuntimeEnvironment.application;
+ directExecutor = MoreExecutors.newDirectExecutorService();
+
+ defaultApp = initializeFirebaseApp(context);
+
+ when(mockFirebaseApp.getOptions())
+ .thenReturn(new FirebaseOptions.Builder().setApplicationId(APP_ID).build());
+ when(mockFirebaseApp.getName()).thenReturn(FirebaseApp.DEFAULT_APP_NAME);
+ }
+
+ @Test
+ public void constructor_callsLegacyConfigHandler() {
+ getNewFrcComponent();
+ verify(mockLegacyConfigsHandler).saveLegacyConfigsIfNecessary();
+ }
+
+ @Test
+ public void frc2p_doesNotCallAbt() throws Exception {
+
+ FirebaseRemoteConfig fireperfFrc =
+ getFrcInstanceFromComponent(getNewFrcComponent(), /* namespace= */ "fireperf");
+ loadConfigsWithExperimentsForActivate();
+
+ assertWithMessage("Fireperf fetch and activate failed!")
+ .that(fireperfFrc.activate().getResult())
+ .isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @Test
+ public void frcNonMainFirebaseApp_doesNotCallAbt() throws Exception {
+
+ when(mockFirebaseApp.getName()).thenReturn("secondary");
+ FirebaseRemoteConfig frc =
+ getFrcInstanceFromComponent(getNewFrcComponentWithoutLoadingDefault(), DEFAULT_NAMESPACE);
+ loadConfigsWithExperimentsForActivate();
+
+ assertWithMessage("Fetch and activate failed!").that(frc.activate().getResult()).isTrue();
+
+ verify(mockFirebaseAbt, never()).replaceAllExperiments(any());
+ }
+
+ @Test
+ public void getFetchHandler_nonMainFirebaseApp_doesNotUseAnalytics() {
+
+ when(mockFirebaseApp.getName()).thenReturn("secondary");
+
+ ConfigFetchHandler fetchHandler =
+ getNewFrcComponent()
+ .getFetchHandler(DEFAULT_NAMESPACE, mockFetchedCache, mockMetadataClient);
+
+ assertThat(fetchHandler.getAnalyticsConnector()).isNull();
+ }
+
+ @Test
+ public void
+ getFrcBackendApiClient_fetchTimeoutIsNotSet_buildsConfigFetchHttpClientWithDefaultConnectionTimeout() {
+
+ RemoteConfigComponent frcComponent = defaultApp.get(RemoteConfigComponent.class);
+ when(mockMetadataClient.getFetchTimeoutInSeconds())
+ .thenReturn(NETWORK_CONNECTION_TIMEOUT_IN_SECONDS);
+
+ ConfigFetchHttpClient frcBackendClient =
+ frcComponent.getFrcBackendApiClient(DUMMY_API_KEY, DEFAULT_NAMESPACE, mockMetadataClient);
+
+ int actualConnectTimeout = getConnectTimeoutInSeconds(frcBackendClient);
+ int actualReadTimeout = getReadTimeoutInSeconds(frcBackendClient);
+ assertThat(actualConnectTimeout).isEqualTo(NETWORK_CONNECTION_TIMEOUT_IN_SECONDS);
+ assertThat(actualReadTimeout).isEqualTo(NETWORK_CONNECTION_TIMEOUT_IN_SECONDS);
+ }
+
+ @Test
+ public void
+ getFrcBackendApiClient_fetchTimeoutIsSetToDoubleDefault_buildsConfigFetchHttpClientWithDoubleDefaultConnectionTimeout() {
+
+ RemoteConfigComponent frcComponent = defaultApp.get(RemoteConfigComponent.class);
+
+ long customNetworkConnectionTimeoutInSeconds = 2 * NETWORK_CONNECTION_TIMEOUT_IN_SECONDS;
+ when(mockMetadataClient.getFetchTimeoutInSeconds())
+ .thenReturn(customNetworkConnectionTimeoutInSeconds);
+
+ ConfigFetchHttpClient frcBackendClient =
+ frcComponent.getFrcBackendApiClient(DUMMY_API_KEY, DEFAULT_NAMESPACE, mockMetadataClient);
+
+ int actualConnectTimeout = getConnectTimeoutInSeconds(frcBackendClient);
+ int actualReadTimeout = getReadTimeoutInSeconds(frcBackendClient);
+ assertThat(actualConnectTimeout).isEqualTo(customNetworkConnectionTimeoutInSeconds);
+ assertThat(actualReadTimeout).isEqualTo(NETWORK_CONNECTION_TIMEOUT_IN_SECONDS);
+ }
+
+ private RemoteConfigComponent getNewFrcComponent() {
+ return new RemoteConfigComponent(
+ context,
+ directExecutor,
+ mockFirebaseApp,
+ mockFirebaseIid,
+ mockFirebaseAbt,
+ mockAnalyticsConnector,
+ mockLegacyConfigsHandler,
+ /* loadGetDefault= */ true);
+ }
+
+ private RemoteConfigComponent getNewFrcComponentWithoutLoadingDefault() {
+ return new RemoteConfigComponent(
+ context,
+ directExecutor,
+ mockFirebaseApp,
+ mockFirebaseIid,
+ mockFirebaseAbt,
+ mockAnalyticsConnector,
+ mockLegacyConfigsHandler,
+ /* loadGetDefault= */ false);
+ }
+
+ private FirebaseRemoteConfig getFrcInstanceFromComponent(
+ RemoteConfigComponent frcComponent, String namespace) {
+ return frcComponent.get(
+ mockFirebaseApp,
+ namespace,
+ mockFirebaseAbt,
+ directExecutor,
+ mockFetchedCache,
+ mockActivatedCache,
+ mockDefaultsCache,
+ mockFetchHandler,
+ mockGetParameterHandler,
+ mockMetadataClient);
+ }
+
+ private void loadConfigsWithExperimentsForActivate() throws Exception {
+ ConfigContainer containerWithAbtExperiments =
+ ConfigContainer.newBuilder()
+ .withAbtExperiments(createAbtExperiments(createAbtExperiment("exp1")))
+ .build();
+
+ when(mockFetchedCache.get()).thenReturn(Tasks.forResult(containerWithAbtExperiments));
+ when(mockActivatedCache.get()).thenReturn(Tasks.forResult(null));
+
+ when(mockActivatedCache.put(containerWithAbtExperiments))
+ .thenReturn(Tasks.forResult(containerWithAbtExperiments));
+ }
+
+ private int getConnectTimeoutInSeconds(ConfigFetchHttpClient frcBackendClient) {
+ return (int) frcBackendClient.getConnectTimeoutInSeconds();
+ }
+
+ private int getReadTimeoutInSeconds(ConfigFetchHttpClient frcBackendClient) {
+ return (int) frcBackendClient.getReadTimeoutInSeconds();
+ }
+
+ private static FirebaseApp initializeFirebaseApp(Context context) {
+ FirebaseApp.clearInstancesForTest();
+
+ return FirebaseApp.initializeApp(
+ context, new FirebaseOptions.Builder().setApiKey(API_KEY).setApplicationId(APP_ID).build());
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java
new file mode 100644
index 00000000000..dddc300cbea
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java
@@ -0,0 +1,352 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.firebase.remoteconfig.testutil.Assert.assertThrows;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.android.gms.common.internal.Preconditions;
+import com.google.android.gms.shadows.common.internal.ShadowPreconditions;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.Tasks;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Unit tests for the {@link ConfigCacheClient}.
+ *
+ * @author Miraziz Yusupov
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ manifest = Config.NONE,
+ shadows = {ShadowPreconditions.class})
+public class ConfigCacheClientTest {
+ private static final IOException IO_EXCEPTION = new IOException("File I/O failed.");
+
+ @Mock private ConfigStorageClient mockStorageClient;
+
+ private ExecutorService testingThreadPool;
+ private ExecutorService cacheThreadPool;
+ private ConfigCacheClient cacheClient;
+ private ConfigContainer configContainer;
+ private ConfigContainer configContainer2;
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+
+ cacheThreadPool = Executors.newFixedThreadPool(/*nThreads=*/ 2);
+ testingThreadPool = Executors.newFixedThreadPool(/*nThreads=*/ 3);
+
+ ConfigCacheClient.clearInstancesForTest();
+ when(mockStorageClient.getFileName()).thenReturn("FILE_NAME");
+ cacheClient = ConfigCacheClient.getInstance(cacheThreadPool, mockStorageClient);
+
+ configContainer =
+ ConfigContainer.newBuilder()
+ .replaceConfigsWith(ImmutableMap.of("long_param", "1L", "string_param", "string_value"))
+ .withFetchTime(new Date(1000L))
+ .build();
+
+ configContainer2 =
+ ConfigContainer.newBuilder()
+ .replaceConfigsWith(
+ ImmutableMap.of("string_param", "string_value", "double_param", "0.1"))
+ .withFetchTime(new Date(2000L))
+ .build();
+ }
+
+ @Test
+ public void put_validContainer_writesToFileAndSetsCache() throws Exception {
+ ConfigContainer putContainer = Tasks.await(cacheClient.put(configContainer));
+
+ verifyFileWrites(configContainer);
+
+ assertThat(putContainer).isEqualTo(configContainer);
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void put_fileWriteFails_keepsCacheNull() throws Exception {
+ when(mockStorageClient.write(configContainer)).thenThrow(IO_EXCEPTION);
+
+ Task putTask = cacheClient.put(configContainer);
+ assertThrows(ExecutionException.class, () -> Tasks.await(putTask));
+
+ assertThat(putTask.getException()).isInstanceOf(IOException.class);
+ assertThat(cacheClient.getCachedContainerTask()).isNull();
+ }
+
+ @Test
+ public void put_secondFileWriteFails_keepsFirstContainerInCache() throws Exception {
+ when(mockStorageClient.write(configContainer2)).thenThrow(IO_EXCEPTION);
+
+ Tasks.await(cacheClient.put(configContainer));
+ Preconditions.checkArgument(
+ cacheClient.getCachedContainerTask().getResult().equals(configContainer));
+
+ Task failedPutTask = cacheClient.put(configContainer2);
+ assertThrows(ExecutionException.class, () -> Tasks.await(failedPutTask));
+
+ verifyFileWrites(configContainer, configContainer2);
+
+ assertThat(failedPutTask.getException()).isInstanceOf(IOException.class);
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void get_hasCachedValue_returnsCache() throws Exception {
+ Tasks.await(cacheClient.put(configContainer));
+ Preconditions.checkArgument(
+ cacheClient.getCachedContainerTask().getResult().equals(configContainer));
+
+ ConfigContainer getContainer = Tasks.await(cacheClient.get());
+
+ verify(mockStorageClient, never()).read();
+ assertThat(getContainer).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void get_hasNoCachedValue_readsFileAndSetsCache() throws Exception {
+ when(mockStorageClient.read()).thenReturn(configContainer);
+
+ ConfigContainer getContainer = Tasks.await(cacheClient.get());
+ assertThat(getContainer).isEqualTo(configContainer);
+
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void get_hasNoCachedValueAndFileReadFails_throwsIOException() throws Exception {
+ when(mockStorageClient.read()).thenThrow(IO_EXCEPTION);
+
+ Task getTask = cacheClient.get();
+ assertThrows(ExecutionException.class, () -> Tasks.await(getTask));
+
+ assertThat(getTask.getException()).isInstanceOf(IOException.class);
+ }
+
+ @Test
+ public void get_hasFailedCacheValue_readsFileAndSetsCache() throws Exception {
+ when(mockStorageClient.read()).thenThrow(IO_EXCEPTION);
+ Task getTask = cacheClient.get();
+ assertThrows(ExecutionException.class, () -> Tasks.await(getTask));
+ Preconditions.checkArgument(getTask.getException() instanceof IOException);
+
+ doReturn(configContainer).when(mockStorageClient).read();
+
+ ConfigContainer getContainer = Tasks.await(cacheClient.get());
+
+ assertThat(getContainer).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void get_hasMultipleGetCallsInDifferentThreads_readsFileAndSetsCacheOnce()
+ throws Exception {
+ when(mockStorageClient.read()).thenReturn(configContainer);
+
+ List> getTasks = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ getTasks.add(Tasks.call(testingThreadPool, () -> Tasks.await(cacheClient.get())));
+ }
+
+ for (Task getTask : getTasks) {
+ assertThat(Tasks.await(getTask)).isEqualTo(configContainer);
+ }
+ verify(mockStorageClient, times(1)).read();
+ }
+
+ @Test
+ public void get_firstTwoFileReadsFail_readsFileAndSetsCacheThreeTimes() throws Exception {
+ doThrow(IO_EXCEPTION).when(mockStorageClient).read();
+ assertThrows(ExecutionException.class, () -> Tasks.await(cacheClient.get()));
+ assertThrows(ExecutionException.class, () -> Tasks.await(cacheClient.get()));
+
+ doReturn(configContainer).when(mockStorageClient).read();
+ for (int getCallIndex = 0; getCallIndex < 5; getCallIndex++) {
+ assertThat(Tasks.await(cacheClient.get())).isEqualTo(configContainer);
+ }
+
+ // Three file reads: 2 failures and 1 success.
+ verify(mockStorageClient, times(3)).read();
+ }
+
+ @Test
+ public void getBlocking_hasCachedValue_returnsCache() throws Exception {
+ Tasks.await(cacheClient.put(configContainer));
+ Preconditions.checkArgument(
+ cacheClient.getCachedContainerTask().getResult().equals(configContainer));
+
+ ConfigContainer readConfig = cacheClient.getBlocking();
+
+ assertThat(readConfig).isEqualTo(configContainer);
+
+ verify(mockStorageClient, never()).read();
+ }
+
+ @Test
+ public void getBlocking_hasNoCachedValueAndFileReadTimesOut_returnsNull() throws Exception {
+ when(mockStorageClient.read()).thenReturn(configContainer);
+
+ ConfigContainer container = cacheClient.getBlocking(/*diskReadTimeoutInSeconds=*/ 0L);
+
+ assertThat(container).isNull();
+ }
+
+ @Test
+ public void getBlocking_hasNoCachedValueAndFileReadFails_returnsNull() throws Exception {
+ when(mockStorageClient.read()).thenThrow(IO_EXCEPTION);
+
+ ConfigContainer container = cacheClient.getBlocking();
+
+ assertThat(container).isNull();
+ }
+
+ @Test
+ public void getBlocking_hasFailedCacheValue_blocksOnFileReadAndReturnsFileContainer()
+ throws Exception {
+ when(mockStorageClient.read()).thenThrow(IO_EXCEPTION);
+ Task getTask = cacheClient.get();
+ assertThrows(ExecutionException.class, () -> Tasks.await(getTask));
+ Preconditions.checkArgument(getTask.getException() instanceof IOException);
+
+ doReturn(configContainer).when(mockStorageClient).read();
+
+ ConfigContainer container = cacheClient.getBlocking();
+
+ assertThat(container).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void getBlocking_hasNoCachedValue_blocksOnFileReadAndReturnsFileContainer()
+ throws Exception {
+ when(mockStorageClient.read()).thenReturn(configContainer);
+
+ ConfigContainer container = cacheClient.getBlocking();
+
+ assertThat(container).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void getBlocking_firstTwoFileReadsFail_readsFileAndSetsCacheThreeTimes() throws Exception {
+ doThrow(IO_EXCEPTION).when(mockStorageClient).read();
+ assertThat(cacheClient.getBlocking()).isNull();
+ assertThat(cacheClient.getBlocking()).isNull();
+
+ doReturn(configContainer).when(mockStorageClient).read();
+ for (int getCallIndex = 0; getCallIndex < 5; getCallIndex++) {
+ assertThat(cacheClient.getBlocking()).isEqualTo(configContainer);
+ }
+
+ verify(mockStorageClient, times(3)).read();
+ }
+
+ @Test
+ public void putWithoutWaitingForDiskWrite_fileWriteFails_setsCache() throws Exception {
+ when(mockStorageClient.write(configContainer)).thenThrow(IO_EXCEPTION);
+
+ Task putTask = cacheClient.putWithoutWaitingForDiskWrite(configContainer);
+ assertThrows(ExecutionException.class, () -> Tasks.await(putTask));
+
+ assertThat(putTask.getException()).isEqualTo(IO_EXCEPTION);
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void putWithoutWaitingForDiskWrite_fileWriteSucceeds_setsCache() throws Exception {
+ ConfigContainer putContainer =
+ Tasks.await(cacheClient.putWithoutWaitingForDiskWrite(configContainer));
+
+ assertThat(putContainer).isEqualTo(configContainer);
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isEqualTo(configContainer);
+ }
+
+ @Test
+ public void clear_hasNoCachedValue_setsCacheContainerToNull() {
+ Preconditions.checkArgument(cacheClient.getCachedContainerTask() == null);
+
+ cacheClient.clear();
+
+ verify(mockStorageClient).clear();
+
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isNull();
+ }
+
+ @Test
+ public void clear_hasCachedValue_setsCacheContainerToNull() throws Exception {
+ Tasks.await(cacheClient.put(configContainer));
+ Preconditions.checkArgument(
+ cacheClient.getCachedContainerTask().getResult().equals(configContainer));
+
+ cacheClient.clear();
+
+ verify(mockStorageClient).clear();
+
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isNull();
+ assertThat(Tasks.await(cacheClient.get())).isNull();
+ }
+
+ @Test
+ public void clear_hasOngoingGetCall_setsCacheContainerToNull() throws Exception {
+ when(mockStorageClient.read()).thenReturn(configContainer);
+ Tasks.call(testingThreadPool, () -> Tasks.await(cacheClient.get()));
+
+ cacheClient.clear();
+
+ verify(mockStorageClient).clear();
+
+ assertThat(cacheClient.getCachedContainerTask().getResult()).isNull();
+ assertThat(Tasks.await(cacheClient.get())).isNull();
+ }
+
+ private void verifyFileWrites(ConfigContainer... containers) throws Exception {
+ int numContainers = containers.length;
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+ verify(mockStorageClient, times(numContainers)).write(captor.capture());
+
+ List capturedContainers = captor.getAllValues();
+ for (int i = 0; i < numContainers; i++) {
+ assertThat(capturedContainers.get(i)).isEqualTo(containers[i]);
+ }
+ }
+
+ @After
+ public void cleanUp() {
+ cacheThreadPool.shutdownNow();
+ testingThreadPool.shutdownNow();
+ }
+}
diff --git a/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java
new file mode 100644
index 00000000000..49466602655
--- /dev/null
+++ b/firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java
@@ -0,0 +1,967 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+//
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.remoteconfig.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_FAILURE;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS;
+import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_THROTTLED;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ExperimentDescriptionFieldKey.EXPERIMENT_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ExperimentDescriptionFieldKey.VARIANT_ID;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.ENTRIES;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.EXPERIMENT_DESCRIPTIONS;
+import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.STATE;
+import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.BACKOFF_TIME_DURATIONS_IN_MINUTES;
+import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS;
+import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.HTTP_TOO_MANY_REQUESTS;
+import static com.google.firebase.remoteconfig.internal.ConfigMetadataClient.LAST_FETCH_TIME_NO_FETCH_YET;
+import static com.google.firebase.remoteconfig.internal.ConfigMetadataClient.NO_BACKOFF_TIME;
+import static com.google.firebase.remoteconfig.internal.ConfigMetadataClient.NO_FAILED_FETCHES;
+import static com.google.firebase.remoteconfig.testutil.Assert.assertThrows;
+import static java.net.HttpURLConnection.HTTP_BAD_GATEWAY;
+import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
+import static java.net.HttpURLConnection.HTTP_GATEWAY_TIMEOUT;
+import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
+import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
+import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
+import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import androidx.annotation.Nullable;
+import com.google.android.gms.common.util.MockClock;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.Tasks;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.firebase.analytics.connector.AnalyticsConnector;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigClientException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigFetchThrottledException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigServerException;
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings;
+import com.google.firebase.remoteconfig.internal.ConfigFetchHandler.FetchResponse;
+import com.google.firebase.remoteconfig.internal.ConfigMetadataClient.BackoffMetadata;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.Executor;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.skyscreamer.jsonassert.JSONAssert;
+
+/**
+ * Unit tests for the Firebase Remote Config (FRC) Fetch handler.
+ *
+ * @author Miraziz Yusupov
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class ConfigFetchHandlerTest {
+ private static final String INSTANCE_ID_STRING = "fake instance id";
+ private static final String INSTANCE_ID_TOKEN_STRING = "fake instance id token";
+ private static final long DEFAULT_CACHE_EXPIRATION_IN_MILLISECONDS =
+ SECONDS.toMillis(DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS);
+
+ private static final Date FIRST_FETCH_TIME = new Date(HOURS.toMillis(1L));
+ private static final Date SECOND_FETCH_TIME = new Date(HOURS.toMillis(12L));
+
+ private Executor directExecutor;
+ private MockClock mockClock;
+ @Mock private Random mockRandom;
+ @Mock private ConfigCacheClient mockFetchedCache;
+
+ @Mock private ConfigFetchHttpClient mockBackendFetchApiClient;
+
+ private Context context;
+ @Mock private FirebaseInstanceId mockFirebaseInstanceId;
+ private ConfigMetadataClient metadataClient;
+
+ private ConfigFetchHandler fetchHandler;
+
+ private ConfigContainer firstFetchedContainer;
+ private ConfigContainer secondFetchedContainer;
+ private String responseETag = "";
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+
+ directExecutor = MoreExecutors.directExecutor();
+ context = RuntimeEnvironment.application.getApplicationContext();
+ mockClock = new MockClock(0L);
+ metadataClient =
+ new ConfigMetadataClient(context.getSharedPreferences("test_file", Context.MODE_PRIVATE));
+
+ loadBackendApiClient();
+ loadInstanceIdAndToken();
+
+ /*
+ * Every fetch starts with a call to retrieve the cached fetch values. Return successfully in
+ * the base case.
+ */
+ when(mockFetchedCache.get()).thenReturn(Tasks.forResult(null));
+
+ // Assume there is no analytics SDK for most of the tests.
+ fetchHandler = getNewFetchHandler(/*analyticsConnector=*/ null);
+
+ firstFetchedContainer =
+ ConfigContainer.newBuilder()
+ .replaceConfigsWith(
+ new JSONObject(ImmutableMap.of("string_param", "string_value", "long_param", "1L")))
+ .withFetchTime(FIRST_FETCH_TIME)
+ .build();
+
+ secondFetchedContainer =
+ ConfigContainer.newBuilder()
+ .replaceConfigsWith(
+ new JSONObject(
+ ImmutableMap.of("string_param", "string_value", "double_param", "0.1")))
+ .withFetchTime(SECOND_FETCH_TIME)
+ .build();
+ }
+
+ @Test
+ public void fetch_noPreviousSuccessfulFetch_fetchesFromBackend() throws Exception {
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(secondFetchedContainer);
+
+ assertWithMessage("Fetch() failed for first fetch!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verifyBackendIsCalled();
+ }
+
+ @Test
+ public void fetch_cacheHasNotExpired_doesNotFetchFromBackend() throws Exception {
+ loadCacheAndClockWithConfig(mockFetchedCache, firstFetchedContainer);
+
+ // Don't wait long enough for cache to expire.
+ mockClock.advance(DEFAULT_CACHE_EXPIRATION_IN_MILLISECONDS - 1);
+
+ assertWithMessage("Fetch() failed even though cache has not expired!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verifyBackendIsNeverCalled();
+ }
+
+ @Test
+ public void fetch_cacheHasNotExpiredAndEmptyFetchCache_doesNotFetchFromBackend()
+ throws Exception {
+ simulateFetchAndActivate(mockFetchedCache, firstFetchedContainer);
+
+ // Don't wait long enough for cache to expire.
+ mockClock.advance(DEFAULT_CACHE_EXPIRATION_IN_MILLISECONDS - 1);
+
+ assertWithMessage("Fetch() failed even though cache has not expired!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verifyBackendIsNeverCalled();
+ }
+
+ @Test
+ public void fetch_cacheHasExpired_fetchesFromBackend() throws Exception {
+ loadCacheAndClockWithConfig(mockFetchedCache, firstFetchedContainer);
+
+ // Wait long enough for cache to expire.
+ mockClock.advance(DEFAULT_CACHE_EXPIRATION_IN_MILLISECONDS);
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(secondFetchedContainer);
+
+ assertWithMessage("Fetch() failed after cache expired!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verifyBackendIsCalled();
+ }
+
+ @Test
+ public void fetch_cacheHasExpiredAndEmptyFetchCache_fetchesFromBackend() throws Exception {
+ simulateFetchAndActivate(mockFetchedCache, firstFetchedContainer);
+
+ // Wait long enough for cache to expire.
+ mockClock.advance(DEFAULT_CACHE_EXPIRATION_IN_MILLISECONDS);
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(secondFetchedContainer);
+
+ assertWithMessage("Fetch() failed after cache expired!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verifyBackendIsCalled();
+ }
+
+ @Test
+ public void fetch_userSetMinimumFetchIntervalHasPassed_fetchesFromBackend() throws Exception {
+ long minimumFetchIntervalInSeconds = 600L;
+ setMinimumFetchIntervalInMetadata(minimumFetchIntervalInSeconds);
+
+ simulateFetchAndActivate(mockFetchedCache, firstFetchedContainer);
+ // Wait long enough for cache to expire.
+ mockClock.advance(SECONDS.toMillis(minimumFetchIntervalInSeconds));
+
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(secondFetchedContainer);
+ assertWithMessage("Fetch() failed after cache expired!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verifyBackendIsCalled();
+ }
+
+ @Test
+ public void fetch_userSetMinimumFetchIntervalHasNotPassed_doesNotFetchFromBackend()
+ throws Exception {
+ long minimumFetchIntervalInSeconds = 600L;
+ setMinimumFetchIntervalInMetadata(minimumFetchIntervalInSeconds);
+
+ loadCacheAndClockWithConfig(mockFetchedCache, firstFetchedContainer);
+ // Don't wait long enough for cache to expire.
+ mockClock.advance(SECONDS.toMillis(minimumFetchIntervalInSeconds) - 1);
+
+ assertWithMessage("Fetch() failed even though cache has not expired!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verifyBackendIsNeverCalled();
+ }
+
+ @Test
+ public void fetchWithExpiration_noPreviousSuccessfulFetch_fetchesFromBackend() throws Exception {
+ // Wait long enough for cache to expire.
+ long cacheExpirationInHours = 1;
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(secondFetchedContainer);
+
+ assertWithMessage("Fetch() failed for first fetch!")
+ .that(fetchHandler.fetch(HOURS.toSeconds(cacheExpirationInHours)).isSuccessful())
+ .isTrue();
+
+ verifyBackendIsCalled();
+ }
+
+ @Test
+ public void fetchWithExpiration_cacheHasNotExpired_doesNotFetchFromBackend() throws Exception {
+ loadCacheAndClockWithConfig(mockFetchedCache, firstFetchedContainer);
+
+ // Don't wait long enough for cache to expire.
+ long cacheExpirationInHours = 1;
+ mockClock.advance(HOURS.toMillis(cacheExpirationInHours) - 1);
+
+ assertWithMessage("Fetch() failed even though cache has not expired!")
+ .that(fetchHandler.fetch(HOURS.toSeconds(cacheExpirationInHours)).isSuccessful())
+ .isTrue();
+
+ verifyBackendIsNeverCalled();
+ }
+
+ @Test
+ public void fetchWithExpiration_cacheHasNotExpiredAndEmptyFetchCache_doesNotFetchFromBackend()
+ throws Exception {
+ simulateFetchAndActivate(mockFetchedCache, firstFetchedContainer);
+
+ // Don't wait long enough for cache to expire.
+ long cacheExpirationInHours = 1;
+ mockClock.advance(HOURS.toMillis(cacheExpirationInHours) - 1);
+
+ assertWithMessage("Fetch() failed even though cache has not expired!")
+ .that(fetchHandler.fetch(HOURS.toSeconds(cacheExpirationInHours)).isSuccessful())
+ .isTrue();
+ verifyBackendIsNeverCalled();
+ }
+
+ @Test
+ public void fetchWithExpiration_cacheHasExpired_fetchesFromBackend() throws Exception {
+ loadCacheAndClockWithConfig(mockFetchedCache, firstFetchedContainer);
+
+ // Wait long enough for cache to expire.
+ long cacheExpirationInHours = 1;
+ mockClock.advance(HOURS.toMillis(cacheExpirationInHours));
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(secondFetchedContainer);
+
+ assertWithMessage("Fetch() failed after cache expired!")
+ .that(fetchHandler.fetch(HOURS.toSeconds(cacheExpirationInHours)).isSuccessful())
+ .isTrue();
+
+ verifyBackendIsCalled();
+ }
+
+ @Test
+ public void fetchWithExpiration_cacheHasExpiredAndEmptyFetchCache_fetchesFromBackend()
+ throws Exception {
+ simulateFetchAndActivate(mockFetchedCache, firstFetchedContainer);
+
+ // Wait long enough for cache to expire.
+ long cacheExpirationInHours = 1;
+ mockClock.advance(HOURS.toMillis(cacheExpirationInHours));
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(secondFetchedContainer);
+
+ assertWithMessage("Fetch() failed after cache expired!")
+ .that(fetchHandler.fetch(HOURS.toSeconds(cacheExpirationInHours)).isSuccessful())
+ .isTrue();
+
+ verifyBackendIsCalled();
+ }
+
+ @Test
+ public void fetch_gettingFetchCacheFails_doesNotThrowException() throws Exception {
+ when(mockFetchedCache.get())
+ .thenReturn(Tasks.forException(new IOException("Disk read failed.")));
+
+ fetchCallToHttpClientUpdatesClockAndReturnsConfig(firstFetchedContainer);
+
+ assertWithMessage("Fetch() failed when fetch cache could not be read!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+ }
+
+ @Test
+ public void fetch_fetchBackendCallFails_taskThrowsException() throws Exception {
+ when(mockBackendFetchApiClient.fetch(any(), any(), any(), any(), any(), any(), any()))
+ .thenThrow(
+ new FirebaseRemoteConfigClientException("Fetch failed due to an unexpected error."));
+
+ Task fetchTask = fetchHandler.fetch();
+
+ assertThrowsClientException(fetchTask, "unexpected error");
+ }
+
+ @Test
+ public void fetch_noChangeSinceLastFetch_doesNotUpdateCache() throws Exception {
+ setBackendResponseToNoChange(new Date(mockClock.currentTimeMillis()));
+
+ assertWithMessage("Fetch() failed after no changes were returned from backend!")
+ .that(fetchHandler.fetch().isSuccessful())
+ .isTrue();
+
+ verify(mockFetchedCache, never()).put(any());
+ }
+
+ @Test
+ public void fetch_fetchedCachePutFails_taskThrowsException() throws Exception {
+ IOException expectedException = new IOException("Network call failed.");
+ setBackendResponseConfigsTo(firstFetchedContainer);
+ when(mockFetchedCache.put(any())).thenReturn(Tasks.forException(expectedException));
+
+ Task fetchTask = fetchHandler.fetch();
+
+ IOException actualException =
+ assertThrows(IOException.class, () -> fetchTask.getResult(IOException.class));
+ assertThat(actualException).isEqualTo(expectedException);
+ }
+
+ @Test
+ public void fetch_HasNoErrors_everythingWorks() throws Exception {
+ fetchCallToHttpClientUpdatesClockAndReturnsConfig(firstFetchedContainer);
+
+ assertWithMessage("Fetch() failed!").that(fetchHandler.fetch().isSuccessful()).isTrue();
+
+ verify(mockFetchedCache).put(firstFetchedContainer);
+ }
+
+ @Test
+ public void fetch_HasETag_sendsETagAndSavesResponseETag() throws Exception {
+ String requestETag = "Request eTag";
+ String responseETag = "Response eTag";
+ loadETags(requestETag, responseETag);
+ fetchCallToHttpClientUpdatesClockAndReturnsConfig(firstFetchedContainer);
+
+ assertWithMessage("Fetch() failed!").that(fetchHandler.fetch().isSuccessful()).isTrue();
+
+ verifyETags(requestETag, responseETag);
+ }
+
+ @Test
+ public void fetch_HasNoETag_doesNotSendETagAndSavesResponseETag() throws Exception {
+ String responseETag = "Response eTag";
+ loadETags(/*requestETag=*/ null, responseETag);
+ fetchCallToHttpClientUpdatesClockAndReturnsConfig(firstFetchedContainer);
+
+ assertWithMessage("Fetch() failed!").that(fetchHandler.fetch().isSuccessful()).isTrue();
+
+ verifyETags(/*requestETag=*/ null, responseETag);
+ }
+
+ @Test
+ public void fetch_hasAbtExperiments_storesExperiments() throws Exception {
+ ConfigContainer containerWithExperiments =
+ ConfigContainer.newBuilder(firstFetchedContainer)
+ .withAbtExperiments(generateAbtExperiments(/*numExperiments=*/ 5))
+ .build();
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigContainer.class);
+
+ fetchCallToHttpClientUpdatesClockAndReturnsConfig(containerWithExperiments);
+ fetchHandler.fetch();
+
+ verify(mockFetchedCache).put(captor.capture());
+
+ JSONAssert.assertEquals(
+ containerWithExperiments.toString(), captor.getValue().toString(), false);
+ }
+
+ @Test
+ public void fetch_getsThrottledResponseFromServer_backsOffOnSecondCall() throws Exception {
+ fetchCallToBackendThrowsException(HTTP_TOO_MANY_REQUESTS);
+ long backoffDurationInMillis = loadAndGetNextBackoffDuration(/*numFailedFetches=*/ 1);
+
+ FirebaseRemoteConfigFetchThrottledException actualException =
+ getThrottledException(fetchHandler.fetch(/*minimumFetchIntervalInSeconds=*/ 0L));
+
+ assertThat(actualException.getThrottleEndTimeMillis())
+ .isEqualTo(mockClock.currentTimeMillis() + backoffDurationInMillis);
+ }
+
+ @Test
+ public void fetch_getsMultipleThrottledResponsesFromServer_exponentiallyBacksOff()
+ throws Exception {
+ for (int numFetch = 1; numFetch <= BACKOFF_TIME_DURATIONS_IN_MINUTES.length; numFetch++) {
+ fetchCallToBackendThrowsException(HTTP_TOO_MANY_REQUESTS);
+ long backoffDurationInMillis = loadAndGetNextBackoffDuration(numFetch);
+
+ assertThrowsThrottledException(
+ fetchHandler.fetch(/*minimumFetchIntervalInSeconds=*/ 0L),
+ mockClock.currentTimeMillis() + backoffDurationInMillis);
+
+ // Wait long enough for throttling to clear.
+ mockClock.advance(backoffDurationInMillis);
+ }
+ }
+
+ @Test
+ public void fetch_getsMultipleFailedResponsesFromServer_resetsBackoffAfterSuccessfulFetch()
+ throws Exception {
+ callFetchAssertThrottledAndAdvanceClock(HTTP_TOO_MANY_REQUESTS);
+ callFetchAssertThrottledAndAdvanceClock(HTTP_BAD_GATEWAY);
+ callFetchAssertThrottledAndAdvanceClock(HTTP_UNAVAILABLE);
+ callFetchAssertThrottledAndAdvanceClock(HTTP_GATEWAY_TIMEOUT);
+
+ fetchCallToHttpClientReturnsConfigWithCurrentTime(firstFetchedContainer);
+
+ Task fetchTask = fetchHandler.fetch(/*minimumFetchIntervalInSeconds=*/ 0L);
+
+ assertWithMessage("Fetch() failed!").that(fetchTask.isSuccessful()).isTrue();
+
+ BackoffMetadata backoffMetadata = metadataClient.getBackoffMetadata();
+ assertThat(backoffMetadata.getNumFailedFetches()).isEqualTo(NO_FAILED_FETCHES);
+ assertThat(backoffMetadata.getBackoffEndTime()).isEqualTo(NO_BACKOFF_TIME);
+ }
+
+ @Test
+ public void getRandomizedBackoffDuration_callOverMaxTimes_returnsUpToMaxInterval()
+ throws Exception {
+ int backoffDurationsLength = BACKOFF_TIME_DURATIONS_IN_MINUTES.length;
+ for (int numFetch = 1; numFetch <= backoffDurationsLength + 2; numFetch++) {
+ long backoffDurationInterval =
+ MINUTES.toMillis(
+ BACKOFF_TIME_DURATIONS_IN_MINUTES[Math.min(numFetch, backoffDurationsLength) - 1]);
+
+ fetchCallToBackendThrowsException(HTTP_TOO_MANY_REQUESTS);
+ when(mockRandom.nextInt((int) backoffDurationInterval))
+ .thenReturn(new Random().nextInt((int) backoffDurationInterval));
+
+ FirebaseRemoteConfigFetchThrottledException actualException =
+ getThrottledException(fetchHandler.fetch(/*minimumFetchIntervalInSeconds=*/ 0L));
+
+ long actualBackoffDuration =
+ actualException.getThrottleEndTimeMillis() - mockClock.currentTimeMillis();
+ assertThat(actualBackoffDuration)
+ .isAtLeast(backoffDurationInterval - backoffDurationInterval / 2);
+ assertThat(actualBackoffDuration)
+ .isLessThan(backoffDurationInterval + backoffDurationInterval / 2);
+
+ // Wait long enough for throttling to clear.
+ mockClock.advance(actualBackoffDuration);
+ }
+ }
+
+ @Test
+ public void fetch_serverReturnsUnauthorizedCode_throwsServerUnauthenticatedException()
+ throws Exception {
+ // The 401 HTTP Code is mapped from UNAUTHENTICATED in the gRPC world.
+ fetchCallToBackendThrowsException(HTTP_UNAUTHORIZED);
+
+ assertThrowsServerException(
+ fetchHandler.fetch(), HTTP_UNAUTHORIZED, "did not have the required credentials");
+ }
+
+ @Test
+ public void fetch_serverReturnsForbiddenCode_throwsServerUnauthorizedException()
+ throws Exception {
+ fetchCallToBackendThrowsException(HTTP_FORBIDDEN);
+
+ assertThrowsServerException(fetchHandler.fetch(), HTTP_FORBIDDEN, "is not authorized");
+ }
+
+ @Test
+ public void fetch_serverReturnsBadGatewayCode_throwsServerUnavailableException()
+ throws Exception {
+ fetchCallToBackendThrowsException(HTTP_BAD_GATEWAY);
+
+ Task fetchTask = fetchHandler.fetch();
+
+ assertThrowsServerException(fetchTask, HTTP_BAD_GATEWAY, "unavailable");
+ }
+
+ @Test
+ public void fetch_serverReturnsUnavailableCode_throwsServerUnavailableException()
+ throws Exception {
+ fetchCallToBackendThrowsException(HTTP_UNAVAILABLE);
+
+ Task fetchTask = fetchHandler.fetch();
+
+ assertThrowsServerException(fetchTask, HTTP_UNAVAILABLE, "unavailable");
+ }
+
+ @Test
+ public void fetch_serverReturnsGatewayTimeoutCode_throwsServerUnavailableException()
+ throws Exception {
+ fetchCallToBackendThrowsException(HTTP_GATEWAY_TIMEOUT);
+
+ Task fetchTask = fetchHandler.fetch();
+
+ assertThrowsServerException(fetchTask, HTTP_GATEWAY_TIMEOUT, "unavailable");
+ }
+
+ @Test
+ public void fetch_serverReturnsThrottleableErrorTwice_throwsThrottledException()
+ throws Exception {
+ fetchCallToBackendThrowsException(HTTP_UNAVAILABLE);
+ fetchHandler.fetch();
+
+ fetchCallToBackendThrowsException(HTTP_UNAVAILABLE);
+
+ Task