diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientPluginsTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientPluginsTest.java new file mode 100644 index 00000000..2510dbcf --- /dev/null +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientPluginsTest.java @@ -0,0 +1,228 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext; +import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; +import com.launchdarkly.sdk.android.integrations.Plugin; +import com.launchdarkly.sdk.android.integrations.PluginMetadata; +import com.launchdarkly.sdk.android.integrations.TrackSeriesContext; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class LDClientPluginsTest { + + private static final String mobileKey = "test-mobile-key"; + private static final String secondaryMobileKey = "test-secondary-mobile-key"; + private static final LDContext ldContext = LDContext.create("userKey"); + private Application application; + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + @Before + public void setUp() { + application = ApplicationProvider.getApplicationContext(); + } + + @Test + public void registerIsCalledForPlugins() throws Exception { + + MockHook testHook = new MockHook(); + MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook)); + + try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testPlugin)), ldContext, 1)) { + ldClient.boolVariation("test-flag", false); + assertEquals(1, testPlugin.getHooksCalls.size()); + assertEquals(1, testPlugin.registerCalls.size()); + assertEquals(1, testHook.beforeEvaluationCalls.size()); + assertEquals(1, testHook.afterEvaluationCalls.size()); + + EnvironmentMetadata environmentMetadata1 = (EnvironmentMetadata) testPlugin.getHooksCalls.get(0).get("environmentMetadata"); + assertEquals(mobileKey, environmentMetadata1.getCredential()); + assertEquals(environmentMetadata1, testPlugin.getHooksCalls.get(0).get("environmentMetadata")); + assertEquals("AndroidClient", environmentMetadata1.getSdkMetadata().getName()); + + assertEquals(ldClient, testPlugin.registerCalls.get(0).get("client")); + EnvironmentMetadata environmentMetadata2 = (EnvironmentMetadata) testPlugin.registerCalls.get(0).get("environmentMetadata"); + assertEquals(mobileKey, environmentMetadata2.getCredential()); + assertEquals("AndroidClient", environmentMetadata2.getSdkMetadata().getName()); + + logging.assertNoWarningsLogged(); + logging.assertNoErrorsLogged(); + } + } + + @Test + public void pluginRegisterCalledForEachClientEnvironment() throws Exception { + MockHook testHook = new MockHook(); + MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook)); + + // create config with multiple mobile keys + LDConfig.Builder builder = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled) + .mobileKey(mobileKey) + .secondaryMobileKeys(Map.of( + "secondaryEnvironment", secondaryMobileKey + )) + .plugins(Components.plugins().setPlugins(Collections.singletonList(testPlugin))) + .offline(true) + .events(Components.noEvents()) + .logAdapter(logging.logAdapter); + LDConfig config = builder.build(); + + try (LDClient ldClient = LDClient.init(application, config, ldContext, 1)) { + ldClient.boolVariation("test-flag", false); + assertEquals(2, testPlugin.getHooksCalls.size()); + assertEquals(2, testPlugin.registerCalls.size()); + assertEquals(1, testHook.beforeEvaluationCalls.size()); + assertEquals(1, testHook.afterEvaluationCalls.size()); + + LDClient.getForMobileKey("secondaryEnvironment").boolVariation("test-flag", false); + assertEquals(2, testHook.beforeEvaluationCalls.size()); + assertEquals(2, testHook.afterEvaluationCalls.size()); + + EnvironmentMetadata environmentMetadata1 = (EnvironmentMetadata) testPlugin.getHooksCalls.get(1).get("environmentMetadata"); + assertEquals(mobileKey, environmentMetadata1.getCredential()); + assertEquals(environmentMetadata1, testPlugin.getHooksCalls.get(1).get("environmentMetadata")); + assertEquals("AndroidClient", environmentMetadata1.getSdkMetadata().getName()); + + assertEquals(LDClient.getForMobileKey("secondaryEnvironment"), testPlugin.registerCalls.get(0).get("client")); + EnvironmentMetadata environmentMetadata2 = (EnvironmentMetadata) testPlugin.registerCalls.get(0).get("environmentMetadata"); + assertEquals(secondaryMobileKey, environmentMetadata2.getCredential()); + assertEquals("AndroidClient", environmentMetadata2.getSdkMetadata().getName()); + + logging.assertNoWarningsLogged(); + logging.assertNoErrorsLogged(); + } + } + + private LDConfig makeOfflineConfig(List plugins) { + LDConfig.Builder builder = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled) + .mobileKey(mobileKey) + .offline(true) + .events(Components.noEvents()) + .logAdapter(logging.logAdapter); + + if (plugins != null) { + builder.plugins(Components.plugins().setPlugins(plugins)); + } + + return builder.build(); + } + + private static class MockPlugin extends Plugin { + + private final List hooks; + + public final List> getHooksCalls = new ArrayList<>(); + public final List> registerCalls = new ArrayList<>(); + + public MockPlugin(List hooks) { + this.hooks = hooks; + } + + @NonNull + @Override + public PluginMetadata getMetadata() { + return new PluginMetadata() { + @NonNull + @Override + public String getName() { + return "mock-plugin"; + } + }; + } + + @Override + public void register(LDClient client, EnvironmentMetadata metadata) { + registerCalls.add(Map.of( + "client", client, + "environmentMetadata", metadata + )); + } + + @NonNull + @Override + public List getHooks(EnvironmentMetadata metadata) { + getHooksCalls.add(Map.of( + "environmentMetadata", metadata + )); + return this.hooks; + } + } + + private static class MockHook extends Hook { + public final List> beforeEvaluationCalls = new ArrayList<>(); + public final List> afterEvaluationCalls = new ArrayList<>(); + public final List> beforeIdentifyCalls = new ArrayList<>(); + public final List> afterIdentifyCalls = new ArrayList<>(); + public final List> afterTrackCalls = new ArrayList<>(); + + public MockHook() { + super("MockHook"); + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map seriesData) { + beforeEvaluationCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, EvaluationDetail evaluationDetail) { + afterEvaluationCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData, + "evaluationDetail", evaluationDetail + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public Map beforeIdentify(IdentifySeriesContext seriesContext, Map seriesData) { + beforeIdentifyCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public Map afterIdentify(IdentifySeriesContext seriesContext, Map seriesData, IdentifySeriesResult result) { + afterIdentifyCalls.add(Map.of( + "seriesContext", seriesContext, + "seriesData", seriesData, + "result", result + )); + return Collections.unmodifiableMap(Collections.emptyMap()); + } + + @Override + public void afterTrack(TrackSeriesContext seriesContext) { + afterTrackCalls.add(Map.of( + "seriesContext", seriesContext + )); + } + } +} 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 5e4d56f6..2506740b 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 @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.android.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; @@ -200,4 +201,24 @@ public static StreamingDataSourceBuilder streamingDataSource() { public static HooksConfigurationBuilder hooks() { return new ComponentsImpl.HooksConfigurationBuilderImpl(); } + + /** + * Returns a builder for configuring plugins. + * Passing this to {@link LDConfig.Builder#plugins(com.launchdarkly.sdk.android.integrations.PluginsConfigurationBuilder)}, + * after setting any desired plugins on the builder, applies this configuration to the SDK. + *

