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 @@ + + +