diff --git a/.circleci/config.yml b/.circleci/config.yml
index 69c33ca6..dc947498 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,105 +1,140 @@
version: 2.1
-orbs:
- android: circleci/android@1.0
-jobs:
- build:
- executor:
- name: android/android-machine
- resource-class: large
+orbs:
+ android: circleci/android@2.1.2
+
+workflows:
+ test:
+ jobs:
+ - package
+ - unit-tests
+ - instrumented-tests:
+ matrix:
+ parameters:
+ api-level:
+ # - 21 - TODO: EasyMock makes our tests fail
+ - 25
+ # - 29 - TODO: offline tests fail
+ # - 31 - https://github.com/CircleCI-Public/android-orb/issues/52
+ - contract-tests:
+ matrix:
+ parameters:
+ api-level:
+ - 21
+ - 25
+ - 30
+
+commands:
+ check-emulator-available:
steps:
- - checkout
-
- # Create and start emulator
- - android/create-avd:
- avd-name: ci-android-avd
- system-image: system-images;android-25;default;x86
- install: true
-
- - android/start-emulator:
- avd-name: ci-android-avd
- wait-for-emulator: false
-
- # Perform tasks we can do while waiting for emulator to start
-
- # Restore caches
- - android/restore-gradle-cache
- - android/restore-build-cache
-
- - run:
- name: Build local tests
- command: ./gradlew :launchdarkly-android-client-sdk:assembleDebugUnitTest
-
- - run:
- name: Build connected tests
- command: ./gradlew :launchdarkly-android-client-sdk:assembleDebugAndroidTest
-
- # Save caches
- - android/save-build-cache
- - android/save-gradle-cache
-
- # Run unit tests that do not require the emulator
- - run:
- name: Run local tests
- command: ./gradlew :launchdarkly-android-client-sdk:testDebugUnitTest
-
- # Now wait for emulator to fully start
- - android/wait-for-emulator
-
# Additional validation that emulator is fully started and will accept adb
# commands
- run:
- name: Validate retrieving emulator SDK version
+ name: Check emulator is available
command: |
while ! adb shell getprop ro.build.version.sdk; do
sleep 1
done
- # Necessary for test mocking to disable network access through WiFi
- # configuration, allowing testing of behavior when device is offline
- - run:
- name: Disable mobile data for network tests
- command: adb shell svc data disable
-
- - run:
- name: Fetch logcat and props
- background: true
- command: |
- mkdir -p ~/artifacts
- adb shell getprop | tee -a ~/artifacts/props.txt
- adb logcat | tee -a ~/artifacts/logcat.txt
-
- - android/run-tests:
- max-tries: 1
-
- - android/kill-emulators
-
+jobs:
+ package:
+ docker:
+ - image: cimg/android:2022.06
+ steps:
+ - checkout
+ - android/restore-gradle-cache:
+ cache-prefix: package
- run:
name: Validate package creation
command: ./gradlew packageRelease --console=plain -PdisablePreDex
-
- run:
name: Validate Javadoc
command: ./gradlew Javadoc
-
- - run:
- name: Save test results
- command: |
- mkdir -p ~/test-results
- cp -r ./launchdarkly-android-client-sdk/build/test-results/testDebugUnitTest ~/test-results/
- cp -r ./launchdarkly-android-client-sdk/build/outputs/androidTest-results/* ~/test-results/
- when: always
+ - store_artifacts:
+ path: ./launchdarkly-android-client-sdk/build/reports/
+ - android/save-gradle-cache:
+ cache-prefix: package
+ unit-tests:
+ docker:
+ - image: cimg/android:2022.06
+ steps:
+ - checkout
+ - android/restore-gradle-cache:
+ cache-prefix: unit
- run:
- name: Save artifacts
- command: |
- mv ./launchdarkly-android-client-sdk/build/test-results ~/artifacts
- mv ./launchdarkly-android-client-sdk/build/reports ~/artifacts
- mv ./launchdarkly-android-client-sdk/build/outputs ~/artifacts
- mv ./launchdarkly-android-client-sdk/build/docs ~/artifacts
- when: always
-
+ name: Build unit tests
+ command: ./gradlew :launchdarkly-android-client-sdk:assembleDebugUnitTest
+ - run:
+ name: Run local tests
+ command: ./gradlew :launchdarkly-android-client-sdk:testDebugUnitTest
+ - android/save-gradle-cache:
+ cache-prefix: unit
- store_test_results:
- path: ~/test-results
- - store_artifacts:
- path: ~/artifacts
+ path: ./launchdarkly-android-client-sdk/build/test-results/testDebugUnitTest
+
+ instrumented-tests:
+ parameters:
+ api-level:
+ type: integer
+
+ executor:
+ name: android/android-machine
+ tag: 202102-01
+ resource-class: large
+
+ steps:
+ - checkout
+ - android/start-emulator-and-run-tests:
+ avd-name: ci-android-avd
+ system-image: system-images;android-<< parameters.api-level >>;default;x86_64
+ run-logcat: true
+ restore-gradle-cache-prefix: instrumented-v1
+ post-emulator-launch-assemble-command: ./gradlew :launchdarkly-android-client-sdk:assembleDebugAndroidTest
+ pre-run-tests-steps:
+ - check-emulator-available
+ # Necessary for test mocking to disable network access through WiFi
+ # configuration, allowing testing of behavior when device is offline
+ - run:
+ name: Disable mobile data for network tests
+ command: adb shell svc data disable
+ test-command: ./gradlew connectedDebugAndroidTest
+ max-tries: 1
+ post-run-tests-steps:
+ - store_test_results:
+ path: ./launchdarkly-android-client-sdk/build/outputs/androidTest-results/
+ - store_artifacts:
+ path: ./launchdarkly-android-client-sdk/build/reports/
+
+ contract-tests:
+ parameters:
+ api-level:
+ type: integer
+
+ executor:
+ name: android/android-machine
+ tag: 202102-01
+ resource-class: large
+
+ environment:
+ TEST_HARNESS_PARAMS: -junit /home/circleci/junit/contract-tests-junit.xml
+
+ steps:
+ - checkout
+ - android/start-emulator-and-run-tests:
+ avd-name: ci-android-avd
+ system-image: system-images;android-<< parameters.api-level >>;default;x86_64
+ run-logcat: true
+ restore-gradle-cache-prefix: contract-v1
+ post-emulator-launch-assemble-command: make build-contract-tests
+ pre-run-tests-steps:
+ - check-emulator-available
+ - run: mkdir -p ~/junit
+ - run: make start-contract-test-service
+ test-command: make run-contract-tests
+ max-tries: 1
+ post-run-tests-steps:
+ - store_test_results:
+ path: ~/junit
+ - store_artifacts:
+ path: ./launchdarkly-android-client-sdk/build/reports/
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..54c91615
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+build-contract-tests:
+ @cd contract-tests && ../gradlew --no-daemon -s assembleDebug -PdisablePreDex
+
+start-emulator:
+ @scripts/start-emulator.sh
+
+start-contract-test-service:
+ @scripts/start-test-service.sh
+
+run-contract-tests:
+ @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v1.0.0/downloader/run.sh \
+ | VERSION=v1 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -skip-from testharness-suppressions.txt -debug $(TEST_HARNESS_PARAMS)" sh
+
+contract-tests: build-contract-tests start-emulator start-contract-test-service run-contract-tests
+
+.PHONY: build-contract-tests start-emulator start-contract-test-service run-contract-tests contract-tests
diff --git a/build.gradle b/build.gradle
index 974db2de..60b702ad 100644
--- a/build.gradle
+++ b/build.gradle
@@ -62,10 +62,7 @@ subprojects {
nexusPublishing {
packageGroup = "com.launchdarkly"
repositories {
- sonatype {
- nexusUrl.set(uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/"))
- snapshotRepositoryUrl.set(uri("https://oss.sonatype.org/content/repositories/snapshots/"))
- }
+ sonatype()
}
transitionCheckOptions {
diff --git a/contract-tests/README.md b/contract-tests/README.md
new file mode 100644
index 00000000..14235543
--- /dev/null
+++ b/contract-tests/README.md
@@ -0,0 +1,66 @@
+# contract-tests service
+
+A test service that runs inside an Android emulator, that follows the [contract-tests specification](https://github.com/launchdarkly/sdk-test-harness/blob/main/docs/service_spec.md).
+
+## Running locally
+
+You can run the contract tests locally via the `Makefile`, which will:
+* start up an emulator
+* run the contract-test service APK in the emulator
+* forward port 8001 (using `adb forward`) so that we can hit the HTTP server running inside the emulator
+ * important: this part is something you can't do with Android Studio, which is why I don't use Android Studio to run the contract tests
+* run the [SDK test harness](https://github.com/launchdarkly/sdk-test-harness) against the service
+
+To run the contract tests and all its dependencies at once:
+```sh
+$ make contract-tests
+```
+
+For a pseudo-interactive dev flow, I use [watchexec](https://github.com/watchexec/watchexec):
+```sh
+# start up the emulator once
+$ make start-emulator
+# anytime code changes, rebuild and restart the test service
+$ watchexec make build-contract-tests start-contract-test-service
+# meanwhile, run the test harness whenever you want
+$ make run-contract-tests
+```
+
+For this to work, there are some prerequisites that must be on your machine:
+
+### Install Android Studio
+
+Even though we won't use it to run the contract tests, installing Android Studio will give us all the ingredients necessary for things to work.
+
+### [Android command-line tools](https://developer.android.com/studio/command-line)
+
+The following programs should already be present thanks to Android Studio, but need to be
+available on your `$PATH` to be run at the command line:
+
+* `adb`
+* `avdmanager`
+* `emulator`
+* `sdkmanager`
+
+This is what my `~/.zshrc` looks like, to make that happen:
+
+```sh
+export PATH="/Users/alex/Library/Android/sdk/cmdline-tools/latest/bin:$PATH" # avdmanager, sdkmanager
+export PATH="/Users/alex/Library/Android/sdk/platform-tools/:$PATH" # adb
+export PATH="/Users/alex/Library/Android/sdk/emulator/:$PATH" # emulator
+```
+
+### System images
+
+The contract-tests scripts expect at least one Android system image (i.e. an image of Android OS that can be run as an emulator)
+to be installed. If you've done any Android development already, there should be at least one that works. `start-emulator.sh`
+will automatically pick the latest applicable image when creating the emulator.
+
+You can verify by calling `sdkmanager --list_installed`. You're looking for something like:
+```
+ Path | Version | Description | Location
+ ------- | ------- | ------- | -------
+ ... ... ... ...
+ system-images;android-32;google_apis;arm64-v8a | 3 | Google APIs ARM 64 v8a System Image | system-images/android-32/google_apis/arm64-v8a
+```
+but swap `arm64-v8a` with `x86` if you aren't on an M1 machine.
\ No newline at end of file
diff --git a/contract-tests/build.gradle b/contract-tests/build.gradle
new file mode 100644
index 00000000..c377f0d5
--- /dev/null
+++ b/contract-tests/build.gradle
@@ -0,0 +1,35 @@
+plugins {
+ id("com.android.application")
+ // make sure this line comes *after* you apply the Android plugin
+ id("com.getkeepsafe.dexcount")
+}
+
+android {
+ compileSdkVersion(30)
+ buildToolsVersion = "30.0.3"
+
+ defaultConfig {
+ applicationId = "com.launchdarkly.sdktest"
+ minSdkVersion(21)
+ targetSdkVersion(30)
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled = true
+ proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
+ }
+ }
+}
+
+dependencies {
+ implementation("com.jakewharton.timber:timber:5.0.1")
+ // https://mvnrepository.com/artifact/org.nanohttpd/nanohttpd
+ implementation("org.nanohttpd:nanohttpd:2.3.1")
+ implementation("com.google.code.gson:gson:2.8.9")
+ implementation(project(":launchdarkly-android-client-sdk"))
+ // Comment the previous line and uncomment this one to depend on the published artifact:
+ //implementation("com.launchdarkly:launchdarkly-android-client-sdk:3.1.5")
+}
diff --git a/contract-tests/src/main/AndroidManifest.xml b/contract-tests/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..77c21cdd
--- /dev/null
+++ b/contract-tests/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdk/android/LDClientControl.java b/contract-tests/src/main/java/com/launchdarkly/sdk/android/LDClientControl.java
new file mode 100644
index 00000000..6dcc03d9
--- /dev/null
+++ b/contract-tests/src/main/java/com/launchdarkly/sdk/android/LDClientControl.java
@@ -0,0 +1,21 @@
+package com.launchdarkly.sdk.android;
+
+import android.app.Application;
+
+/**
+ * A class that is only here to allow the contract test service to instantiate multiple
+ * LDClients. Contains one static method `resetInstances()` that resets the static global
+ * state in LDClient.
+ */
+public class LDClientControl {
+
+ /**
+ * Resets the global state that prevents creating more than one LDClient.
+ *
+ * This is a workaround that allows testing the Android SDK from a long-lived
+ * test service.
+ */
+ public static void resetInstances() {
+ LDClient.instances = null;
+ }
+}
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Config.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Config.java
new file mode 100644
index 00000000..95ac71f3
--- /dev/null
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Config.java
@@ -0,0 +1,22 @@
+package com.launchdarkly.sdktest;
+
+import android.os.Bundle;
+
+/**
+ * Contains the configuration values passed to the test service at runtime.
+ * Currently only contains the port number.
+ */
+public class Config {
+ public int port = 35001;
+
+ public static Config fromArgs(Bundle params) {
+ Config ret = new Config();
+
+ String portStr = params == null ? null : params.getString("PORT");
+ if (portStr != null) {
+ ret.port = Integer.parseInt(portStr);
+ }
+
+ return ret;
+ }
+}
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/MainActivity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/MainActivity.java
new file mode 100644
index 00000000..3e2573f2
--- /dev/null
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/MainActivity.java
@@ -0,0 +1,53 @@
+package com.launchdarkly.sdktest;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.TextView;
+
+import java.io.IOException;
+
+import timber.log.Timber;
+
+public class MainActivity extends Activity {
+
+ private Config config;
+ private TestService server;
+ private Timber.DebugTree debugTree;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+ config = Config.fromArgs(getIntent().getExtras());
+
+ TextView textIpaddr = findViewById(R.id.ipaddr);
+ textIpaddr.setText("Contract test service running on port " + config.port);
+
+ if (Timber.treeCount() == 0) {
+ debugTree = new Timber.DebugTree();
+ Timber.plant(debugTree);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ Timber.w("Restarting test service on port " + config.port);
+ server = new TestService(getApplication());
+ if (!server.isAlive()) {
+ try {
+ server.start();
+ } catch (IOException e) {
+ Timber.e(e, "Error starting server");
+ }
+ }
+ }
+}
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java
new file mode 100644
index 00000000..23ed8695
--- /dev/null
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java
@@ -0,0 +1,119 @@
+package com.launchdarkly.sdktest;
+
+import com.launchdarkly.sdk.EvaluationReason;
+import com.launchdarkly.sdk.LDUser;
+import com.launchdarkly.sdk.LDValue;
+
+import java.util.Map;
+
+/**
+ * These classes are all the data we might send or receive from the contract test harness.
+ * We use Gson to magically serialize these classes to/from JSON.
+ */
+public abstract class Representations {
+ public static class Status {
+ String name;
+ String[] capabilities;
+ String clientVersion;
+ }
+
+ public static class CreateInstanceParams {
+ SdkConfigParams configuration;
+ String tag;
+ }
+
+ public static class SdkConfigParams {
+ String credential;
+ Long startWaitTimeMs;
+ boolean initCanFail;
+ SdkConfigStreamParams streaming;
+ SdkConfigPollParams polling;
+ SdkConfigEventParams events;
+ SdkConfigTagParams tags;
+ SdkConfigClientSideParams clientSide;
+ }
+
+ public static class SdkConfigStreamParams {
+ String baseUri;
+ long initialRetryDelayMs;
+ }
+
+ public static class SdkConfigPollParams {
+ String baseUri;
+ Long pollIntervalMs;
+ }
+
+ public static class SdkConfigEventParams {
+ String baseUri;
+ boolean allAttributesPrivate;
+ int capacity;
+ boolean enableDiagnostics;
+ String[] globalPrivateAttributes;
+ Long flushIntervalMs;
+ boolean inlineUsers;
+ }
+
+ public static class SdkConfigTagParams {
+ String applicationId;
+ String applicationVersion;
+ }
+
+ public static class SdkConfigClientSideParams {
+ LDUser initialUser;
+ boolean autoAliasingOptOut;
+ boolean evaluationReasons;
+ boolean useReport;
+ }
+
+ public static class CommandParams {
+ String command;
+ EvaluateFlagParams evaluate;
+ EvaluateAllFlagsParams evaluateAll;
+ IdentifyEventParams identifyEvent;
+ CustomEventParams customEvent;
+ AliasEventParams aliasEvent;
+ }
+
+ public static class EvaluateFlagParams {
+ String flagKey;
+ LDUser user;
+ String valueType;
+ LDValue value;
+ LDValue defaultValue;
+ boolean detail;
+ }
+
+ public static class EvaluateFlagResponse {
+ LDValue value;
+ Integer variationIndex;
+ EvaluationReason reason;
+ }
+
+ public static class EvaluateAllFlagsParams {
+ LDUser user;
+ boolean clientSideOnly;
+ boolean detailsOnlyForTrackedFlags;
+ boolean withReasons;
+ }
+
+ public static class EvaluateAllFlagsResponse {
+ Map state;
+ }
+
+ public static class IdentifyEventParams {
+ LDUser user;
+ }
+
+ public static class CustomEventParams {
+ String eventKey;
+ LDUser user;
+ LDValue data;
+ boolean omitNullData;
+ Double metricValue;
+ }
+
+ public static class AliasEventParams {
+ LDUser user;
+ LDUser previousUser;
+ }
+}
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Router.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Router.java
new file mode 100644
index 00000000..4b89e4a0
--- /dev/null
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Router.java
@@ -0,0 +1,68 @@
+package com.launchdarkly.sdktest;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import fi.iki.elonen.NanoHTTPD;
+
+import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse;
+
+/**
+ * An HTTP router inspired by com.launchdarkly.testhelpers.httptest.SimpleRouter, that
+ * we can use with NanoHTTPD.
+ * https://github.com/launchdarkly/java-test-helpers/blob/main/src/main/java/com/launchdarkly/testhelpers/httptest/SimpleRouter.java
+ */
+public class Router {
+ public interface Handler {
+ NanoHTTPD.Response apply(List pathParams, String body) throws Exception;
+ }
+
+ private final List routes = new ArrayList<>();
+
+ private static class Route {
+ final String method;
+ final Pattern pattern;
+ final Handler handler;
+
+ Route(String method, Pattern pattern, Handler handler) {
+ this.method = method;
+ this.pattern = pattern;
+ this.handler = handler;
+ }
+ }
+
+ public void add(String method, String path, Handler handler) {
+ addRegex(method, Pattern.compile(Pattern.quote(path)), handler);
+ }
+
+ public void addRegex(String method, Pattern regex, Handler handler) {
+ routes.add(new Route(method, regex, handler));
+ }
+
+ public NanoHTTPD.Response route(String method, String path, String body) throws Exception {
+ boolean matchedPath = false;
+ for (Route r: routes) {
+ Matcher m = r.pattern.matcher(path);
+ if (m.matches()) {
+ matchedPath = true;
+ if (r.method != null && !r.method.equalsIgnoreCase(method)) {
+ continue;
+ }
+ ArrayList params = new ArrayList<>();
+ if (m.groupCount() > 0) {
+ for (int i = 1; i <= m.groupCount(); i++) {
+ params.add(m.group(i));
+ }
+ }
+ return r.handler.apply(params, body);
+ }
+ }
+ if (matchedPath) {
+ return newFixedLengthResponse(NanoHTTPD.Response.Status.METHOD_NOT_ALLOWED, NanoHTTPD.MIME_PLAINTEXT, "Method Not Allowed\n");
+ } else {
+ return newFixedLengthResponse(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not Found\n");
+ }
+ }
+}
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java
new file mode 100644
index 00000000..ec091443
--- /dev/null
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java
@@ -0,0 +1,262 @@
+package com.launchdarkly.sdktest;
+
+import com.launchdarkly.sdk.EvaluationDetail;
+import com.launchdarkly.sdk.LDValue;
+import com.launchdarkly.sdk.UserAttribute;
+import com.launchdarkly.sdk.android.LaunchDarklyException;
+import com.launchdarkly.sdk.android.LDClient;
+import com.launchdarkly.sdk.android.LDConfig;
+import com.launchdarkly.sdk.android.LDClientControl;
+
+import com.launchdarkly.sdktest.Representations.AliasEventParams;
+import com.launchdarkly.sdktest.Representations.CommandParams;
+import com.launchdarkly.sdktest.Representations.CreateInstanceParams;
+import com.launchdarkly.sdktest.Representations.CustomEventParams;
+import com.launchdarkly.sdktest.Representations.EvaluateAllFlagsParams;
+import com.launchdarkly.sdktest.Representations.EvaluateAllFlagsResponse;
+import com.launchdarkly.sdktest.Representations.EvaluateFlagParams;
+import com.launchdarkly.sdktest.Representations.EvaluateFlagResponse;
+import com.launchdarkly.sdktest.Representations.IdentifyEventParams;
+import com.launchdarkly.sdktest.Representations.SdkConfigParams;
+
+import android.app.Application;
+import android.net.Uri;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import timber.log.Timber;
+
+/**
+ * This class implements all the client-level testing protocols defined in
+ * the contract tests service specification, such as executing commands,
+ * and configuring / initializing a new client.
+ *
+ * https://github.com/launchdarkly/sdk-test-harness/blob/main/docs/service_spec.md
+ */
+public class SdkClientEntity {
+ private final LDClient client;
+
+ public SdkClientEntity(Application application, CreateInstanceParams params) {
+ Timber.i("Creating client for %s", params.tag);
+ LDClientControl.resetInstances();
+ Timber.i("Reset global state to allow for another client");
+ LDConfig config = buildSdkConfig(params.configuration);
+ // Each new client will plant a new Timber tree, so we uproot any existing ones
+ // to avoid spamming stdout with duplicate log lines
+ Timber.uprootAll();
+ long startWaitMs = params.configuration.startWaitTimeMs != null ?
+ params.configuration.startWaitTimeMs.longValue() : 5000;
+ Future initFuture = LDClient.init(
+ application,
+ config,
+ params.configuration.clientSide.initialUser);
+ // Try to initialize client, but if it fails, keep going in case the test harness wants us to
+ // work with an uninitialized client
+ try {
+ initFuture.get(startWaitMs, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException | ExecutionException e) {
+ Timber.e(e, "Exception during Client initialization");
+ } catch (TimeoutException e) {
+ Timber.w("Client did not successfully initialize within %s ms. It could be taking longer than expected to start up", startWaitMs);
+ }
+ try {
+ this.client = LDClient.get();
+ if (!client.isInitialized() && !params.configuration.initCanFail) {
+ // If `initCanFail` is true, we can proceed with an uninitialized client
+ throw new RuntimeException("client initialization failed or timed out");
+ }
+ } catch (LaunchDarklyException e) {
+ Timber.e(e, "Exception when initializing LDClient");
+ throw new RuntimeException("Exception when initializing LDClient", e);
+ }
+ }
+
+ public Object doCommand(CommandParams params) throws TestService.BadRequestException {
+ Timber.i("Test harness sent command: %s", TestService.gson.toJson(params));
+ switch (params.command) {
+ case "evaluate":
+ return doEvaluateFlag(params.evaluate);
+ case "evaluateAll":
+ return doEvaluateAll(params.evaluateAll);
+ case "identifyEvent":
+ doIdentifyEvent(params.identifyEvent);
+ return null;
+ case "customEvent":
+ doCustomEvent(params.customEvent);
+ return null;
+ case "aliasEvent":
+ doAliasEvent(params.aliasEvent);
+ return null;
+ case "flushEvents":
+ client.flush();
+ return null;
+ default:
+ throw new TestService.BadRequestException("unknown command: " + params.command);
+ }
+ }
+
+ public EvaluateFlagResponse doEvaluateFlag(EvaluateFlagParams params) {
+ EvaluateFlagResponse resp = new EvaluateFlagResponse();
+ if (params.detail) {
+ EvaluationDetail> genericResult;
+ switch (params.valueType) {
+ case "bool":
+ EvaluationDetail boolResult = client.boolVariationDetail(params.flagKey,
+ params.defaultValue.booleanValue());
+ resp.value = LDValue.of(boolResult.getValue());
+ genericResult = boolResult;
+ break;
+ case "int":
+ EvaluationDetail intResult = client.intVariationDetail(params.flagKey,
+ params.defaultValue.intValue());
+ resp.value = LDValue.of(intResult.getValue());
+ genericResult = intResult;
+ break;
+ case "double":
+ EvaluationDetail doubleResult = client.doubleVariationDetail(params.flagKey,
+ params.defaultValue.doubleValue());
+ resp.value = LDValue.of(doubleResult.getValue());
+ genericResult = doubleResult;
+ break;
+ case "string":
+ EvaluationDetail stringResult = client.stringVariationDetail(params.flagKey,
+ params.defaultValue.stringValue());
+ resp.value = LDValue.of(stringResult.getValue());
+ genericResult = stringResult;
+ break;
+ default:
+ EvaluationDetail anyResult = client.jsonValueVariationDetail(params.flagKey,
+ params.defaultValue);
+ resp.value = anyResult.getValue();
+ genericResult = anyResult;
+ break;
+ }
+ resp.variationIndex = genericResult.getVariationIndex() == EvaluationDetail.NO_VARIATION ?
+ null : Integer.valueOf(genericResult.getVariationIndex());
+ resp.reason = genericResult.getReason();
+ } else {
+ switch (params.valueType) {
+ case "bool":
+ resp.value = LDValue.of(client.boolVariation(params.flagKey, params.defaultValue.booleanValue()));
+ break;
+ case "int":
+ resp.value = LDValue.of(client.intVariation(params.flagKey, params.defaultValue.intValue()));
+ break;
+ case "double":
+ resp.value = LDValue.of(client.doubleVariation(params.flagKey, params.defaultValue.doubleValue()));
+ break;
+ case "string":
+ resp.value = LDValue.of(client.stringVariation(params.flagKey, params.defaultValue.stringValue()));
+ break;
+ default:
+ resp.value = client.jsonValueVariation(params.flagKey, params.defaultValue);
+ break;
+ }
+ }
+ return resp;
+ }
+
+ private EvaluateAllFlagsResponse doEvaluateAll(EvaluateAllFlagsParams params) {
+ Map state = client.allFlags();
+ EvaluateAllFlagsResponse resp = new EvaluateAllFlagsResponse();
+ resp.state = state;
+ return resp;
+ }
+
+ private void doIdentifyEvent(IdentifyEventParams params) {
+ client.identify(params.user);
+ }
+
+ private void doCustomEvent(CustomEventParams params) {
+ if ((params.data == null || params.data.isNull()) && params.omitNullData && params.metricValue == null) {
+ client.track(params.eventKey);
+ } else if (params.metricValue == null) {
+ client.trackData(params.eventKey, params.data);
+ } else {
+ client.trackMetric(params.eventKey, params.data, params.metricValue.doubleValue());
+ }
+ }
+
+ private void doAliasEvent(AliasEventParams params) {
+ client.alias(params.user, params.previousUser);
+ }
+
+ private LDConfig buildSdkConfig(SdkConfigParams params) {
+ LDConfig.Builder builder = new LDConfig.Builder();
+ builder.mobileKey(params.credential);
+
+ if (params.streaming != null) {
+ builder.stream(true);
+ if (params.streaming.baseUri != null) {
+ builder.streamUri(Uri.parse(params.streaming.baseUri));
+ }
+ // TODO: initialRetryDelayMs?
+ }
+
+ // The only time we should turn _off_ streaming is if polling is configured but NOT streaming
+ if (params.streaming == null && params.polling != null) {
+ builder.stream(false);
+ }
+
+ if (params.polling != null) {
+ if (params.polling.baseUri != null) {
+ builder.pollUri(Uri.parse(params.polling.baseUri));
+ }
+ if (params.polling.pollIntervalMs != null) {
+ builder.backgroundPollingIntervalMillis(params.polling.pollIntervalMs.intValue());
+ }
+ }
+
+ if (params.events != null) {
+ builder.diagnosticOptOut(!params.events.enableDiagnostics);
+ builder.inlineUsersInEvents(params.events.inlineUsers);
+
+ if (params.events.baseUri != null) {
+ builder.eventsUri(Uri.parse(params.events.baseUri));
+ }
+ if (params.events.capacity > 0) {
+ builder.eventsCapacity(params.events.capacity);
+ }
+ if (params.events.flushIntervalMs != null) {
+ builder.eventsFlushIntervalMillis(params.events.flushIntervalMs.intValue());
+ }
+ if (params.events.allAttributesPrivate) {
+ builder.allAttributesPrivate();
+ }
+ if (params.events.flushIntervalMs != null) {
+ builder.eventsFlushIntervalMillis(params.events.flushIntervalMs.intValue());
+ }
+ if (params.events.globalPrivateAttributes != null) {
+ String[] attrNames = params.events.globalPrivateAttributes;
+ List privateAttributes = new ArrayList<>();
+ for (String a : attrNames) {
+ privateAttributes.add(UserAttribute.forName(a));
+ }
+ builder.privateAttributes((UserAttribute[]) privateAttributes.toArray(new UserAttribute[]{}));
+ }
+ }
+ // TODO: disable events if no params.events
+ builder.autoAliasingOptOut(params.clientSide.autoAliasingOptOut);
+ builder.evaluationReasons(params.clientSide.evaluationReasons);
+ builder.useReport(params.clientSide.useReport);
+
+ return builder.build();
+ }
+
+ public void close() {
+ try {
+ client.close();
+ Timber.i("Closed LDClient");
+ } catch (IOException e) {
+ Timber.e(e, "Unexpected error closing client");
+ throw new RuntimeException("Unexpected error closing client", e);
+ }
+ }
+}
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java
new file mode 100644
index 00000000..7b12a1b2
--- /dev/null
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java
@@ -0,0 +1,139 @@
+package com.launchdarkly.sdktest;
+
+import android.app.Application;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonSyntaxException;
+import com.launchdarkly.sdktest.Representations.CommandParams;
+import com.launchdarkly.sdktest.Representations.CreateInstanceParams;
+import com.launchdarkly.sdktest.Representations.Status;
+import com.launchdarkly.sdk.android.BuildConfig;
+import com.launchdarkly.sdk.json.LDGson;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+
+import fi.iki.elonen.NanoHTTPD;
+
+import timber.log.Timber;
+
+public class TestService extends NanoHTTPD {
+ private static final int PORT = 8001;
+ private static final String[] CAPABILITIES = new String[]{
+ "client-side",
+ "mobile",
+ "singleton",
+ "strongly-typed",
+ };
+ private static final String MIME_JSON = "application/json";
+ static final Gson gson = new GsonBuilder()
+ .registerTypeAdapterFactory(LDGson.typeAdapters())
+ .create();
+
+ private final Router router = new Router();
+ private final Application application;
+
+ private final Map clients = new ConcurrentHashMap();
+ private final AtomicInteger clientCounter = new AtomicInteger(0);
+
+ public static class BadRequestException extends Exception {
+ public BadRequestException(String message) {
+ super(message);
+ }
+ }
+
+ TestService(Application application) {
+ super(PORT);
+ this.application = application;
+ router.add("GET", "/", (params, body) -> getStatus());
+ router.add("POST", "/", (params, body) -> postCreateClient(body));
+ router.addRegex("POST", Pattern.compile("/clients/(.*)"), (params, body) -> postClientCommand(params, body));
+ router.addRegex("DELETE", Pattern.compile("/clients/(.*)"), (params, body) -> deleteClient(params));
+ }
+
+ @Override
+ public Response serve(IHTTPSession session) {
+ Method method = session.getMethod();
+
+ String body = null;
+ if (Method.POST.equals(method) && session.getHeaders().containsKey("content-length")) {
+ int contentLength = Integer.parseInt(session.getHeaders().get("content-length"));
+ byte[] buffer = new byte[contentLength];
+ try {
+ session.getInputStream().read(buffer, 0, contentLength);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ body = new String(buffer);
+ }
+
+ Timber.i("Handling request: %s %s", method.name(), session.getUri());
+ try {
+ return router.route(method.name(), session.getUri(), body);
+ } catch (JsonSyntaxException jse) {
+ return newFixedLengthResponse(Response.Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Invalid JSON Format\n");
+ } catch (Exception e) {
+ Timber.e(e, "Exception when handling request: %s %s", method.name(), session.getUri());
+ return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, e.toString());
+ }
+ }
+
+ private Response getStatus() {
+ Status ret = new Representations.Status();
+
+ ret.name = "android-client-sdk";
+ ret.clientVersion = BuildConfig.VERSION_NAME;
+ ret.capabilities = CAPABILITIES;
+
+ return newFixedLengthResponse(Response.Status.OK, MIME_JSON, gson.toJson(ret));
+ }
+
+ private Response postCreateClient(String jsonPayload) {
+ CreateInstanceParams params = gson.fromJson(jsonPayload, CreateInstanceParams.class);
+
+ String clientId = String.valueOf(clientCounter.incrementAndGet());
+ SdkClientEntity client = new SdkClientEntity(application, params);
+
+ clients.put(clientId, client);
+
+ Response response = newFixedLengthResponse(null);
+ response.addHeader("Location", "/clients/" + clientId);
+ return response;
+ }
+
+ private Response postClientCommand(List pathParams, String jsonPayload) {
+ String clientId = pathParams.get(0);
+ CommandParams params = gson.fromJson(jsonPayload, CommandParams.class);
+ SdkClientEntity client = clients.get(clientId);
+ if (client == null) {
+ return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Client not found\n");
+ }
+ try {
+ Object resp = client.doCommand(params);
+ return newFixedLengthResponse(
+ Response.Status.ACCEPTED,
+ MIME_JSON,
+ resp != null ? gson.toJson(resp) : null);
+ } catch (BadRequestException e) {
+ return newFixedLengthResponse(
+ Response.Status.BAD_REQUEST,
+ NanoHTTPD.MIME_PLAINTEXT,
+ e.getMessage());
+ }
+ }
+
+ private Response deleteClient(List pathParams) {
+ String clientId = pathParams.get(0);
+ SdkClientEntity client = clients.get(clientId);
+ if (client == null) {
+ return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Client not found\n");
+ }
+ client.close();
+ return newFixedLengthResponse(null);
+ }
+}
diff --git a/contract-tests/src/main/res/drawable-v24/ic_launcher_foreground.xml b/contract-tests/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..c7bd21db
--- /dev/null
+++ b/contract-tests/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contract-tests/src/main/res/drawable/ic_launcher_background.xml b/contract-tests/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..d5fccc53
--- /dev/null
+++ b/contract-tests/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contract-tests/src/main/res/layout/activity_main.xml b/contract-tests/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000..50047a50
--- /dev/null
+++ b/contract-tests/src/main/res/layout/activity_main.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/contract-tests/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/contract-tests/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..eca70cfe
--- /dev/null
+++ b/contract-tests/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/contract-tests/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/contract-tests/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..eca70cfe
--- /dev/null
+++ b/contract-tests/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/contract-tests/src/main/res/mipmap-hdpi/ic_launcher.png b/contract-tests/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..a2f59082
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/contract-tests/src/main/res/mipmap-hdpi/ic_launcher_round.png b/contract-tests/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..1b523998
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/contract-tests/src/main/res/mipmap-mdpi/ic_launcher.png b/contract-tests/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..ff10afd6
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/contract-tests/src/main/res/mipmap-mdpi/ic_launcher_round.png b/contract-tests/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..115a4c76
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/contract-tests/src/main/res/mipmap-xhdpi/ic_launcher.png b/contract-tests/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..dcd3cd80
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/contract-tests/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/contract-tests/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..459ca609
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/contract-tests/src/main/res/mipmap-xxhdpi/ic_launcher.png b/contract-tests/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..8ca12fe0
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/contract-tests/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/contract-tests/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..8e19b410
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/contract-tests/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/contract-tests/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..b824ebdd
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/contract-tests/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/contract-tests/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..4c19a13c
Binary files /dev/null and b/contract-tests/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/contract-tests/src/main/res/values-w820dp/dimens.xml b/contract-tests/src/main/res/values-w820dp/dimens.xml
new file mode 100644
index 00000000..63fc8164
--- /dev/null
+++ b/contract-tests/src/main/res/values-w820dp/dimens.xml
@@ -0,0 +1,6 @@
+
+
+ 64dp
+
diff --git a/contract-tests/src/main/res/values/colors.xml b/contract-tests/src/main/res/values/colors.xml
new file mode 100644
index 00000000..3ab3e9cb
--- /dev/null
+++ b/contract-tests/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #3F51B5
+ #303F9F
+ #FF4081
+
diff --git a/contract-tests/src/main/res/values/dimens.xml b/contract-tests/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..47c82246
--- /dev/null
+++ b/contract-tests/src/main/res/values/dimens.xml
@@ -0,0 +1,5 @@
+
+
+ 16dp
+ 16dp
+
diff --git a/contract-tests/src/main/res/values/strings.xml b/contract-tests/src/main/res/values/strings.xml
new file mode 100644
index 00000000..3a8e2d2f
--- /dev/null
+++ b/contract-tests/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ android-client-restwrapper
+
diff --git a/contract-tests/src/main/res/values/styles.xml b/contract-tests/src/main/res/values/styles.xml
new file mode 100644
index 00000000..067a609a
--- /dev/null
+++ b/contract-tests/src/main/res/values/styles.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/example/gradle/wrapper/gradle-wrapper.properties b/example/gradle/wrapper/gradle-wrapper.properties
index 9a4163a4..e336581f 100644
--- a/example/gradle/wrapper/gradle-wrapper.properties
+++ b/example/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,6 @@
+#Thu Jan 11 09:20:17 PST 2018
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java
index fadfe71a..c933a8ac 100644
--- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java
+++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java
@@ -131,7 +131,7 @@ private void createTestManager(boolean setOffline, boolean streaming, boolean ba
.streamUri(streamUri != null ? Uri.parse(streamUri) : Uri.parse(mockStreamServer.url("/").toString()))
.build();
- connectivityManager = new ConnectivityManager(app, config, eventProcessor, userManager, "default", null);
+ connectivityManager = new ConnectivityManager(app, config, eventProcessor, userManager, "default", null, null);
}
private void awaitStartUp() throws ExecutionException {
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java
index 333ce215..4c48e4f0 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java
@@ -26,6 +26,7 @@ class ConnectivityManager {
private final StreamUpdateProcessor streamUpdateProcessor;
private final UserManager userManager;
private final EventProcessor eventProcessor;
+ private final DiagnosticEventProcessor diagnosticEventProcessor;
private final Throttler throttler;
private final Foreground.Listener foregroundListener;
private final String environmentName;
@@ -40,9 +41,11 @@ class ConnectivityManager {
@NonNull final EventProcessor eventProcessor,
@NonNull final UserManager userManager,
@NonNull final String environmentName,
+ final DiagnosticEventProcessor diagnosticEventProcessor,
final DiagnosticStore diagnosticStore) {
this.application = application;
this.eventProcessor = eventProcessor;
+ this.diagnosticEventProcessor = diagnosticEventProcessor;
this.userManager = userManager;
this.environmentName = environmentName;
pollingInterval = ldConfig.getPollingIntervalMillis();
@@ -274,6 +277,18 @@ private void voidSuccess(LDUtil.ResultCallback listener) {
}
}
+ private void startDiagnostics() {
+ if (diagnosticEventProcessor != null) {
+ diagnosticEventProcessor.startScheduler();
+ }
+ }
+
+ private void stopDiagnostics() {
+ if (diagnosticEventProcessor != null) {
+ diagnosticEventProcessor.stopScheduler();
+ }
+ }
+
synchronized boolean startUp(LDUtil.ResultCallback onCompleteListener) {
initialized = false;
if (setOffline) {
@@ -294,6 +309,7 @@ synchronized boolean startUp(LDUtil.ResultCallback onCompleteListener) {
initCallback = onCompleteListener;
eventProcessor.start();
+ startDiagnostics();
throttler.attemptRun();
return true;
}
@@ -306,6 +322,7 @@ synchronized void shutdown() {
stopStreaming();
stopPolling();
setOffline = true;
+ stopDiagnostics();
callInitCallback();
}
@@ -322,6 +339,7 @@ synchronized void setOffline() {
throttler.cancel();
attemptTransition(ConnectionMode.SET_OFFLINE);
eventProcessor.stop();
+ stopDiagnostics();
}
}
@@ -373,9 +391,11 @@ synchronized void onNetworkConnectivityChange(boolean connectedToInternet) {
}
if (connectionInformation.getConnectionMode() == ConnectionMode.OFFLINE && connectedToInternet) {
eventProcessor.start();
+ startDiagnostics();
throttler.attemptRun();
} else if (connectionInformation.getConnectionMode() != ConnectionMode.OFFLINE && !connectedToInternet) {
eventProcessor.stop();
+ stopDiagnostics();
throttler.cancel();
attemptTransition(ConnectionMode.OFFLINE);
}
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java
index 050f9ac4..4198dfd9 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java
@@ -47,7 +47,11 @@ public class LDClient implements LDClientInterface, Closeable {
private static final String INSTANCE_ID_KEY = "instanceId";
// Upon client init will get set to a Unique id per installation used when creating anonymous users
private static String instanceId = "UNKNOWN_ANDROID";
- private static Map instances = null;
+ // A map of each LDClient (one per environment), or null if `init` hasn't been called yet.
+ // Will only be set once, during initialization, and the map is considered immutable.
+ static volatile Map instances = null;
+ // A lock to ensure calls to `init()` are serialized.
+ static Object initLock = new Object();
private final Application application;
private final LDConfig config;
@@ -76,9 +80,9 @@ public class LDClient implements LDClientInterface, Closeable {
* @param user The user used in evaluating feature flags
* @return a {@link Future} which will complete once the client has been initialized.
*/
- public static synchronized Future init(@NonNull Application application,
- @NonNull LDConfig config,
- @NonNull LDUser user) {
+ public static Future init(@NonNull Application application,
+ @NonNull LDConfig config,
+ @NonNull LDUser user) {
// As this is an externally facing API we should still check these, so we hide the linter
// warnings
@@ -94,66 +98,76 @@ public static synchronized Future init(@NonNull Application applicatio
if (user == null) {
return new LDFailedFuture<>(new LaunchDarklyException("Client initialization requires a valid user"));
}
+ // Acquire the `initLock` to ensure that if `init()` is called multiple times, we will only
+ // initialize the client(s) once.
+ synchronized (initLock) {
+ if (instances != null) {
+ LDConfig.LOG.w("LDClient.init() was called more than once! returning primary instance.");
+ return new LDSuccessFuture<>(instances.get(LDConfig.primaryEnvironmentName));
+ }
+ if (BuildConfig.DEBUG) {
+ Timber.plant(new Timber.DebugTree());
+ }
- if (instances != null) {
- LDConfig.LOG.w("LDClient.init() was called more than once! returning primary instance.");
- return new LDSuccessFuture<>(instances.get(LDConfig.primaryEnvironmentName));
- }
- if (BuildConfig.DEBUG) {
- Timber.plant(new Timber.DebugTree());
- }
+ Foreground.init(application);
- Foreground.init(application);
+ SharedPreferences instanceIdSharedPrefs =
+ application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE);
- instances = new HashMap<>();
+ if (!instanceIdSharedPrefs.contains(INSTANCE_ID_KEY)) {
+ String uuid = UUID.randomUUID().toString();
+ LDConfig.LOG.i("Did not find existing instance id. Saving a new one");
+ SharedPreferences.Editor editor = instanceIdSharedPrefs.edit();
+ editor.putString(INSTANCE_ID_KEY, uuid);
+ editor.apply();
+ }
- SharedPreferences instanceIdSharedPrefs =
- application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "id", Context.MODE_PRIVATE);
+ instanceId = instanceIdSharedPrefs.getString(INSTANCE_ID_KEY, instanceId);
+ LDConfig.LOG.i("Using instance id: %s", instanceId);
- if (!instanceIdSharedPrefs.contains(INSTANCE_ID_KEY)) {
- String uuid = UUID.randomUUID().toString();
- LDConfig.LOG.i("Did not find existing instance id. Saving a new one");
- SharedPreferences.Editor editor = instanceIdSharedPrefs.edit();
- editor.putString(INSTANCE_ID_KEY, uuid);
- editor.apply();
- }
+ Migration.migrateWhenNeeded(application, config);
- instanceId = instanceIdSharedPrefs.getString(INSTANCE_ID_KEY, instanceId);
- LDConfig.LOG.i("Using instance id: %s", instanceId);
+ // Create, but don't start, every LDClient instance
+ final Map newInstances = new HashMap<>();
- Migration.migrateWhenNeeded(application, config);
+ for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) {
+ final LDClient instance = new LDClient(application, config, mobileKeys.getKey());
+ instance.userManager.setCurrentUser(user);
- final LDAwaitFuture resultFuture = new LDAwaitFuture<>();
- final AtomicInteger initCounter = new AtomicInteger(config.getMobileKeys().size());
- LDUtil.ResultCallback completeWhenCounterZero = new LDUtil.ResultCallback() {
- @Override
- public void onSuccess(Void result) {
- if (initCounter.decrementAndGet() == 0) {
- resultFuture.set(instances.get(LDConfig.primaryEnvironmentName));
- }
+ newInstances.put(mobileKeys.getKey(), instance);
}
- @Override
- public void onError(Throwable e) {
- resultFuture.setException(e);
- }
- };
+ instances = newInstances;
- PollingUpdater.setBackgroundPollingIntervalMillis(config.getBackgroundPollingIntervalMillis());
+ final LDAwaitFuture resultFuture = new LDAwaitFuture<>();
+ final AtomicInteger initCounter = new AtomicInteger(config.getMobileKeys().size());
+ LDUtil.ResultCallback completeWhenCounterZero = new LDUtil.ResultCallback() {
+ @Override
+ public void onSuccess(Void result) {
+ if (initCounter.decrementAndGet() == 0) {
+ resultFuture.set(newInstances.get(LDConfig.primaryEnvironmentName));
+ }
+ }
+
+ @Override
+ public void onError(Throwable e) {
+ resultFuture.setException(e);
+ }
+ };
- user = customizeUser(user);
+ PollingUpdater.setBackgroundPollingIntervalMillis(config.getBackgroundPollingIntervalMillis());
- for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) {
- final LDClient instance = new LDClient(application, config, mobileKeys.getKey());
- instance.userManager.setCurrentUser(user);
+ user = customizeUser(user);
- instances.put(mobileKeys.getKey(), instance);
- if (instance.connectivityManager.startUp(completeWhenCounterZero)) {
- instance.sendEvent(new IdentifyEvent(user));
+ // Start up all instances
+ for (final LDClient instance : instances.values()) {
+ if (instance.connectivityManager.startUp(completeWhenCounterZero)) {
+ instance.sendEvent(new IdentifyEvent(user));
+ }
}
- }
- return resultFuture;
+ return resultFuture;
+ }
}
@VisibleForTesting
@@ -189,7 +203,7 @@ static LDUser customizeUser(LDUser user) {
* @param startWaitSeconds Maximum number of seconds to wait for the client to initialize
* @return The primary LDClient instance
*/
- public static synchronized LDClient init(Application application, LDConfig config, LDUser user, int startWaitSeconds) {
+ public static LDClient init(Application application, LDConfig config, LDUser user, int startWaitSeconds) {
LDConfig.LOG.i("Initializing Client and waiting up to %s for initialization to complete", startWaitSeconds);
Future initFuture = init(application, config, user);
try {
@@ -254,7 +268,7 @@ protected LDClient(final Application application, @NonNull final LDConfig config
this.userManager = DefaultUserManager.newInstance(application, fetcher, environmentName, sdkKey, config.getMaxCachedUsers());
eventProcessor = new DefaultEventProcessor(application, config, userManager.getSummaryEventStore(), environmentName, diagnosticStore, sharedEventClient);
- connectivityManager = new ConnectivityManager(application, config, eventProcessor, userManager, environmentName, diagnosticStore);
+ connectivityManager = new ConnectivityManager(application, config, eventProcessor, userManager, environmentName, diagnosticEventProcessor, diagnosticStore);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityReceiver = new ConnectivityReceiver();
@@ -301,8 +315,8 @@ public Future identify(LDUser user) {
return LDClient.identifyInstances(customizeUser(user));
}
- private synchronized void identifyInternal(@NonNull LDUser user,
- LDUtil.ResultCallback onCompleteListener) {
+ private void identifyInternal(@NonNull LDUser user,
+ LDUtil.ResultCallback onCompleteListener) {
if (!config.isAutoAliasingOptOut()) {
LDUser previousUser = userManager.getCurrentUser();
if (Event.userContextKind(previousUser).equals("anonymousUser") && Event.userContextKind(user).equals("user")) {
@@ -314,7 +328,7 @@ private synchronized void identifyInternal(@NonNull LDUser user,
sendEvent(new IdentifyEvent(user));
}
- private static synchronized Future identifyInstances(@NonNull LDUser user) {
+ private static Future identifyInstances(@NonNull LDUser user) {
final LDAwaitFuture resultFuture = new LDAwaitFuture<>();
final AtomicInteger identifyCounter = new AtomicInteger(instances.size());
LDUtil.ResultCallback completeWhenCounterZero = new LDUtil.ResultCallback() {
@@ -445,10 +459,6 @@ public void close() throws IOException {
private void closeInternal() {
connectivityManager.shutdown();
eventProcessor.close();
-
- if (diagnosticEventProcessor != null) {
- diagnosticEventProcessor.close();
- }
if (connectivityReceiver != null) {
application.unregisterReceiver(connectivityReceiver);
@@ -493,39 +503,27 @@ public boolean isOffline() {
}
@Override
- public synchronized void setOffline() {
+ public void setOffline() {
LDClient.setInstancesOffline();
}
- private synchronized void setOfflineInternal() {
+ private void setOfflineInternal() {
connectivityManager.setOffline();
- setDiagnosticsOnline(false);
}
- private synchronized static void setInstancesOffline() {
+ private static void setInstancesOffline() {
for (LDClient client : instances.values()) {
client.setOfflineInternal();
}
}
@Override
- public synchronized void setOnline() {
+ public void setOnline() {
setOnlineStatusInstances();
}
private void setOnlineStatusInternal() {
connectivityManager.setOnline();
- setDiagnosticsOnline(true);
- }
-
- private void setDiagnosticsOnline(boolean isOnline) {
- if (diagnosticEventProcessor != null) {
- if (isOnline) {
- diagnosticEventProcessor.startScheduler();
- } else {
- diagnosticEventProcessor.stopScheduler();
- }
- }
}
private static void setOnlineStatusInstances() {
@@ -637,7 +635,6 @@ static String getInstanceId() {
}
private void onNetworkConnectivityChange(boolean connectedToInternet) {
- setDiagnosticsOnline(connectedToInternet);
connectivityManager.onNetworkConnectivityChange(connectedToInternet);
}
@@ -688,7 +685,7 @@ private void updateSummaryEvents(String flagKey, Flag flag, LDValue result, LDVa
userManager.getSummaryEventStore().addOrUpdateEvent(flagKey, result, defaultValue, version, variation);
}
- static synchronized void triggerPollInstances() {
+ static void triggerPollInstances() {
if (instances == null) {
LDConfig.LOG.w("Cannot perform poll when LDClient has not been initialized!");
return;
@@ -698,7 +695,7 @@ static synchronized void triggerPollInstances() {
}
}
- static synchronized void onNetworkConnectivityChangeInstances(boolean network) {
+ static void onNetworkConnectivityChangeInstances(boolean network) {
if (instances == null) {
LDConfig.LOG.e("Tried to update LDClients with network connectivity status, but LDClient has not yet been initialized.");
return;
diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh
new file mode 100755
index 00000000..c85caf8f
--- /dev/null
+++ b/scripts/start-emulator.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+
+# Starts an Android emulator to run the contract tests in.
+#
+# This script assumes that adb, avdmanager, and emulator are on the path. It
+# will remember the PID of the last emulator created by this script, and try to kill
+# it automatically before starting another one.
+#
+# This script assumes that you have at least one Android package installed (probably from
+# Android Studio), and will automatically pick the latest one to run as an emulator.
+#
+# Optional params:
+# ANDROID_PORT: the Android port, which uniquely identifies a running virtual device.
+# If you have another virtual device running (e.g. via Android Studio),
+# you may have to override this port number to something else.
+# AVD_NAME: the name of the AVD to create and run.
+
+set -eo pipefail
+
+ANDROID_PORT=${ANDROID_PORT:-5554}
+SERIAL_NUMBER=emulator-${ANDROID_PORT}
+AVD_NAME=${AVD_NAME:-launchdarkly-contract-test-emulator}
+EMULATOR_PID=.emulator-pid
+if [ -z "${AVD_IMAGE}" ]; then
+ echo "Using the latest installed Android image"
+ AVD_IMAGE=$(sdkmanager --list_installed | awk '{ print $1 }' | grep system-images | sort -r -k 2 -t ';' | head -1)
+ if [ -z "${AVD_IMAGE}" ]; then
+ echo "No emulator images installed locally that meet criteria; try overriding AVD_IMAGE variable"
+ exit 1
+ fi
+ echo "Picked ${AVD_IMAGE}"
+fi
+
+if [ -f ${EMULATOR_PID} ]; then
+ if ps $(cat ${EMULATOR_PID}); then
+ echo "Killing previous emulator"
+ kill -9 $(cat ${EMULATOR_PID})
+ fi
+ rm ${EMULATOR_PID}
+fi
+
+# Create or recreate the AVD.
+echo no | avdmanager create avd -n ${AVD_NAME} -f -k "${AVD_IMAGE}"
+
+# According to https://stackoverflow.com/questions/37063267/high-cpu-usage-with-android-emulator-qemu-system-i386-exe
+# the emulator's CPU usage can be greatly reduced by modifying the following properties of the AVD. Not sure if
+# that's entirely true - CPU usage seems to go up and down a lot with no apparent cause - but it's worth a try.
+AVD_CONFIG=~/.android/avd/${AVD_NAME}.avd/config.ini
+echo "hw.audioInput=no" >>${AVD_CONFIG}
+echo "hw.audioOutput=no" >>${AVD_CONFIG}
+
+# Start emulator in background
+# Note that for some reason things do not work properly in Ubuntu unless we cd to the directory where the emulator is
+EMULATOR_PARAMS="-avd ${AVD_NAME} -port ${ANDROID_PORT} -no-audio -no-snapshot"
+emulator $EMULATOR_PARAMS &
+EMULATOR_PID=$!
+
+echo $EMULATOR_PID > .emulator-pid
+
+# If something goes wrong for the remainder of this script, tear down the emulator automatically
+trap "kill -9 $EMULATOR_PID" SIGINT SIGTERM ERR
+
+# Wait for emulator
+
+TIMEFORMAT='Emulator started in %R seconds'
+
+bootanim=""
+failcounter=0
+timeout_in_sec=360
+
+echo -n "Waiting for emulator to start"
+
+time {
+ until [[ "$bootanim" =~ "stopped" ]]; do
+ bootanim=`adb -s ${SERIAL_NUMBER} shell getprop init.svc.bootanim 2>&1 &`
+ if [[ "$bootanim" =~ "device not found" || "$bootanim" =~ "device offline"
+ || "$bootanim" =~ "running" ]]; then
+ let "failcounter += 1"
+ echo -n "."
+ if [[ $failcounter -gt $timeout_in_sec ]]; then
+ echo "Timeout ($timeout_in_sec seconds) reached; failed to start emulator"
+ TIMEFORMAT=
+ exit 1
+ fi
+ fi
+ sleep 2
+ done
+}
+
+# Remove lock screen
+adb -s ${SERIAL_NUMBER} shell input keyevent 82
diff --git a/scripts/start-test-service.sh b/scripts/start-test-service.sh
new file mode 100755
index 00000000..86a169a8
--- /dev/null
+++ b/scripts/start-test-service.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+
+# Starts the contract test service in the specified Android emulator.
+#
+# This script assumes that adb is on the path. After it starts the test app on the specified
+# emulator, it will wait to confirm that the process appears to be running on the device, and then
+# forward the port to localhost, before exiting.
+#
+# Optional params:
+# LOCAL_PORT: the test service will bind to localhost on this port
+# ANDROID_PORT: the Android port, which uniquely identifies a running virtual device.
+# If you have multiple virtual devices running (e.g. via Android Studio),
+# you may have to override this port number to something else. Must be the same
+# value as the ANDROID_PORT from start-emulator.sh.
+# CONTRACT_TESTS_APK: the contract-tests APK to install and run on the device.
+
+set -eo pipefail
+
+LOCAL_PORT=${LOCAL_PORT:-8001}
+ANDROID_PORT=${ANDROID_PORT:-5554}
+SERIAL_NUMBER=emulator-${ANDROID_PORT}
+CONTRACT_TESTS_APK=${CONTRACT_TESTS_APK:-contract-tests/build/outputs/apk/debug/contract-tests-debug.apk}
+
+# Install APK to emulator
+adb -s ${SERIAL_NUMBER} install -t -r -d ${CONTRACT_TESTS_APK}
+
+# Run APK on emulator
+adb -s ${SERIAL_NUMBER} shell am start -n com.launchdarkly.sdktest/.MainActivity -e PORT $LOCAL_PORT
+
+TIMEFORMAT='App started in %R seconds'
+APP_PID=""
+time {
+ while [[ "$APP_PID" == "" ]]; do
+ APP_PID=`adb -s ${SERIAL_NUMBER} shell pidof -s com.launchdarkly.sdktest` || APP_PID=""
+ if [[ "$APP_PID" == "" ]]; then sleep 1; fi
+ done
+}
+
+# Forward connections to emulator
+adb -s ${SERIAL_NUMBER} forward tcp:$LOCAL_PORT tcp:$LOCAL_PORT
+
+echo "Test service started. Run 'adb logcat' to see live log output"
diff --git a/settings.gradle b/settings.gradle
index e9e4b664..f1e8dec9 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,3 @@
include(":launchdarkly-android-client-sdk")
include(":example")
+include(":contract-tests")
diff --git a/testharness-suppressions.txt b/testharness-suppressions.txt
new file mode 100644
index 00000000..8667c169
--- /dev/null
+++ b/testharness-suppressions.txt
@@ -0,0 +1,16 @@
+# sc-159880 (sdk bug) - NullPointerException on null variation id
+# sc-160002 (sdk bug) - doesn't support initialRetryDelayMs
+streaming/updates/
+# sc-159579 (test harness bug) - `device` and `os` user properties
+streaming/requests/user properties/GET
+streaming/requests/user properties/REPORT
+polling/requests/user properties/GET
+polling/requests/user properties/REPORT
+# sc-159583 (sdk bug) - `anonymous: false` in events
+# sc-159579 (test harness bug) - `device` and `os` user properties
+events/user properties/
+# sc-159578 (sdk bug) - trailing slashes not handled properly
+streaming/requests/URL path is computed correctly/base URI has a trailing slash/GET
+streaming/requests/URL path is computed correctly/base URI has a trailing slash/REPORT
+polling/requests/URL path is computed correctly/base URI has a trailing slash/GET
+polling/requests/URL path is computed correctly/base URI has a trailing slash/REPORT