From b1572493560f4993db4d36e1fda3e5d8397ddd89 Mon Sep 17 00:00:00 2001 From: Aziz Date: Thu, 20 Jun 2019 14:43:27 -0700 Subject: [PATCH] Open source firebase remote config and firebase abt --- README.md | 11 +- firebase-abt/firebase-abt.gradle | 72 + firebase-abt/gradle.properties | 2 + firebase-abt/src/main/AndroidManifest.xml | 13 + .../com/google/firebase/abt/AbtException.java | 34 + .../firebase/abt/AbtExperimentInfo.java | 252 ++++ .../firebase/abt/FirebaseABTesting.java | 329 +++++ .../firebase/abt/component/AbtComponent.java | 65 + .../firebase/abt/component/AbtRegistrar.java | 49 + .../firebase/abt/AbtExperimentInfoTest.java | 74 + .../abt/FirebaseABTWithoutAnalyticsTest.java | 124 ++ firebase-remote-config/bandwagoner/.gitignore | 1 + .../bandwagoner/bandwagoner.gradle | 92 ++ .../bandwagoner/proguard-rules.pro | 21 + .../src/androidTest/AndroidManifest.xml | 34 + .../bandwagoner/BandwagonerEspressoTest.java | 148 ++ .../bandwagoner/src/main/AndroidManifest.xml | 52 + .../remoteconfig/bandwagoner/ApiFragment.java | 247 ++++ .../bandwagoner/BandwagonerFragment.java | 30 + .../remoteconfig/bandwagoner/Constants.java | 26 + .../bandwagoner/IdlingResourceManager.java | 36 + .../bandwagoner/MainActivity.java | 91 ++ .../bandwagoner/MainApplication.java | 33 + .../remoteconfig/bandwagoner/TaskHelper.java | 71 + .../bandwagoner/TimeFormatHelper.java | 37 + .../src/main/res/layout/activity_main.xml | 40 + .../main/res/layout/analytics_fragment.xml | 211 +++ .../src/main/res/layout/api_fragment.xml | 178 +++ .../src/main/res/layout/settings_fragment.xml | 169 +++ .../src/main/res/values/.gitignore | 1 + .../src/main/res/values/colors.xml | 22 + .../src/main/res/values/dimens.xml | 23 + .../src/main/res/values/strings.xml | 20 + .../src/main/res/values/styles.xml | 32 + .../firebase-remote-config.gradle | 115 ++ firebase-remote-config/gradle.properties | 20 + .../src/androidTest/AndroidManifest.xml | 30 + .../FirebaseRemoteConfigIntegrationTest.java | 177 +++ .../androidTest/res/xml/frc_bad_defaults.xml | 44 + .../res/xml/frc_empty_defaults.xml | 22 + .../androidTest/res/xml/frc_good_defaults.xml | 31 + .../src/main/AndroidManifest.xml | 34 + .../remoteconfig/FirebaseRemoteConfig.java | 741 ++++++++++ .../FirebaseRemoteConfigClientException.java | 33 + .../FirebaseRemoteConfigException.java | 30 + .../FirebaseRemoteConfigFetchException.java | 35 + ...seRemoteConfigFetchThrottledException.java | 52 + .../FirebaseRemoteConfigInfo.java | 44 + .../FirebaseRemoteConfigServerException.java | 47 + .../FirebaseRemoteConfigSettings.java | 138 ++ .../FirebaseRemoteConfigValue.java | 62 + .../remoteconfig/RemoteConfigComponent.java | 292 ++++ .../remoteconfig/RemoteConfigConstants.java | 101 ++ .../remoteconfig/RemoteConfigRegistrar.java | 61 + .../firebase/remoteconfig/internal/Code.java | 143 ++ .../internal/ConfigCacheClient.java | 287 ++++ .../internal/ConfigContainer.java | 195 +++ .../internal/ConfigFetchHandler.java | 569 ++++++++ .../internal/ConfigFetchHttpClient.java | 384 ++++++ .../internal/ConfigGetParameterHandler.java | 414 ++++++ .../internal/ConfigMetadataClient.java | 269 ++++ .../internal/ConfigStorageClient.java | 137 ++ .../internal/DefaultsXmlParser.java | 132 ++ .../FirebaseRemoteConfigInfoImpl.java | 88 ++ .../FirebaseRemoteConfigValueImpl.java | 121 ++ .../internal/LegacyConfigsHandler.java | 399 ++++++ .../remoteconfig/internal/package-info.java | 19 + .../android/gms/config/proto/config.proto | 402 ++++++ .../gms/config/proto/config_logs.proto | 40 + .../gms/config/proto/config_persistence.proto | 55 + .../mobile/abt/proto/experiment_payload.proto | 109 ++ .../src/test/AndroidManifest.xml | 34 + .../android/gms/common/util/MockClock.java | 73 + .../common/internal/ShadowPreconditions.java | 47 + .../remoteconfig/AbtExperimentHelper.java | 57 + .../FirebaseRemoteConfigSettingsTest.java | 41 + .../FirebaseRemoteConfigTest.java | 1195 +++++++++++++++++ .../MockAnalyticsConnectorRegistrar.java | 40 + .../MockFirebaseAbtRegistrar.java | 43 + .../MockFirebaseIidRegistrar.java | 40 + .../RemoteConfigComponentTest.java | 242 ++++ .../internal/ConfigCacheClientTest.java | 352 +++++ .../internal/ConfigFetchHandlerTest.java | 967 +++++++++++++ .../internal/ConfigFetchHttpClientTest.java | 319 +++++ .../ConfigGetParameterHandlerTest.java | 736 ++++++++++ .../internal/ConfigMetadataClientTest.java | 318 +++++ .../internal/ConfigStorageClientTest.java | 125 ++ .../internal/FakeHttpURLConnection.java | 104 ++ .../FirebaseRemoteConfigValueImplTest.java | 144 ++ .../internal/LegacyConfigsHandlerTest.java | 399 ++++++ .../remoteconfig/res/xml/frc_bad_defaults.xml | 43 + .../res/xml/frc_empty_defaults.xml | 21 + .../res/xml/frc_good_defaults.xml | 33 + .../remoteconfig/testutil/Assert.java | 952 +++++++++++++ .../testutil/ThrowingRunnable.java | 26 + .../src/test/resources/xml/frc_foo.xml | 36 + subprojects.cfg | 21 +- 97 files changed, 14909 insertions(+), 11 deletions(-) create mode 100644 firebase-abt/firebase-abt.gradle create mode 100644 firebase-abt/gradle.properties create mode 100644 firebase-abt/src/main/AndroidManifest.xml create mode 100644 firebase-abt/src/main/java/com/google/firebase/abt/AbtException.java create mode 100644 firebase-abt/src/main/java/com/google/firebase/abt/AbtExperimentInfo.java create mode 100644 firebase-abt/src/main/java/com/google/firebase/abt/FirebaseABTesting.java create mode 100644 firebase-abt/src/main/java/com/google/firebase/abt/component/AbtComponent.java create mode 100644 firebase-abt/src/main/java/com/google/firebase/abt/component/AbtRegistrar.java create mode 100644 firebase-abt/src/test/java/com/google/firebase/abt/AbtExperimentInfoTest.java create mode 100644 firebase-abt/src/test/java/com/google/firebase/abt/FirebaseABTWithoutAnalyticsTest.java create mode 100644 firebase-remote-config/bandwagoner/.gitignore create mode 100644 firebase-remote-config/bandwagoner/bandwagoner.gradle create mode 100644 firebase-remote-config/bandwagoner/proguard-rules.pro create mode 100644 firebase-remote-config/bandwagoner/src/androidTest/AndroidManifest.xml create mode 100644 firebase-remote-config/bandwagoner/src/androidTest/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerEspressoTest.java create mode 100644 firebase-remote-config/bandwagoner/src/main/AndroidManifest.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/ApiFragment.java create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/BandwagonerFragment.java create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/Constants.java create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/IdlingResourceManager.java create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainActivity.java create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/MainApplication.java create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TaskHelper.java create mode 100644 firebase-remote-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/TimeFormatHelper.java create mode 100644 firebase-remote-config/bandwagoner/src/main/res/layout/activity_main.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/res/layout/analytics_fragment.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/res/layout/api_fragment.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/res/layout/settings_fragment.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/res/values/.gitignore create mode 100644 firebase-remote-config/bandwagoner/src/main/res/values/colors.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/res/values/dimens.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/res/values/strings.xml create mode 100644 firebase-remote-config/bandwagoner/src/main/res/values/styles.xml create mode 100644 firebase-remote-config/firebase-remote-config.gradle create mode 100644 firebase-remote-config/gradle.properties create mode 100644 firebase-remote-config/src/androidTest/AndroidManifest.xml create mode 100644 firebase-remote-config/src/androidTest/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigIntegrationTest.java create mode 100644 firebase-remote-config/src/androidTest/res/xml/frc_bad_defaults.xml create mode 100644 firebase-remote-config/src/androidTest/res/xml/frc_empty_defaults.xml create mode 100644 firebase-remote-config/src/androidTest/res/xml/frc_good_defaults.xml create mode 100644 firebase-remote-config/src/main/AndroidManifest.xml create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientException.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigException.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchException.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigFetchThrottledException.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigInfo.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettings.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigValue.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigRegistrar.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/Code.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigCacheClient.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigContainer.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigGetParameterHandler.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigMetadataClient.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigStorageClient.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/DefaultsXmlParser.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigInfoImpl.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigValueImpl.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/LegacyConfigsHandler.java create mode 100644 firebase-remote-config/src/main/java/com/google/firebase/remoteconfig/internal/package-info.java create mode 100644 firebase-remote-config/src/proto/com/google/android/gms/config/proto/config.proto create mode 100644 firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_logs.proto create mode 100644 firebase-remote-config/src/proto/com/google/android/gms/config/proto/config_persistence.proto create mode 100644 firebase-remote-config/src/proto/com/google/protos/developers/mobile/abt/proto/experiment_payload.proto create mode 100644 firebase-remote-config/src/test/AndroidManifest.xml create mode 100644 firebase-remote-config/src/test/java/com/google/android/gms/common/util/MockClock.java create mode 100644 firebase-remote-config/src/test/java/com/google/android/gms/shadows/common/internal/ShadowPreconditions.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/AbtExperimentHelper.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigSettingsTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockAnalyticsConnectorRegistrar.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseAbtRegistrar.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/MockFirebaseIidRegistrar.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClientTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigGetParameterHandlerTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigMetadataClientTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigStorageClientTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/FakeHttpURLConnection.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/FirebaseRemoteConfigValueImplTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/internal/LegacyConfigsHandlerTest.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/res/xml/frc_bad_defaults.xml create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/res/xml/frc_empty_defaults.xml create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/res/xml/frc_good_defaults.xml create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/testutil/Assert.java create mode 100644 firebase-remote-config/src/test/java/com/google/firebase/remoteconfig/testutil/ThrowingRunnable.java create mode 100644 firebase-remote-config/src/test/resources/xml/frc_foo.xml 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: + * + *

    + *
  1. Any experiment in the origin's list that is not in {@code replacementExperiments} is + * removed. + *
  2. 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 @@ + + + + + + + + + + + + + + +