+     *     List plugins = getPluginsFunc();
+     *     LDConfig config = new LDConfig.Builder()
+     *         .plugins(
+     *             Components.plugins()
+     *                 .setPlugins(plugins)
+     *         )
+     *         .build();
+     * 
+ * + * @return a {@link PluginsConfigurationBuilder} for plugins configuration + */ + public static PluginsConfigurationBuilder plugins() { + return new ComponentsImpl.PluginsConfigurationBuilderImpl(); + } } 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 69d1ff71..9ff155b8 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 @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.android.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; @@ -18,6 +19,7 @@ import com.launchdarkly.sdk.android.subsystems.EventProcessor; import com.launchdarkly.sdk.android.subsystems.HookConfiguration; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; +import com.launchdarkly.sdk.android.subsystems.PluginsConfiguration; import com.launchdarkly.sdk.internal.events.DefaultEventProcessor; import com.launchdarkly.sdk.internal.events.DefaultEventSender; import com.launchdarkly.sdk.internal.events.Event; @@ -365,6 +367,14 @@ public HookConfiguration build() { } } + static final class PluginsConfigurationBuilderImpl extends PluginsConfigurationBuilder { + + @Override + public PluginsConfiguration build() { + return new PluginsConfiguration(plugins); + } + } + // Marker interface for data source implementations that will require a FeatureFetcher interface DataSourceRequiresFeatureFetcher {} } 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 debe679f..899c20c9 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 @@ -13,8 +13,12 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; +import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult; +import com.launchdarkly.sdk.android.integrations.Plugin; +import com.launchdarkly.sdk.android.integrations.SdkMetadata; +import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.android.subsystems.EventProcessor; @@ -24,7 +28,9 @@ import java.io.IOException; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -64,6 +70,7 @@ public class LDClient implements LDClientInterface, Closeable { private final ConnectivityManager connectivityManager; private final LDLogger logger; private final HookRunner hookRunner; + private List plugins; // If 15 seconds or more is passed as a timeout to init, we will log a warning. private static final int EXCESSIVE_INIT_WAIT_SECONDS = 15; @@ -116,6 +123,9 @@ public static Future init(@NonNull Application application, LDClient primaryClient; LDContext modifiedContext; + // used for plugin registration after instances are created + final Map instanceMetadatas = new HashMap<>(); + // Acquire the `initLock` to ensure that if `init()` is called multiple times, we will only // initialize the client(s) once. synchronized (initLock) { @@ -143,6 +153,8 @@ public static Future init(@NonNull Application application, reporterBuilder.enableCollectionFromPlatform(application); } IEnvironmentReporter environmentReporter = reporterBuilder.build(); + ApplicationInfo applicationInfo = environmentReporter.getApplicationInfo(); + SdkMetadata sdkMetadata = new SdkMetadata(LDPackageConsts.SDK_CLIENT_NAME, BuildConfig.VERSION_NAME); if (config.isAutoEnvAttributes()) { autoEnvContextModifier = new AutoEnvContextModifier(persistentData, environmentReporter, logger); @@ -154,12 +166,13 @@ public static Future init(@NonNull Application application, modifiedContext = autoEnvContextModifier.modifyContext(context); modifiedContext = anonymousKeyContextModifier.modifyContext(modifiedContext); + Set> envAndMobileKeys = config.getMobileKeys().entrySet(); // Create, but don't start, every LDClient instance - final Map newInstances = new HashMap<>(); + final Map newInstances = new HashMap<>(envAndMobileKeys.size()); LDClient createdPrimaryClient = null; - for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { - String envName = mobileKeys.getKey(), mobileKey = mobileKeys.getValue(); + for (Map.Entry entry : envAndMobileKeys) { + String envName = entry.getKey(), mobileKey = entry.getValue(); try { final LDClient instance = new LDClient( sharedPlatformState, @@ -171,10 +184,16 @@ public static Future init(@NonNull Application application, mobileKey, envName ); + instance.plugins = config.pluginsConfig.getPlugins(); + newInstances.put(envName, instance); if (mobileKey.equals(config.getMobileKey())) { createdPrimaryClient = instance; } + + // metadata created per environment since mobile key varies + instanceMetadatas.put(instance, new EnvironmentMetadata(applicationInfo, sdkMetadata, mobileKey)); + } catch (LaunchDarklyException e) { resultFuture.setException(e); return resultFuture; @@ -187,6 +206,26 @@ public static Future init(@NonNull Application application, instances = newInstances; } + // after instances have been created, set up hooks for each plugin of the instance and call register + for (Map.Entry entry : instanceMetadatas.entrySet()) { + LDClient instance = entry.getKey(); + EnvironmentMetadata metadata = entry.getValue(); + + for (Plugin plugin : instance.plugins) { + // try is for each plugin so that if one plugin has an issue, the others will have an opportunity to be used + try { + List pluginHooks = plugin.getHooks(metadata); + for (Hook hook : pluginHooks) { + instance.hookRunner.addHook(hook); + } + + plugin.register(instance, metadata); + } catch (Exception e) { + logger.error("Exception thrown getting hooks for plugin " + plugin.getMetadata().getName() + ". Unable to get hooks, plugin will not be registered."); + } + } + } + final AtomicInteger initCounter = new AtomicInteger(config.getMobileKeys().size()); Callback completeWhenCounterZero = new Callback() { @Override 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 44d88e8e..2db4576c 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 @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; +import com.launchdarkly.sdk.android.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; @@ -18,6 +19,7 @@ import com.launchdarkly.sdk.android.subsystems.HookConfiguration; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.PersistentDataStore; +import com.launchdarkly.sdk.android.subsystems.PluginsConfiguration; import java.util.Collections; import java.util.HashMap; @@ -64,6 +66,7 @@ public class LDConfig { final ComponentConfigurer dataSource; final ComponentConfigurer events; final HookConfiguration hooks; + final PluginsConfiguration pluginsConfig; final ComponentConfigurer http; private final boolean diagnosticOptOut; @@ -83,6 +86,7 @@ public class LDConfig { ComponentConfigurer dataSource, ComponentConfigurer events, HookConfiguration hooks, + PluginsConfiguration pluginsConfig, ComponentConfigurer http, boolean offline, boolean disableBackgroundUpdating, @@ -100,6 +104,7 @@ public class LDConfig { this.dataSource = dataSource; this.events = events; this.hooks = hooks; + this.pluginsConfig = pluginsConfig; this.http = http; this.offline = offline; this.disableBackgroundUpdating = disableBackgroundUpdating; @@ -225,6 +230,7 @@ public enum AutoEnvAttributes { private ComponentConfigurer dataSource = null; private ComponentConfigurer events = null; private HooksConfigurationBuilder hooksConfigurationBuilder = null; + private PluginsConfigurationBuilder pluginsConfigurationBuilder = null; private ComponentConfigurer http = null; private int maxCachedContexts = DEFAULT_MAX_CACHED_CONTEXTS; @@ -428,6 +434,21 @@ public Builder hooks(HooksConfigurationBuilder hooksConfiguration) { return this; } + /** + * Sets the SDK's plugins configuration, using a builder. This is normally a obtained from + *

