diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java index c62c90a7..677c5328 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.android.LDClient; import com.launchdarkly.sdk.android.LDConfig; +import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; @@ -247,6 +248,17 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, Components.httpConfiguration().useReport(params.clientSide.useReport) ); + if (params.tags != null) { + ApplicationInfoBuilder ab = Components.applicationInfo(); + if (params.tags.applicationId != null) { + ab.applicationId(params.tags.applicationId); + } + if (params.tags.applicationVersion != null) { + ab.applicationVersion(params.tags.applicationVersion); + } + builder.applicationInfo(ab); + } + if (params.serviceEndpoints != null) { if (params.serviceEndpoints.streaming != null) { endpoints.streaming(params.serviceEndpoints.streaming); diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java index e4bd626e..69f9b55f 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -32,6 +32,7 @@ public class TestService extends NanoHTTPD { "service-endpoints", "singleton", "strongly-typed", + "tags" }; private static final String MIME_JSON = "application/json"; static final Gson gson = new GsonBuilder() diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 718b8dee..fa9ddb03 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -54,19 +54,20 @@ static ClientContextImpl fromConfig( SummaryEventStore summaryEventStore, LDLogger logger ) { - ClientContext minimalContext = new ClientContext(null, mobileKey, logger, config, + ClientContext minimalContext = new ClientContext(null, config.applicationInfo, logger, config, environmentName, config.isEvaluationReasons(), null, config.isOffline(), - config.serviceEndpoints); + mobileKey, config.serviceEndpoints); HttpConfiguration httpConfig = config.http.build(minimalContext); ClientContext baseClientContext = new ClientContext( application, - mobileKey, + config.applicationInfo, logger, config, environmentName, config.isEvaluationReasons(), httpConfig, config.isOffline(), + mobileKey, config.serviceEndpoints ); return new ClientContextImpl(baseClientContext, diagnosticStore, sharedEventClient, summaryEventStore); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java index 771a18a0..532bdffc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java @@ -2,6 +2,7 @@ import static com.launchdarkly.sdk.android.ComponentsImpl.NULL_EVENT_PROCESSOR_FACTORY; +import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; @@ -25,6 +26,28 @@ public abstract class Components { private Components() {} + /** + * Returns a configuration builder for the SDK's application metadata. + *

+ * Passing this to {@link LDConfig.Builder#applicationInfo(com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder)}, + * after setting any desired properties on the builder, applies this configuration to the SDK. + *


+     *     LDConfig config = new LDConfig.Builder()
+     *         .applicationInfo(
+     *             Components.applicationInfo()
+     *                 .applicationId("authentication-service")
+     *                 .applicationVersion("1.0.0")
+     *         )
+     *         .build();
+     * 
+ * + * @return a builder object + * @see LDConfig.Builder#applicationInfo(com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder) + */ + public static ApplicationInfoBuilder applicationInfo() { + return new ApplicationInfoBuilder(); + } + /** * Returns a configuration builder for the SDK's networking configuration. *

diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java index 286cca17..2fd40bd6 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java @@ -132,6 +132,13 @@ public HttpConfiguration build(ClientContext clientContext) { Map headers = new HashMap<>(); headers.put("Authorization", LDUtil.AUTH_SCHEME + clientContext.getMobileKey()); headers.put("User-Agent", LDUtil.USER_AGENT_HEADER_VALUE); + if (clientContext.getApplicationInfo() != null) { + String tagHeader = LDUtil.applicationTagHeader(clientContext.getApplicationInfo(), + clientContext.getBaseLogger()); + if (!tagHeader.isEmpty()) { + headers.put("X-LaunchDarkly-Tags", tagHeader); + } + } if (wrapperName != null) { String wrapperId = wrapperVersion == null ? wrapperName : (wrapperName + "/" + wrapperVersion); headers.put("X-LaunchDarkly-Wrapper", wrapperId); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index eb5107b4..c81b6a2e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -10,11 +10,13 @@ import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.EventProcessor; @@ -71,6 +73,7 @@ public class LDConfig { private final Uri eventsUri; private final Uri streamUri; + final ApplicationInfo applicationInfo; final ComponentConfigurer dataSource; final ComponentConfigurer events; final ComponentConfigurer http; @@ -107,6 +110,7 @@ public class LDConfig { Uri pollUri, Uri eventsUri, Uri streamUri, + ApplicationInfo applicationInfo, ComponentConfigurer dataSource, ComponentConfigurer events, ComponentConfigurer http, @@ -138,6 +142,7 @@ public class LDConfig { this.pollUri = pollUri; this.eventsUri = eventsUri; this.streamUri = streamUri; + this.applicationInfo = applicationInfo; this.dataSource = dataSource; this.events = events; this.http = http; @@ -469,6 +474,7 @@ public static class Builder { private Uri eventsUri = DEFAULT_EVENTS_URI; private Uri streamUri = DEFAULT_STREAM_URI; + private ApplicationInfoBuilder applicationInfoBuilder = null; private ComponentConfigurer dataSource = null; private ComponentConfigurer events = null; private ComponentConfigurer http = null; @@ -652,6 +658,22 @@ public LDConfig.Builder streamUri(Uri streamUri) { return this; } + /** + * Sets the SDK's application metadata, which may be used in LaunchDarkly analytics or other product features, + * but does not affect feature flag evaluations. + *

+ * This object is normally a configuration builder obtained from {@link Components#applicationInfo()}, + * which has methods for setting individual metadata properties. + * + * @param applicationInfoBuilder a configuration builder object returned by {@link Components#applicationInfo()} + * @return the builder + * @since 3.3.0 + */ + public Builder applicationInfo(ApplicationInfoBuilder applicationInfoBuilder) { + this.applicationInfoBuilder = applicationInfoBuilder; + return this; + } + /** * Sets the configuration of the component that receives feature flag data from LaunchDarkly. *

@@ -1274,11 +1296,16 @@ public LDConfig build() { Components.serviceEndpoints().polling(pollUri).streaming(streamUri).events(eventsUri).build() : this.serviceEndpointsBuilder.build(); + ApplicationInfo applicationInfo = this.applicationInfoBuilder == null ? + Components.applicationInfo().createApplicationInfo() : + applicationInfoBuilder.createApplicationInfo(); + return new LDConfig( mobileKeys, pollUri, eventsUri, streamUri, + applicationInfo, dataSourceConfig, eventsConfig, httpConfig, diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java index 12520d92..247c6776 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java @@ -19,14 +19,18 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; import okhttp3.Headers; @@ -34,6 +38,41 @@ class LDUtil { static final String AUTH_SCHEME = "api_key "; static final String USER_AGENT_HEADER_VALUE = "AndroidClient/" + BuildConfig.VERSION_NAME; + // Tag values must not be empty, and only contain letters, numbers, `.`, `_`, or `-`. + private static Pattern TAG_VALUE_REGEX = Pattern.compile("^[-a-zA-Z0-9._]+$"); + + /** + * Builds the "X-LaunchDarkly-Tags" HTTP header out of the configured application info. + * + * @param applicationInfo the application metadata + * @return a space-separated string of tags, e.g. "application-id/authentication-service application-version/1.0.0" + */ + static String applicationTagHeader(ApplicationInfo applicationInfo, LDLogger logger) { + String[][] tags = { + {"applicationId", "application-id", applicationInfo.getApplicationId()}, + {"applicationVersion", "application-version", applicationInfo.getApplicationVersion()}, + }; + List parts = new ArrayList<>(); + for (String[] row : tags) { + String javaKey = row[0]; + String tagKey = row[1]; + String tagVal = row[2]; + if (tagVal == null) { + continue; + } + if (!TAG_VALUE_REGEX.matcher(tagVal).matches()) { + logger.warn("Value of ApplicationInfo.{} contained invalid characters and was discarded", javaKey); + continue; + } + if (tagVal.length() > 64) { + logger.warn("Value of ApplicationInfo.{} was longer than 64 characters and was discarded", javaKey); + continue; + } + parts.add(tagKey + "/" + tagVal); + } + return String.join(" ", parts); + } + static Headers makeRequestHeaders( @NonNull HttpConfiguration httpConfig, Map additionalHeaders diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/ApplicationInfoBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/ApplicationInfoBuilder.java new file mode 100644 index 00000000..48363fbd --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/ApplicationInfoBuilder.java @@ -0,0 +1,77 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.Components; +import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; + +/** + * Contains methods for configuring the SDK's application metadata. + *

+ * Application metadata may be used in LaunchDarkly analytics or other product features, but does not affect feature flag evaluations. + *

+ * If you want to set non-default values for any of these fields, create a builder with + * {@link Components#applicationInfo()}, change its properties with the methods of this class, + * and pass it to {@link com.launchdarkly.sdk.android.LDConfig.Builder#applicationInfo(ApplicationInfoBuilder)}: + *


+ *     LDConfig config = new LDConfig.Builder()
+ *         .applicationInfo(
+ *             Components.applicationInfo()
+ *                 .applicationId("authentication-service")
+ *                 .applicationVersion("1.0.0")
+ *         )
+ *         .build();
+ * 
+ *

+ * + * @since 3.3.0 + */ +public final class ApplicationInfoBuilder { + private String applicationId; + private String applicationVersion; + + /** + * Create an empty ApplicationInfoBuilder. + * + * @see Components#applicationInfo() + */ + public ApplicationInfoBuilder() {} + + /** + * Sets a unique identifier representing the application where the LaunchDarkly SDK is running. + *

+ * This can be specified as any string value as long as it only uses the following characters: ASCII + * letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + * ignored. + * + * @param applicationId the application identifier + * @return the builder + */ + public ApplicationInfoBuilder applicationId(String applicationId) { + this.applicationId = applicationId; + return this; + } + + /** + * Sets a unique identifier representing the version of the application where the LaunchDarkly SDK + * is running. + *

+ * This can be specified as any string value as long as it only uses the following characters: ASCII + * letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + * ignored. + * + * @param applicationVersion the application version + * @return the builder + */ + public ApplicationInfoBuilder applicationVersion(String applicationVersion) { + this.applicationVersion = applicationVersion; + return this; + } + + /** + * Called internally by the SDK to create the configuration object. + * + * @return the configuration object + */ + public ApplicationInfo createApplicationInfo() { + return new ApplicationInfo(applicationId, applicationVersion); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ApplicationInfo.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ApplicationInfo.java new file mode 100644 index 00000000..9ef1dfdc --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ApplicationInfo.java @@ -0,0 +1,46 @@ +package com.launchdarkly.sdk.android.subsystems; + +import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; + +/** + * Encapsulates the SDK's application metadata. + *

+ * See {@link ApplicationInfoBuilder} for more details on these properties. + * + * @since 3.3.0 + */ +public final class ApplicationInfo { + private String applicationId; + private String applicationVersion; + + /** + * Used internally by the SDK to store application metadata. + * + * @param applicationId the application ID + * @param applicationVersion the application version + * @see ApplicationInfoBuilder + */ + public ApplicationInfo(String applicationId, String applicationVersion) { + this.applicationId = applicationId; + this.applicationVersion = applicationVersion; + } + + /** + * A unique identifier representing the application where the LaunchDarkly SDK is running. + * + * @return the application identifier, or null + */ + public String getApplicationId() { + return applicationId; + } + + /** + * A unique identifier representing the version of the application where the + * LaunchDarkly SDK is running. + * + * @return the application version, or null + */ + public String getApplicationVersion() { + return applicationVersion; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ClientContext.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ClientContext.java index 7d99e00b..2b108a65 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ClientContext.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ClientContext.java @@ -25,7 +25,8 @@ * @since 3.3.0 */ public class ClientContext { - private final Application application; + private final Application androidApplication; + private final ApplicationInfo applicationInfo; private final LDLogger baseLogger; private final LDConfig config; private final boolean evaluationReasons; @@ -36,37 +37,40 @@ public class ClientContext { private final ServiceEndpoints serviceEndpoints; public ClientContext( - Application application, - String mobileKey, + Application androidApplication, + ApplicationInfo applicationInfo, LDLogger baseLogger, LDConfig config, String environmentName, boolean evaluationReasons, HttpConfiguration http, boolean initiallySetOffline, + String mobileKey, ServiceEndpoints serviceEndpoints ) { - this.application = application; - this.mobileKey = mobileKey; + this.androidApplication = androidApplication; + this.applicationInfo = applicationInfo; this.baseLogger = baseLogger; this.config = config; this.environmentName = environmentName; this.evaluationReasons = evaluationReasons; this.http = http; this.initiallySetOffline = initiallySetOffline; + this.mobileKey = mobileKey; this.serviceEndpoints = serviceEndpoints; } protected ClientContext(ClientContext copyFrom) { this( - copyFrom.application, - copyFrom.mobileKey, + copyFrom.androidApplication, + copyFrom.applicationInfo, copyFrom.baseLogger, copyFrom.config, copyFrom.environmentName, copyFrom.evaluationReasons, copyFrom.http, copyFrom.initiallySetOffline, + copyFrom.mobileKey, copyFrom.serviceEndpoints ); } @@ -76,7 +80,15 @@ protected ClientContext(ClientContext copyFrom) { * @return the application */ public Application getApplication() { - return application; + return androidApplication; + } + + /** + * The application metadata object. + * @return the application metadata + */ + public ApplicationInfo getApplicationInfo() { + return applicationInfo; } /** diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HttpConfigurationBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HttpConfigurationBuilderTest.java new file mode 100644 index 00000000..aea0c51e --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/HttpConfigurationBuilderTest.java @@ -0,0 +1,94 @@ +package com.launchdarkly.sdk.android; + +import com.launchdarkly.sdk.android.Components; +import com.launchdarkly.sdk.android.LDUtil; +import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; +import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; + +import org.junit.Test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import javax.net.SocketFactory; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +import static com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class HttpConfigurationBuilderTest { + private static final String MOBILE_KEY = "mobile-key"; + private static final ClientContext BASIC_CONTEXT = new ClientContext(null, null, null, null, + "", false, null, false, MOBILE_KEY, null); + + private static Map buildBasicHeaders() { + Map ret = new HashMap<>(); + ret.put("Authorization", LDUtil.AUTH_SCHEME + MOBILE_KEY); + ret.put("User-Agent", LDUtil.USER_AGENT_HEADER_VALUE); + return ret; + } + + private static Map toMap(Iterable> entries) { + Map ret = new HashMap<>(); + for (Map.Entry e: entries) { + ret.put(e.getKey(), e.getValue()); + } + return ret; + } + + @Test + public void testDefaults() { + HttpConfiguration hc = Components.httpConfiguration().build(BASIC_CONTEXT); + assertEquals(DEFAULT_CONNECT_TIMEOUT_MILLIS, hc.getConnectTimeoutMillis()); + assertEquals(buildBasicHeaders(), toMap(hc.getDefaultHeaders())); + } + + @Test + public void testConnectTimeout() { + HttpConfiguration hc = Components.httpConfiguration() + .connectTimeoutMillis(999) + .build(BASIC_CONTEXT); + assertEquals(999, hc.getConnectTimeoutMillis()); + } + + @Test + public void testWrapperNameOnly() { + HttpConfiguration hc = Components.httpConfiguration() + .wrapper("Scala", null) + .build(BASIC_CONTEXT); + assertEquals("Scala", toMap(hc.getDefaultHeaders()).get("X-LaunchDarkly-Wrapper")); + } + + @Test + public void testWrapperWithVersion() { + HttpConfiguration hc = Components.httpConfiguration() + .wrapper("Scala", "0.1.0") + .build(BASIC_CONTEXT); + assertEquals("Scala/0.1.0", toMap(hc.getDefaultHeaders()).get("X-LaunchDarkly-Wrapper")); + } + + @Test + public void testApplicationTags() { + ApplicationInfo info = new ApplicationInfo("authentication-service", "1.0.0"); + ClientContext contextWithTags = new ClientContext(null, info, null, null, + "", false, null, false, MOBILE_KEY, null); + HttpConfiguration hc = Components.httpConfiguration() + .build(contextWithTags); + assertEquals("application-id/authentication-service application-version/1.0.0", + toMap(hc.getDefaultHeaders()).get("X-LaunchDarkly-Tags")); + } +}