diff --git a/CHANGELOG.md b/CHANGELOG.md index ba49c69f..11801849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to the LaunchDarkly Android SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). + +## [2.13.0] - 2020-08-07 +### Added +- Allow specifying additional headers to be included on HTTP requests to LaunchDarkly services using `LDConfig.Builder.setAdditionalHeaders`. This feature is to enable certain proxy configurations, and is not needed for normal use. + ## [2.12.0] - 2020-05-29 ### Added - Added a new configuration option, `maxCachedUsers` to LDConfig. This option allows configuration of the limit to how many users have their flag values cached locally in the device's SharedPreferences. diff --git a/example/build.gradle b/example/build.gradle index b781a9d3..5eb50c50 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -46,7 +46,7 @@ dependencies { implementation 'com.android.support:appcompat-v7:26.1.0' implementation project(path: ':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:2.12.0' + //implementation 'com.launchdarkly:launchdarkly-android-client-sdk:2.13.0' implementation 'com.jakewharton.timber:timber:4.7.1' diff --git a/launchdarkly-android-client-sdk/build.gradle b/launchdarkly-android-client-sdk/build.gradle index ead1ae63..e24fa579 100644 --- a/launchdarkly-android-client-sdk/build.gradle +++ b/launchdarkly-android-client-sdk/build.gradle @@ -7,7 +7,7 @@ apply plugin: 'io.codearte.nexus-staging' allprojects { group = 'com.launchdarkly' - version = '2.12.0' + version = '2.13.0' sourceCompatibility = 1.7 targetCompatibility = 1.7 } diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/DiagnosticEventProcessorTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/DiagnosticEventProcessorTest.java new file mode 100644 index 00000000..91f8c544 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/DiagnosticEventProcessorTest.java @@ -0,0 +1,125 @@ +package com.launchdarkly.android; + +import android.net.Uri; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.launchdarkly.android.test.TestActivity; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.util.HashMap; + +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import static junit.framework.Assert.assertEquals; + +@RunWith(AndroidJUnit4.class) +public class DiagnosticEventProcessorTest { + + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + private MockWebServer mockEventsServer; + + @Before + public void before() throws IOException { + NetworkTestController.setup(activityTestRule.getActivity()); + mockEventsServer = new MockWebServer(); + mockEventsServer.start(); + } + + @After + public void after() throws InterruptedException, IOException { + NetworkTestController.enableNetwork(); + mockEventsServer.close(); + } + + @Test + public void defaultDiagnosticRequest() throws InterruptedException { + // Setup in background to prevent initial diagnostic event + ForegroundTestController.setup(false); + OkHttpClient okHttpClient = new OkHttpClient.Builder().build(); + LDConfig ldConfig = new LDConfig.Builder() + .setMobileKey("test-mobile-key") + .setEventsUri(Uri.parse(mockEventsServer.url("/mobile").toString())) + .build(); + DiagnosticStore diagnosticStore = new DiagnosticStore(activityTestRule.getActivity().getApplication(), "test-mobile-key"); + DiagnosticEventProcessor diagnosticEventProcessor = new DiagnosticEventProcessor(ldConfig, "default", diagnosticStore, okHttpClient); + + DiagnosticEvent testEvent = new DiagnosticEvent("test-kind", System.currentTimeMillis(), diagnosticStore.getDiagnosticId()); + + mockEventsServer.enqueue(new MockResponse()); + diagnosticEventProcessor.sendDiagnosticEventSync(testEvent); + RecordedRequest r = mockEventsServer.takeRequest(); + assertEquals("POST", r.getMethod()); + assertEquals("/mobile/events/diagnostic", r.getPath()); + assertEquals("api_key test-mobile-key", r.getHeader("Authorization")); + assertEquals("AndroidClient/" + BuildConfig.VERSION_NAME, r.getHeader("User-Agent")); + assertEquals("application/json; charset=utf-8", r.getHeader("Content-Type")); + assertEquals(GsonCache.getGson().toJson(testEvent), r.getBody().readUtf8()); + } + + @Test + public void defaultDiagnosticRequestIncludingWrapper() throws InterruptedException { + // Setup in background to prevent initial diagnostic event + ForegroundTestController.setup(false); + OkHttpClient okHttpClient = new OkHttpClient.Builder().build(); + LDConfig ldConfig = new LDConfig.Builder() + .setMobileKey("test-mobile-key") + .setEventsUri(Uri.parse(mockEventsServer.url("/mobile").toString())) + .setWrapperName("ReactNative") + .setWrapperVersion("1.0.0") + .build(); + DiagnosticStore diagnosticStore = new DiagnosticStore(activityTestRule.getActivity().getApplication(), "test-mobile-key"); + DiagnosticEventProcessor diagnosticEventProcessor = new DiagnosticEventProcessor(ldConfig, "default", diagnosticStore, okHttpClient); + + DiagnosticEvent testEvent = new DiagnosticEvent("test-kind", System.currentTimeMillis(), diagnosticStore.getDiagnosticId()); + + mockEventsServer.enqueue(new MockResponse()); + diagnosticEventProcessor.sendDiagnosticEventSync(testEvent); + RecordedRequest r = mockEventsServer.takeRequest(); + assertEquals("POST", r.getMethod()); + assertEquals("/mobile/events/diagnostic", r.getPath()); + assertEquals("ReactNative/1.0.0", r.getHeader("X-LaunchDarkly-Wrapper")); + assertEquals(GsonCache.getGson().toJson(testEvent), r.getBody().readUtf8()); + } + + @Test + public void defaultDiagnosticRequestIncludingAdditionalHeaders() throws InterruptedException { + // Setup in background to prevent initial diagnostic event + ForegroundTestController.setup(false); + OkHttpClient okHttpClient = new OkHttpClient.Builder().build(); + + HashMap additionalHeaders = new HashMap<>(); + additionalHeaders.put("Proxy-Authorization", "token"); + additionalHeaders.put("Authorization", "foo"); + LDConfig ldConfig = new LDConfig.Builder() + .setMobileKey("test-mobile-key") + .setEventsUri(Uri.parse(mockEventsServer.url("/mobile").toString())) + .setAdditionalHeaders(additionalHeaders) + .build(); + DiagnosticStore diagnosticStore = new DiagnosticStore(activityTestRule.getActivity().getApplication(), "test-mobile-key"); + DiagnosticEventProcessor diagnosticEventProcessor = new DiagnosticEventProcessor(ldConfig, "default", diagnosticStore, okHttpClient); + + DiagnosticEvent testEvent = new DiagnosticEvent("test-kind", System.currentTimeMillis(), diagnosticStore.getDiagnosticId()); + + mockEventsServer.enqueue(new MockResponse()); + diagnosticEventProcessor.sendDiagnosticEventSync(testEvent); + RecordedRequest r = mockEventsServer.takeRequest(); + assertEquals("POST", r.getMethod()); + assertEquals("/mobile/events/diagnostic", r.getPath()); + assertEquals("token", r.getHeader("Proxy-Authorization")); + assertEquals("foo", r.getHeader("Authorization")); + assertEquals(GsonCache.getGson().toJson(testEvent), r.getBody().readUtf8()); + } +} diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDClientTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDClientTest.java index ab8f8473..3d408945 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDClientTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDClientTest.java @@ -15,6 +15,7 @@ import org.junit.runner.RunWith; import java.io.IOException; +import java.util.HashMap; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -478,6 +479,27 @@ public void variationFlagTrackReasonGeneratesEventWithReason() throws IOExceptio } } + @Test + public void additionalHeadersIncludedInEventsRequest() throws IOException, InterruptedException { + try (MockWebServer mockEventsServer = new MockWebServer()) { + mockEventsServer.start(); + // Enqueue a successful empty response + mockEventsServer.enqueue(new MockResponse()); + + HashMap additionalHeaders = new HashMap<>(); + additionalHeaders.put("Proxy-Authorization", "token"); + additionalHeaders.put("Authorization", "foo"); + LDConfig ldConfig = baseConfigBuilder(mockEventsServer).setAdditionalHeaders(additionalHeaders).build(); + try (LDClient client = LDClient.init(application, ldConfig, ldUser, 0)) { + client.blockingFlush(); + } + + RecordedRequest r = mockEventsServer.takeRequest(); + assertEquals("token", r.getHeader("Proxy-Authorization")); + assertEquals("foo", r.getHeader("Authorization")); + } + } + private Event[] getEventsFromLastRequest(MockWebServer server, int expectedCount) throws InterruptedException { RecordedRequest r = server.takeRequest(); assertEquals("POST", r.getMethod()); diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java index 5e4a9044..f0695cb7 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java @@ -6,8 +6,11 @@ import org.junit.Test; import org.junit.runner.RunWith; +import java.util.HashMap; import java.util.HashSet; +import okhttp3.Request; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -45,6 +48,7 @@ public void testBuilderDefaults() { assertNull(config.getWrapperName()); assertNull(config.getWrapperVersion()); + assertNull(config.getAdditionalHeaders()); } @@ -234,4 +238,41 @@ public void testBuilderMaxCachedUsers() { config = new LDConfig.Builder().setMaxCachedUsers(-1).build(); assertEquals(-1, config.getMaxCachedUsers()); } + + @Test + public void canSetAdditionalHeaders() { + HashMap additionalHeaders = new HashMap<>(); + additionalHeaders.put("Proxy-Authorization", "token"); + additionalHeaders.put("Authorization", "foo"); + LDConfig config = new LDConfig.Builder().setAdditionalHeaders(additionalHeaders).build(); + assertEquals(2, config.getAdditionalHeaders().size()); + assertEquals("token", config.getAdditionalHeaders().get("Proxy-Authorization")); + assertEquals("foo", config.getAdditionalHeaders().get("Authorization")); + } + + @Test + public void buildRequestWithAdditionalHeadersNull() { + LDConfig config = new LDConfig.Builder().build(); + Request.Builder requestBuilder = new Request.Builder() + .url("http://example.com") + .header("Authorization", "test-key"); + Request request = config.buildRequestWithAdditionalHeaders(requestBuilder); + assertEquals(1, request.headers().size()); + assertEquals("test-key", request.header("Authorization")); + } + + @Test + public void buildRequestWithAdditionalHeaders() { + HashMap additionalHeaders = new HashMap<>(); + additionalHeaders.put("Proxy-Authorization", "token"); + additionalHeaders.put("Authorization", "foo"); + LDConfig config = new LDConfig.Builder().setAdditionalHeaders(additionalHeaders).build(); + Request.Builder requestBuilder = new Request.Builder() + .url("http://example.com") + .header("Authorization", "test-key"); + Request request = config.buildRequestWithAdditionalHeaders(requestBuilder); + assertEquals(2, request.headers().size()); + assertEquals("token", request.header("Proxy-Authorization")); + assertEquals("foo", request.header("Authorization")); + } } \ No newline at end of file diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DefaultEventProcessor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DefaultEventProcessor.java index c2a63b21..3918bac2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DefaultEventProcessor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DefaultEventProcessor.java @@ -152,13 +152,14 @@ private void postEvents(List events) { } catch (InterruptedException e) {} } - Request request = config.getRequestBuilderFor(environmentName) + Request.Builder requestBuilder = config.getRequestBuilderFor(environmentName) .url(url) .post(RequestBody.create(JSON, content)) .addHeader("Content-Type", "application/json") .addHeader("X-LaunchDarkly-Event-Schema", "3") - .addHeader("X-LaunchDarkly-Payload-ID", eventPayloadId) - .build(); + .addHeader("X-LaunchDarkly-Payload-ID", eventPayloadId); + + Request request = config.buildRequestWithAdditionalHeaders(requestBuilder); Response response = null; try { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DiagnosticEventProcessor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DiagnosticEventProcessor.java index d1acd7c5..351865b4 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DiagnosticEventProcessor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/DiagnosticEventProcessor.java @@ -101,12 +101,14 @@ public void run() { }); } - private void sendDiagnosticEventSync(DiagnosticEvent diagnosticEvent) { + void sendDiagnosticEventSync(DiagnosticEvent diagnosticEvent) { String content = GsonCache.getGson().toJson(diagnosticEvent); Request.Builder requestBuilder = config.getRequestBuilderFor(environment) .url(config.getEventsUri().toString() + "/events/diagnostic") - .addHeader("Content-Type", "application/json"); - Request request = requestBuilder.post(RequestBody.create(JSON, content)).build(); + .addHeader("Content-Type", "application/json") + .post(RequestBody.create(JSON, content)); + + Request request = config.buildRequestWithAdditionalHeaders(requestBuilder); Timber.d("Posting diagnostic event to %s with body %s", request.url(), content); Response response = null; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java index 65d08f0b..8efe6af8 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java @@ -123,9 +123,9 @@ private Request getDefaultRequest(LDUser user) { uri += "?withReasons=true"; } Timber.d("Attempting to fetch Feature flags using uri: %s", uri); - return config.getRequestBuilderFor(environmentName) // default GET verb - .url(uri) - .build(); + Request.Builder requestBuilder = config.getRequestBuilderFor(environmentName) // default GET verb + .url(uri); + return config.buildRequestWithAdditionalHeaders(requestBuilder); } private Request getReportRequest(LDUser user) { @@ -136,9 +136,9 @@ private Request getReportRequest(LDUser user) { Timber.d("Attempting to report user using uri: %s", reportUri); String userJson = GSON.toJson(user); RequestBody reportBody = RequestBody.create(MediaType.parse("application/json;charset=UTF-8"), userJson); - return config.getRequestBuilderFor(environmentName) + Request.Builder requestBuilder = config.getRequestBuilderFor(environmentName) .method("REPORT", reportBody) // custom REPORT verb - .url(reportUri) - .build(); + .url(reportUri); + return config.buildRequestWithAdditionalHeaders(requestBuilder); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDConfig.java index 0843faa5..b55ff9be 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDConfig.java @@ -8,6 +8,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -73,8 +74,10 @@ public class LDConfig { private final boolean evaluationReasons; - private String wrapperName; - private String wrapperVersion; + private final String wrapperName; + private final String wrapperVersion; + + private final Map additionalHeaders; LDConfig(Map mobileKeys, Uri baseUri, @@ -97,7 +100,8 @@ public class LDConfig { int diagnosticRecordingIntervalMillis, String wrapperName, String wrapperVersion, - int maxCachedUsers) { + int maxCachedUsers, + Map additionalHeaders) { this.mobileKeys = mobileKeys; this.baseUri = baseUri; @@ -122,6 +126,12 @@ public class LDConfig { this.wrapperVersion = wrapperVersion; this.maxCachedUsers = maxCachedUsers; + if (additionalHeaders != null) { + this.additionalHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(additionalHeaders)); + } else { + this.additionalHeaders = null; + } + this.filteredEventGson = new GsonBuilder() .registerTypeAdapter(LDUser.class, new LDUser.LDUserPrivateAttributesTypeAdapter(this)) .excludeFieldsWithoutExposeAnnotation().create(); @@ -155,6 +165,15 @@ Request.Builder getRequestBuilderForKey(String sdkKey) { return requestBuilder; } + Request buildRequestWithAdditionalHeaders(Request.Builder requestBuilder) { + if (getAdditionalHeaders() != null) { + for (Map.Entry entry: getAdditionalHeaders().entrySet()) { + requestBuilder.header(entry.getKey(), entry.getValue()); + } + } + return requestBuilder.build(); + } + public String getMobileKey() { return mobileKeys.get(primaryEnvironmentName); } @@ -251,6 +270,10 @@ int getMaxCachedUsers() { return maxCachedUsers; } + Map getAdditionalHeaders() { + return additionalHeaders; + } + /** * A builder that helps construct * {@link LDConfig} objects. Builder calls can be chained, enabling the following pattern: @@ -291,6 +314,7 @@ public static class Builder { private String wrapperName; private String wrapperVersion; + private Map additionalHeaders; /** * Specifies that user attributes (other than the key) should be hidden from LaunchDarkly. @@ -628,6 +652,18 @@ public LDConfig.Builder setMaxCachedUsers(int maxCachedUsers) { return this; } + /** + * Additional headers that should be added to all HTTP requests from SDK components to + * LaunchDarkly services + * + * @param additionalHeaders A map of header keys to header values + * @return the builder + */ + public LDConfig.Builder setAdditionalHeaders(Map additionalHeaders) { + this.additionalHeaders = additionalHeaders; + return this; + } + /** * Returns the configured {@link LDConfig} object. * @return the configuration @@ -697,7 +733,8 @@ public LDConfig build() { diagnosticRecordingIntervalMillis, wrapperName, wrapperVersion, - maxCachedUsers); + maxCachedUsers, + additionalHeaders); } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDUser.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDUser.java index 73226544..eef108b7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDUser.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/LDUser.java @@ -31,14 +31,13 @@ * an IP address or session ID. *