+ * {@link Components#plugins()} ()}, which has methods for setting individual plugin + * related properties. + * + * @param pluginsConfiguration the plugins configuration builder + * @return the main configuration builder + * @see Components#plugins() + */ + public Builder plugins(PluginsConfigurationBuilder pluginsConfiguration) { + this.pluginsConfigurationBuilder = pluginsConfiguration; + return this; + } + /** * Sets the SDK's networking configuration, using a configuration builder. This builder is * obtained from {@link Components#httpConfiguration()}, and has methods for setting individual @@ -704,6 +725,7 @@ public LDConfig build() { this.dataSource == null ? Components.streamingDataSource() : this.dataSource, this.events == null ? Components.sendEvents() : this.events, (this.hooksConfigurationBuilder == null ? Components.hooks() : this.hooksConfigurationBuilder).build(), + (this.pluginsConfigurationBuilder == null ? Components.plugins() : this.pluginsConfigurationBuilder).build(), this.http == null ? Components.httpConfiguration() : this.http, offline, disableBackgroundUpdating, diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EnvironmentMetadata.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EnvironmentMetadata.java new file mode 100644 index 00000000..858821b4 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/EnvironmentMetadata.java @@ -0,0 +1,44 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.subsystems.ApplicationInfo; + +/** + * Metadata about the environment that flag evaluations or other functionalities are being performed in. + */ +public final class EnvironmentMetadata { + private final ApplicationInfo applicationInfo; + private final SdkMetadata sdkMetadata; + private final String credential; + + /** + * @param applicationInfo for the application this SDK is used in + * @param sdkMetadata for the LaunchDarkly SDK + * @param credential for authentication to LaunchDarkly endpoints for this environment + */ + public EnvironmentMetadata(ApplicationInfo applicationInfo, SdkMetadata sdkMetadata, String credential) { + this.applicationInfo = applicationInfo; + this.sdkMetadata = sdkMetadata; + this.credential = credential; + } + + /** + * @return the {@link ApplicationInfo} for the application this SDK is used in. + */ + public ApplicationInfo getApplicationInfo() { + return applicationInfo; + } + + /** + * @return the {@link SdkMetadata} for the LaunchDarkly SDK. + */ + public SdkMetadata getSdkMetadata() { + return sdkMetadata; + } + + /** + * @return the credential for authentication to LaunchDarkly endpoints for this environment + */ + public String getCredential() { + return credential; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Plugin.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Plugin.java new file mode 100644 index 00000000..f42a9e72 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/Plugin.java @@ -0,0 +1,50 @@ +package com.launchdarkly.sdk.android.integrations; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.LDClient; + +import java.util.Collections; +import java.util.List; + +/** + * Abstract class that you can extend to create a plugin to the LaunchDarkly SDK. + */ +public abstract class Plugin { + + /** + * @return the {@link PluginMetadata} that gives details about the plugin. + */ + @NonNull + public abstract PluginMetadata getMetadata(); + + /** + * Registers the plugin with the SDK. Called once during SDK initialization. + * The SDK initialization will typically not have been completed at this point, so the plugin should take appropriate + * actions to ensure the SDK is ready before sending track events or evaluating flags. + * Implementations should be prepared for this method to be invoked multiple times in case + * the SDK is configured with multiple environments. Use the metadata to distinguish + * environments. + * + * @param client for the plugin to use + * @param metadata metadata about the environment where the plugin is running. + */ + public abstract void register(LDClient client, EnvironmentMetadata metadata); + + /** + * Gets a list of hooks that the plugin wants to register. + * This method will be called once during SDK initialization before the register method is called. + * If the plugin does not need to register any hooks, this method doesn't need to be implemented. + * Implementations should be prepared for this method to be invoked multiple times in case + * the SDK is configured with multiple environments. Use the metadata to distinguish + * environments. + * + * @param metadata metadata about the environment where the plugin is running. + * @return + */ + @NonNull + public List getHooks(EnvironmentMetadata metadata) { + // default impl + return Collections.emptyList(); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PluginMetadata.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PluginMetadata.java new file mode 100644 index 00000000..1b9b36d7 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PluginMetadata.java @@ -0,0 +1,9 @@ +package com.launchdarkly.sdk.android.integrations; + +import androidx.annotation.NonNull; + +public abstract class PluginMetadata { + + @NonNull + public abstract String getName(); +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PluginsConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PluginsConfigurationBuilder.java new file mode 100644 index 00000000..3976cfb3 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PluginsConfigurationBuilder.java @@ -0,0 +1,52 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.Components; +import com.launchdarkly.sdk.android.subsystems.PluginsConfiguration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Contains methods for configuring the SDK's 'plugins'. + *

+ * If you want to add plugins, use {@link Components#plugins()}, configure accordingly, and pass it + * to {@link com.launchdarkly.sdk.android.LDConfig.Builder#plugins(PluginsConfigurationBuilder)}. + * + *


+ *     List plugins = getPluginsFunc();
+ *     LDConfig config = new LDConfig.Builder()
+ *         .plugins(
+ *             Components.plugins()
+ *                 .setPlugins(plugins)
+ *         )
+ *         .build();
+ * 
+ *

+ * Note that this class is abstract; the actual implementation is created by calling {@link Components#plugins()}. + */ +public abstract class PluginsConfigurationBuilder { + + /** + * The current set of plugins the builder has. + */ + protected List plugins = Collections.emptyList(); + + /** + * Sets the provided list of plugins on the configuration. Note that the order of plugins is important and controls + * the order in which they will be registered. See {@link Plugin} for more details. + * + * @param plugins to be set on the configuration + * @return the builder + */ + public PluginsConfigurationBuilder setPlugins(List plugins) { + // copy to avoid list manipulations impacting the SDK + this.plugins = Collections.unmodifiableList(new ArrayList<>(plugins)); + return this; + } + + /** + * @return the plugins configuration + */ + abstract public PluginsConfiguration build(); +} \ No newline at end of file diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/SdkMetadata.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/SdkMetadata.java new file mode 100644 index 00000000..8d7156d9 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/SdkMetadata.java @@ -0,0 +1,35 @@ +package com.launchdarkly.sdk.android.integrations; + +import androidx.annotation.NonNull; + +/** + * Metadata about the LaunchDarkly SDK. + */ +public final class SdkMetadata { + + @NonNull + private final String name; + @NonNull + private final String version; + + public SdkMetadata(@NonNull String name, @NonNull String version) { + this.name = name; + this.version = version; + } + + /** + * @return name of the SDK for informational purposes such as logging + */ + @NonNull + public String getName() { + return name; + } + + /** + * @return version of the SDK for informational purposes such as logging + */ + @NonNull + public String getVersion() { + return version; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/PluginsConfiguration.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/PluginsConfiguration.java new file mode 100644 index 00000000..15b72430 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/PluginsConfiguration.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.android.subsystems; + +import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.integrations.Plugin; + +import java.util.Collections; +import java.util.List; + +/** + * Encapsulates the SDK's 'plugins' configuration. + *

+ * Use {@link com.launchdarkly.sdk.android.integrations.PluginsConfigurationBuilder} to construct an instance. + */ +public class PluginsConfiguration { + + private final List plugins; + + /** + * @param plugins the list of {@link Plugin} that will be registered. + */ + public PluginsConfiguration(List plugins) { + this.plugins = Collections.unmodifiableList(plugins); + } + + /** + * @return immutable list of plugins + */ + public List getPlugins() { + return plugins; + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/PluginConfigurationBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/PluginConfigurationBuilderTest.java new file mode 100644 index 00000000..ed3a9456 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/integrations/PluginConfigurationBuilderTest.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.android.integrations; + +import static org.easymock.EasyMock.createMock; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +import com.launchdarkly.sdk.android.Components; +import com.launchdarkly.sdk.android.subsystems.PluginsConfiguration; + +import org.junit.Test; + +import java.util.List; + +public class PluginConfigurationBuilderTest { + @Test + public void emptyPluginsAsDefault() { + PluginsConfiguration configuration = Components.plugins().build(); + assertEquals(0, configuration.getPlugins().size()); + } + + @Test + public void canSetPlugins() { + Plugin pluginA = createMock(Plugin.class); + Plugin pluginB = createMock(Plugin.class); + PluginsConfiguration configuration = Components.plugins().setPlugins(List.of(pluginA, pluginB)).build(); + assertEquals(2, configuration.getPlugins().size()); + assertSame(pluginA, configuration.getPlugins().get(0)); + assertSame(pluginB, configuration.getPlugins().get(1)); + } +}