* Besides the mandatory {@code key}, {@code LDUser} supports two kinds of optional attributes: - * interpreted attributes (e.g. {@code ip} and {@code country}) and custom attributes. LaunchDarkly - * can parse interpreted attributes and attach meaning to them. For example, from an {@code ip} - * address, LaunchDarkly can do a geo IP lookup and determine the user's country. + * built-in attributes (e.g. {@code name} and {@code email}) and custom attributes. *

- * Custom attributes are not parsed by LaunchDarkly. They can be used in custom rules-- for example, - * a custom attribute such as "customer_ranking" can be used to launch a feature to the top 10% of - * users on a site. - */ + * For a more complete description of user attributes and how they can be referenced in feature flag + * rules, see the reference guides on + * Setting user attributes + * and Targeting users. + * */ public class LDUser { private static final UserHasher USER_HASHER = new UserHasher(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java index 0e6c22a6..29cb3345 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java @@ -9,6 +9,7 @@ import com.launchdarkly.eventsource.UnsuccessfulResponseException; import java.net.URI; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -63,6 +64,12 @@ synchronized void start() { .add("User-Agent", LDConfig.USER_AGENT_HEADER_VALUE) .add("Accept", "text/event-stream"); + if (config.getAdditionalHeaders() != null) { + for (Map.Entry entry: config.getAdditionalHeaders().entrySet()) { + headersBuilder.set(entry.getKey(), entry.getValue()); + } + } + if (config.getWrapperName() != null) { String wrapperVersion = ""; if (config.getWrapperVersion() != null) {