diff --git a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/ODPIntegrationUpdateConfigTest.java b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/ODPIntegrationUpdateConfigTest.java index 20c0b935..97020b36 100644 --- a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/ODPIntegrationUpdateConfigTest.java +++ b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/ODPIntegrationUpdateConfigTest.java @@ -117,6 +117,7 @@ public void setup() throws Exception { notificationCenter, null, odpManager, + null, "test-vuid", null, null); diff --git a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java index 1c65ff9a..2de53d3d 100644 --- a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java +++ b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java @@ -2249,7 +2249,9 @@ public void testDecide() { assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); assertEquals(decision.getRuleKey(), FEATURE_MULTI_VARIATE_EXPERIMENT_KEY); assertEquals(decision.getFlagKey(), flagKey); - assertEquals(decision.getUserContext(), userContext); + OptimizelyUserContext decisionUserContext = decision.getUserContext(); + assertEquals(decisionUserContext.getUserId(), userContext.getUserId()); + assertEquals(decisionUserContext.getAttributes(), userContext.getAttributes()); assertTrue(decision.getReasons().isEmpty()); } diff --git a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java index 4e44808e..0478d959 100644 --- a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java +++ b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java @@ -163,7 +163,7 @@ public void initializeSyncWithEnvironment() { EventHandler eventHandler = mock(DefaultEventHandler.class); EventProcessor eventProcessor = mock(EventProcessor.class); OptimizelyManager optimizelyManager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, 3600L, datafileHandler, null, 3600L, - eventHandler, eventProcessor, null, null, null, null, null, null, null); + eventHandler, eventProcessor, null, null, null, null, null, null, null, null); /* * Scenario#1: when datafile is not Empty * Scenario#2: when datafile is Empty @@ -222,7 +222,7 @@ public void initializeAsyncWithEnvironment() { EventHandler eventHandler = mock(DefaultEventHandler.class); EventProcessor eventProcessor = mock(EventProcessor.class); final OptimizelyManager optimizelyManager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, 3600L, datafileHandler, null, 3600L, - eventHandler, eventProcessor, null, null, null, null, null, null, null); + eventHandler, eventProcessor, null, null, null, null, null, null, null, null); /* * Scenario#1: when datafile is not Empty @@ -494,7 +494,7 @@ public void initializeSyncWithUpdateOnNewDatafileDisabled() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); ArgumentCaptor configCaptor = ArgumentCaptor.forClass(DatafileConfig.class); @@ -533,7 +533,7 @@ public void initializeSyncWithUpdateOnNewDatafileEnabled() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); ArgumentCaptor configCaptor = ArgumentCaptor.forClass(DatafileConfig.class); @@ -572,7 +572,7 @@ public void initializeSyncWithDownloadToCacheDisabled() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); ArgumentCaptor configCaptor = ArgumentCaptor.forClass(DatafileConfig.class); @@ -611,7 +611,7 @@ public void initializeSyncWithUpdateOnNewDatafileDisabledWithPeriodicPollingEnab Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); ArgumentCaptor configCaptor = ArgumentCaptor.forClass(DatafileConfig.class); @@ -651,7 +651,7 @@ public void initializeSyncWithUpdateOnNewDatafileEnabledWithPeriodicPollingEnabl Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); ArgumentCaptor configCaptor = ArgumentCaptor.forClass(DatafileConfig.class); @@ -690,7 +690,7 @@ public void initializeSyncWithUpdateOnNewDatafileDisabledWithPeriodicPollingDisa Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); ArgumentCaptor configCaptor = ArgumentCaptor.forClass(DatafileConfig.class); @@ -706,7 +706,7 @@ public void initializeSyncWithUpdateOnNewDatafileDisabledWithPeriodicPollingDisa return datafileHandler; }).when(manager.getDatafileHandler()).downloadDatafile(contextCaptor.capture(), configCaptor.capture(), listenerCaptor.capture()); - + OptimizelyClient client = manager.initialize(context, defaultDatafile, downloadToCache, updateConfigOnNewDatafile); try { @@ -730,7 +730,7 @@ public void initializeSyncWithUpdateOnNewDatafileEnabledWithPeriodicPollingDisab Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); ArgumentCaptor configCaptor = ArgumentCaptor.forClass(DatafileConfig.class); @@ -769,7 +769,7 @@ public void initializeSyncWithResourceDatafileNoCache() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = spy(new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null)); + null, null, null, null, null, null, null, null, null, null)); datafileHandler.removeSavedDatafile(context, manager.getDatafileConfig()); OptimizelyClient client = manager.initialize(context, R.raw.datafile, downloadToCache, updateConfigOnNewDatafile); @@ -786,7 +786,7 @@ public void initializeSyncWithResourceDatafileNoCacheWithDefaultParams() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); OptimizelyManager manager = spy(new OptimizelyManager(testProjectId, testSdkKey, null, logger, pollingInterval, datafileHandler, null, 0, - null, null, null, null, null, null, null, null, null)); + null, null, null, null, null, null, null, null, null, null)); datafileHandler.removeSavedDatafile(context, manager.getDatafileConfig()); OptimizelyClient client = manager.initialize(context, R.raw.datafile); diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyClient.java b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyClient.java index 8c3a6265..1ff8c5f4 100644 --- a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyClient.java +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyClient.java @@ -816,28 +816,33 @@ public OptimizelyConfig getOptimizelyConfig() { * @return An OptimizelyUserContext associated with this OptimizelyClient. */ @Nullable - public OptimizelyUserContext createUserContext(@NonNull String userId, - @NonNull Map attributes) { - if (optimizely != null) { - return optimizely.createUserContext(userId, attributes); - } else { + public OptimizelyUserContextAndroid createUserContext(@NonNull String userId, + @NonNull Map attributes) { + if (optimizely == null) { logger.warn("Optimizely is not initialized, could not create a user context"); return null; } + + if (userId == null) { + logger.warn("The userId parameter must be nonnull."); + return null; + } + + return new OptimizelyUserContextAndroid(optimizely, userId, attributes); } @Nullable - public OptimizelyUserContext createUserContext(@NonNull String userId) { + public OptimizelyUserContextAndroid createUserContext(@NonNull String userId) { return createUserContext(userId, Collections.emptyMap()); } @Nullable - public OptimizelyUserContext createUserContext() { + public OptimizelyUserContextAndroid createUserContext() { return createUserContext(Collections.emptyMap()); } @Nullable - public OptimizelyUserContext createUserContext(@NonNull Map attributes) { + public OptimizelyUserContextAndroid createUserContext(@NonNull Map attributes) { if (vuid == null) { logger.warn("Optimizely vuid is not available. A userId is required to create a user context."); return null; diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java index ac43c8e6..4f1a9517 100644 --- a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java @@ -39,9 +39,15 @@ import com.optimizely.ab.android.event_handler.EventDispatcher; import com.optimizely.ab.android.odp.DefaultODPApiManager; import com.optimizely.ab.android.odp.VuidManager; +import com.optimizely.ab.android.sdk.cmab.DefaultCmabClient; +import com.optimizely.ab.android.shared.Client; import com.optimizely.ab.android.shared.DatafileConfig; +import com.optimizely.ab.android.shared.OptlyStorage; import com.optimizely.ab.android.user_profile.DefaultUserProfileService; import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.cmab.client.CmabClient; +import com.optimizely.ab.cmab.service.CmabService; +import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.error.ErrorHandler; @@ -90,6 +96,7 @@ public class OptimizelyManager { @NonNull private UserProfileService userProfileService; @Nullable private ODPManager odpManager; @Nullable private final String vuid; + @Nullable private CmabService cmabService; @Nullable private OptimizelyStartListener optimizelyStartListener; private boolean returnInMainThreadFromAsyncInit = true; @@ -112,6 +119,7 @@ public class OptimizelyManager { @NonNull NotificationCenter notificationCenter, @Nullable List defaultDecideOptions, @Nullable ODPManager odpManager, + @Nullable CmabService cmabService, @Nullable String vuid, @Nullable String clientEngineName, @Nullable String clientVersion) { @@ -137,6 +145,7 @@ public class OptimizelyManager { this.userProfileService = userProfileService; this.vuid = vuid; this.odpManager = odpManager; + this.cmabService = cmabService; this.notificationCenter = notificationCenter; this.defaultDecideOptions = defaultDecideOptions; @@ -646,6 +655,7 @@ private OptimizelyClient buildOptimizely(@NonNull Context context, @NonNull Stri builder.withNotificationCenter(notificationCenter); builder.withDefaultDecideOptions(defaultDecideOptions); builder.withODPManager(odpManager); + builder.withCmabService(cmabService); Optimizely optimizely = builder.build(); return new OptimizelyClient(optimizely, LoggerFactory.getLogger(OptimizelyClient.class), vuid); @@ -781,15 +791,19 @@ public static class Builder { @Nullable private List defaultDecideOptions = null; @Nullable private ODPEventManager odpEventManager; @Nullable private ODPSegmentManager odpSegmentManager; + @Nullable private CmabClient cmabClient; private int odpSegmentCacheSize = 100; - private int odpSegmentCacheTimeoutInSecs = 600; + private int odpSegmentCacheTimeoutInSecs = 10*60; private int timeoutForODPSegmentFetchInSecs = 10; private int timeoutForODPEventDispatchInSecs = 10; private boolean odpEnabled = true; private boolean vuidEnabled = false; private String vuid = null; + private int cmabCacheSize = 100; + private int cmabCacheTimeoutInSecs = 30*60; + private String customSdkName = null; private String customSdkVersion = null; @@ -1058,6 +1072,33 @@ public Builder withClientInfo(@Nullable String clientEngineName, @Nullable Strin this.customSdkVersion = clientVersion; return this; } + + /** + * Override the default Cmab cache size (100). + * @param size the size + * @return this {@link Builder} instance + */ + public Builder withCmabCacheSize(int size) { + this.cmabCacheSize = size; + return this; + } + + /** + * Override the default Cmab cache timeout (30 minutes). + * @param interval the interval + * @param timeUnit the time unit of the timeout argument + * @return this {@link Builder} instance + */ + public Builder withCmabCacheTimeout(int interval, TimeUnit timeUnit) { + this.cmabCacheTimeoutInSecs = (int) timeUnit.toSeconds(interval); + return this; + } + + public Builder withCmabClient(CmabClient cmabClient) { + this.cmabClient = cmabClient; + return this; + } + /** * Get a new {@link Builder} instance to create {@link OptimizelyManager} with. * @param context the application context used to create default service if not provided. @@ -1160,6 +1201,15 @@ public OptimizelyManager build(Context context) { .build(); } + DefaultCmabService.Builder cmabBuilder = DefaultCmabService.builder(); + if (cmabClient == null) { + cmabClient = new DefaultCmabClient(context); + } + cmabBuilder.withClient(cmabClient); + cmabBuilder.withCmabCacheSize(cmabCacheSize); + cmabBuilder.withCmabCacheTimeoutInSecs(cmabCacheTimeoutInSecs); + CmabService cmabService = cmabBuilder.build(); + return new OptimizelyManager(projectId, sdkKey, datafileConfig, logger, @@ -1173,6 +1223,7 @@ public OptimizelyManager build(Context context) { notificationCenter, defaultDecideOptions, odpManager, + cmabService, vuid, customSdkName, customSdkVersion diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroid.java b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroid.java new file mode 100644 index 00000000..ace2b331 --- /dev/null +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroid.java @@ -0,0 +1,374 @@ +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.optimizely.ab.android.sdk; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyForcedDecision; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback;; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionsCallback; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +// This class extends OptimizelyUserContext from the Java-SDK core to maintain backward compatibility +// with synchronous decide API calls. It ensures proper functionality for legacy implementations +// that rely on synchronous behavior, while excluding feature flags that require asynchronous decisions. + +public class OptimizelyUserContextAndroid extends OptimizelyUserContext { + + /** + * Creates an Android user context with basic parameters. + * + * @param optimizely The Optimizely client instance + * @param userId Unique identifier for the user + * @param attributes Map of user attributes for targeting and segmentation + */ + public OptimizelyUserContextAndroid(@NonNull Optimizely optimizely, + @NonNull String userId, + @NonNull Map attributes) { + super(optimizely, userId, attributes); + } + + /** + * Creates an Android user context with forced decisions and qualified segments. + * + * @param optimizely The Optimizely client instance + * @param userId Unique identifier for the user + * @param attributes Map of user attributes for targeting and segmentation + * @param forcedDecisionsMap Map of forced decisions to override normal flag evaluation + * @param qualifiedSegments List of audience segments the user qualifies for + */ + public OptimizelyUserContextAndroid(@NonNull Optimizely optimizely, + @NonNull String userId, + @NonNull Map attributes, + @Nullable Map forcedDecisionsMap, + @Nullable List qualifiedSegments) { + super(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments); + } + + /** + * Creates an Android user context with all available parameters including analytics control. + * + * @param optimizely The Optimizely client instance + * @param userId Unique identifier for the user + * @param attributes Map of user attributes for targeting and segmentation + * @param forcedDecisionsMap Map of forced decisions to override normal flag evaluation + * @param qualifiedSegments List of audience segments the user qualifies for + * @param shouldIdentifyUser Whether to send user identification events for analytics + */ + + public OptimizelyUserContextAndroid(@NonNull Optimizely optimizely, + @NonNull String userId, + @NonNull Map attributes, + @Nullable Map forcedDecisionsMap, + @Nullable List qualifiedSegments, + @Nullable Boolean shouldIdentifyUser) { + super(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments, shouldIdentifyUser); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + *
    + *
  • If the SDK finds an error, it'll return a decision with null for variationKey. The decision will include an error message in reasons. + *
+ *

+ * Note: This API is specifically designed for synchronous decision-making only. + * For asynchronous decision-making, use the decideAsync() API. + *

+ * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result. + */ + @Override + public OptimizelyDecision decide(@NonNull String key, + @NonNull List options) { + return coreDecideSync(key, options); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + * + *

+ * Note: This API is specifically designed for synchronous decision-making only. + * For asynchronous decision-making, use the decideAsync() API. + *

+ * @param key A flag key for which a decision will be made. + * @return A decision result. + */ + @Override + public OptimizelyDecision decide(@NonNull String key) { + return coreDecideSync(key, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. + *
    + *
  • If the SDK finds an error for a key, the response will include a decision for the key showing reasons for the error. + *
  • The SDK will always return key-mapped decisions. When it can not process requests, it’ll return an empty map after logging the errors. + *
+ *

+ * Note: This API is specifically designed for synchronous decision-making only. + * For asynchronous decision-making, use the decideForKeysAsync() API. + *

+ * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + @Override + public Map decideForKeys(@NonNull List keys, + @NonNull List options) { + return coreDecideForKeysSync(keys, options); + } + + /** + * Returns a key-map of decision results for multiple flag keys and a user context. + * + *

+ * Note: This API is specifically designed for synchronous decision-making only. + * For asynchronous decision-making, use the decideForKeysAsync() API. + *

+ * @param keys A list of flag keys for which decisions will be made. + * @return All decision results mapped by flag keys. + */ + @Override + public Map decideForKeys(@NonNull List keys) { + return coreDecideForKeysSync(keys, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + *

+ * Note: This API is specifically designed for synchronous decision-making only. + * For asynchronous decision-making, use the decideAllAsync() API. + *

+ * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + @Override + public Map decideAll(@NonNull List options) { + return coreDecideAllSync(options); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + *

+ * Note: This API is specifically designed for synchronous decision-making only. + * For asynchronous decision-making, use the decideAllAsync() API. + *

+ * @return A dictionary of all decision results, mapped by flag keys. + */ + @Override + public Map decideAll() { + return coreDecideAllSync(Collections.emptyList()); + } + + // =========================================== + // Async Methods (Android-specific) with callbacks + // =========================================== + + /** + * Returns a decision result asynchronously for a given flag key and a user context. + * + * @param key A flag key for which a decision will be made. + * @param callback A callback to invoke when the decision is available. + * @param options A list of options for decision-making. + */ + public void decideAsync(@NonNull String key, + @NonNull List options, + @NonNull OptimizelyDecisionCallback callback) { + coreDecideAsync(key, options, callback); + } + + /** + * Returns a decision result asynchronously for a given flag key and a user context. + * + * @param key A flag key for which a decision will be made. + * @param callback A callback to invoke when the decision is available. + */ + public void decideAsync(@NonNull String key, @NonNull OptimizelyDecisionCallback callback) { + coreDecideAsync(key, Collections.emptyList(), callback); + } + + /** + * Returns decision results asynchronously for multiple flag keys. + * + * @param keys A list of flag keys for which decisions will be made. + * @param callback A callback to invoke when decisions are available. + * @param options A list of options for decision-making. + */ + public void decideForKeysAsync(@NonNull List keys, + @NonNull List options, + @NonNull OptimizelyDecisionsCallback callback) { + coreDecideForKeysAsync(keys, options, callback); + } + + /** + * Returns decision results asynchronously for multiple flag keys. + * + * @param keys A list of flag keys for which decisions will be made. + * @param callback A callback to invoke when decisions are available. + */ + public void decideForKeysAsync(@NonNull List keys, @NonNull OptimizelyDecisionsCallback callback) { + coreDecideForKeysAsync(keys, Collections.emptyList(), callback); + } + + /** + * Returns decision results asynchronously for all active flag keys. + * + * @param callback A callback to invoke when decisions are available. + * @param options A list of options for decision-making. + */ + public void decideAllAsync(@NonNull List options, + @NonNull OptimizelyDecisionsCallback callback) { + coreDecideAllAsync(options, callback); + } + + /** + * Returns decision results asynchronously for all active flag keys. + * + * @param callback A callback to invoke when decisions are available. + */ + public void decideAllAsync(@NonNull OptimizelyDecisionsCallback callback) { + coreDecideAllAsync(Collections.emptyList(), callback); + } + + // =========================================== + // Async Methods (Android-specific) with blocking calls to synchronous methods + // =========================================== + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + *

+ * Note: Despite the "Async" name, this method performs blocking synchronous decision-making. + * For true asynchronous decision-making with callbacks, use the callback-based decideAsync() methods. + *

+ * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result. + */ + public OptimizelyDecision decideAsync(@NonNull String key, + @NonNull List options) { + return coreDecide(key, options); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + * + * @param key A flag key for which a decision will be made. + * @return A decision result. + */ + public OptimizelyDecision decideAsync(@NonNull String key) { + return coreDecide(key, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. + *
    + *
  • If the SDK finds an error for a key, the response will include a decision for the key showing reasons for the error. + *
  • The SDK will always return key-mapped decisions. When it can not process requests, it’ll return an empty map after logging the errors. + *
+ * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeysAsync(@NonNull List keys, + @NonNull List options) { + return coreDecideForKeys(keys, options); + } + + /** + * Returns a key-map of decision results for multiple flag keys and a user context. + * + * @param keys A list of flag keys for which decisions will be made. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeysAsync(@NonNull List keys) { + return coreDecideForKeys(keys, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideAllAsync(@NonNull List options) { + return coreDecideAll(options); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @return A dictionary of all decision results, mapped by flag keys. + */ + public Map decideAllAsync() { + return coreDecideAll(Collections.emptyList()); + } + + // =========================================== + // Core Methods - All super calls centralized here for testability + // =========================================== + + /** + * Core delegation methods that encapsulate all java-sdk parent class method calls. + * These methods enable clean unit testing by providing mockable entry points + * for parent functionality, circumventing Mockito's inability to intercept super calls. + */ + + OptimizelyDecision coreDecideSync(@NonNull String key, @NonNull List options) { + return super.decideSync(key, options); + } + + Map coreDecideForKeysSync(@NonNull List keys, @NonNull List options) { + return super.decideForKeysSync(keys, options); + } + + Map coreDecideAllSync(@NonNull List options) { + return super.decideAllSync(options); + } + + void coreDecideAsync(@NonNull String key, @NonNull List options, @NonNull OptimizelyDecisionCallback callback) { + super.decideAsync(key, options, callback); + } + + void coreDecideForKeysAsync(@NonNull List keys, @NonNull List options, @NonNull OptimizelyDecisionsCallback callback) { + super.decideForKeysAsync(keys, options, callback); + } + + void coreDecideAllAsync(@NonNull List options, @NonNull OptimizelyDecisionsCallback callback) { + super.decideAllAsync(options, callback); + } + + OptimizelyDecision coreDecide(@NonNull String key, @NonNull List options) { + return super.decide(key, options); + } + + Map coreDecideForKeys(@NonNull List keys, @NonNull List options) { + return super.decideForKeys(keys, options); + } + + Map coreDecideAll(@NonNull List options) { + return super.decideAll(options); + } + +} diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/CmabClientHelperAndroid.kt b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/CmabClientHelperAndroid.kt new file mode 100644 index 00000000..6bb54aba --- /dev/null +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/CmabClientHelperAndroid.kt @@ -0,0 +1,50 @@ +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.optimizely.ab.android.sdk.cmab + +import com.optimizely.ab.cmab.client.CmabClientHelper + +open class CmabClientHelperAndroid { + + open val cmabPredictionEndpoint: String + get() = CmabClientHelper.CMAB_PREDICTION_ENDPOINT + + open val cmabFetchFailed: String + get() = CmabClientHelper.CMAB_FETCH_FAILED + + open val invalidCmabFetchResponse: String + get() = CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE + + open fun buildRequestJson( + userId: String?, + ruleId: String?, + attributes: Map?, + cmabUuid: String? + ): String { + return CmabClientHelper.buildRequestJson(userId, ruleId, attributes, cmabUuid) + } + + open fun parseVariationId(jsonResponse: String?): String? { + return CmabClientHelper.parseVariationId(jsonResponse) + } + + open fun validateResponse(responseBody: String?): Boolean { + return CmabClientHelper.validateResponse(responseBody) + } + + open fun isSuccessStatusCode(statusCode: Int): Boolean { + return CmabClientHelper.isSuccessStatusCode(statusCode) + } +} diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt new file mode 100644 index 00000000..48ef9451 --- /dev/null +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt @@ -0,0 +1,133 @@ +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.optimizely.ab.android.sdk.cmab + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.optimizely.ab.android.shared.Client +import com.optimizely.ab.android.shared.OptlyStorage +import com.optimizely.ab.cmab.client.CmabClient +import com.optimizely.ab.cmab.client.CmabFetchException +import com.optimizely.ab.cmab.client.CmabInvalidResponseException +import org.slf4j.LoggerFactory +import java.net.HttpURLConnection +import java.net.URL + +@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) +open class DefaultCmabClient : CmabClient { + private val client: Client + private val cmabClientHelper: CmabClientHelperAndroid + private val logger = LoggerFactory.getLogger(DefaultCmabClient::class.java) + + constructor(context: Context) { + this.client = + Client(OptlyStorage(context), LoggerFactory.getLogger(OptlyStorage::class.java)) + this.cmabClientHelper = CmabClientHelperAndroid() + } + + constructor(client: Client) { + this.client = client + this.cmabClientHelper = CmabClientHelperAndroid() + } + + constructor(client: Client, cmabClientHelper: CmabClientHelperAndroid) { + this.client = client + this.cmabClientHelper = cmabClientHelper + } + + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + override fun fetchDecision( + ruleId: String?, + userId: String?, + attributes: Map?, + cmabUuid: String? + ): String? { + val request: Client.Request = Client.Request { + var urlConnection: HttpURLConnection? = null + try { + val apiEndpoint = String.format(cmabClientHelper.cmabPredictionEndpoint, ruleId) + + val requestBody: String = + cmabClientHelper.buildRequestJson(userId, ruleId, attributes, cmabUuid) + + val url = URL(apiEndpoint) + urlConnection = client.openConnection(url) + if (urlConnection == null) { + logger.error("Error opening connection to $apiEndpoint") + return@Request null + } + + // set timeouts for releasing failed connections (default is 0 = no timeout). + urlConnection.connectTimeout = CONNECTION_TIMEOUT + urlConnection.readTimeout = READ_TIMEOUT + + urlConnection.requestMethod = "POST" + urlConnection.setRequestProperty("content-type", "application/json") + + urlConnection.doOutput = true + val outputStream = urlConnection.outputStream + outputStream.write(requestBody.toByteArray()) + outputStream.flush() + outputStream.close() + val status = urlConnection.responseCode + if (status in 200..399) { + val json = client.readStream(urlConnection) + logger.debug("Successfully fetched CMAB decision: {}", json) + + if (!cmabClientHelper.validateResponse(json)) { + logger.error(cmabClientHelper.invalidCmabFetchResponse) + throw CmabInvalidResponseException(cmabClientHelper.invalidCmabFetchResponse) + } + + return@Request cmabClientHelper.parseVariationId(json) + } else { + val errorMessage: String = java.lang.String.format( + cmabClientHelper.cmabFetchFailed, + urlConnection.responseMessage + ) + logger.error(errorMessage) + throw CmabFetchException(errorMessage) + } + } catch (e: Exception) { + val errorMessage: String = + java.lang.String.format(cmabClientHelper.cmabFetchFailed, e.message) + logger.error(errorMessage) + throw CmabFetchException(errorMessage) + } finally { + if (urlConnection != null) { + try { + urlConnection.disconnect() + } catch (e: Exception) { + logger.error("Error closing connection", e) + } + } + } + } + return client.execute(request, REQUEST_BACKOFF_TIMEOUT, REQUEST_RETRIES_POWER) + } + + companion object { + // configurable connection timeout in milliseconds + var CONNECTION_TIMEOUT = 10 * 1000 + var READ_TIMEOUT = 60 * 1000 + + // cmab service retries only once with 1sec interval + + // the numerical base for the exponential backoff (1 second) + const val REQUEST_BACKOFF_TIMEOUT = 1 + // retry only once = 2 total attempts + const val REQUEST_RETRIES_POWER = 2 + } +} diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java index 92731f5c..d2be7ee2 100644 --- a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyClientTest.java @@ -17,6 +17,7 @@ package com.optimizely.ab.android.sdk; import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.Variation; import com.optimizely.ab.internal.ReservedEventKey; @@ -39,6 +40,7 @@ import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertEquals; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.powermock.reflect.Whitebox; @@ -277,4 +279,56 @@ public void testBadClearNotificationCenterListeners() { notificationCenter.clearAllNotificationListeners(); verify(logger).warn("Optimizely is not initialized, could not get the notification listener"); } + + @Test + public void testCreateUserContext_withUserIdAndAttributes() { + OptimizelyClient optimizelyClient = new OptimizelyClient(optimizely, logger); + String userId = "testUser123"; + Map attributes = new HashMap<>(); + attributes.put("isLoggedIn", true); + attributes.put("userType", "premium"); + + OptimizelyUserContext userContext = optimizelyClient.createUserContext(userId, attributes); + + assertTrue(userContext instanceof OptimizelyUserContextAndroid); + assertEquals(userId, userContext.getUserId()); + assertEquals(attributes, userContext.getAttributes()); + } + + @Test + public void testCreateUserContext_withUserIdOnly() { + OptimizelyClient optimizelyClient = new OptimizelyClient(optimizely, logger); + String userId = "testUser123"; + + OptimizelyUserContext userContext = optimizelyClient.createUserContext(userId); + + assertTrue(userContext instanceof OptimizelyUserContextAndroid); + assertEquals(userId, userContext.getUserId()); + assertEquals(Collections.emptyMap(), userContext.getAttributes()); + } + + @Test + public void testCreateUserContext_withNullOptimizely() { + OptimizelyClient optimizelyClient = new OptimizelyClient(null, logger); + String userId = "testUser123"; + Map attributes = new HashMap<>(); + + OptimizelyUserContext userContext = optimizelyClient.createUserContext(userId, attributes); + + assertEquals(null, userContext); + verify(logger).warn("Optimizely is not initialized, could not create a user context"); + } + + @Test + public void testCreateUserContext_withEmptyAttributes() { + OptimizelyClient optimizelyClient = new OptimizelyClient(optimizely, logger); + String userId = "testUser123"; + Map emptyAttributes = Collections.emptyMap(); + + OptimizelyUserContext userContext = optimizelyClient.createUserContext(userId, emptyAttributes); + + assertTrue(userContext instanceof OptimizelyUserContextAndroid); + assertEquals(userId, userContext.getUserId()); + assertEquals(emptyAttributes, userContext.getAttributes()); + } } diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerBuilderTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerBuilderTest.java index d6c74757..2c8a8e39 100644 --- a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerBuilderTest.java +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerBuilderTest.java @@ -25,13 +25,18 @@ import com.optimizely.ab.android.odp.ODPEventClient; import com.optimizely.ab.android.odp.ODPSegmentClient; import com.optimizely.ab.android.odp.VuidManager; +import com.optimizely.ab.android.sdk.cmab.DefaultCmabClient; +import com.optimizely.ab.android.shared.Client; import com.optimizely.ab.android.shared.DatafileConfig; import com.optimizely.ab.android.user_profile.DefaultUserProfileService; import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.cmab.client.CmabClient; +import com.optimizely.ab.cmab.service.DefaultCmabService; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; import com.optimizely.ab.event.EventProcessor; +import com.optimizely.ab.internal.DefaultLRUCache; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.odp.ODPEventManager; import com.optimizely.ab.odp.ODPManager; @@ -53,6 +58,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.isNull; @@ -73,7 +79,17 @@ import java.util.concurrent.TimeUnit; @RunWith(PowerMockRunner.class) -@PrepareForTest({OptimizelyManager.class, BatchEventProcessor.class, DefaultEventHandler.class, ODPManager.class, ODPSegmentManager.class, ODPEventManager.class, VuidManager.class}) +@PrepareForTest({ + OptimizelyManager.class, + BatchEventProcessor.class, + DefaultEventHandler.class, + ODPManager.class, + ODPSegmentManager.class, + ODPEventManager.class, + VuidManager.class, + CmabClient.class, + DefaultCmabService.class +}) public class OptimizelyManagerBuilderTest { private String testProjectId = "7595190003"; @@ -259,6 +275,7 @@ public void testBuildWithDefaultODP_defaultEnabled() throws Exception { any(NotificationCenter.class), any(), // nullable (DefaultDecideOptions) any(ODPManager.class), + any(), eq("test-vuid"), any(), any()); @@ -289,6 +306,7 @@ public void testBuildWithDefaultODP_disabled() throws Exception { any(NotificationCenter.class), any(), // nullable (DefaultDecideOptions) isNull(), + any(), eq("test-vuid"), any(), any()); @@ -465,4 +483,123 @@ public void testBuildWithVuidEnabled() throws Exception { when(ODPManager.builder()).thenCallRealMethod(); } + + DefaultCmabService.Builder getMockDefaultCmabServiceBuilder() { + DefaultCmabService.Builder mockBuilder = PowerMockito.mock(DefaultCmabService.Builder.class); + when(mockBuilder.withClient(any())).thenReturn(mockBuilder); + when(mockBuilder.withCmabCacheSize(anyInt())).thenReturn(mockBuilder); + when(mockBuilder.withCmabCacheTimeoutInSecs(anyInt())).thenReturn(mockBuilder); + return mockBuilder; + } + + @Test + public void testCmabServiceConfigurationValidation() throws Exception { + // Custom configuration values + int customCacheSize = 500; + int customTimeoutMinutes = 45; + int expectedTimeoutSeconds = customTimeoutMinutes * 60; // 45 min = 2700 sec + CmabClient mockCmabClient = mock(CmabClient.class); + + DefaultCmabService.Builder mockBuilder = getMockDefaultCmabServiceBuilder(); + mockStatic(DefaultCmabService.class); + when(DefaultCmabService.builder()).thenReturn(mockBuilder); + + DefaultCmabService mockDefaultCmabService = mock(DefaultCmabService.class); + when(mockBuilder.build()).thenReturn(mockDefaultCmabService); + + whenNew(OptimizelyManager.class).withAnyArguments().thenReturn(mock(OptimizelyManager.class)); + + OptimizelyManager manager = OptimizelyManager.builder(testProjectId) + .withCmabCacheSize(customCacheSize) + .withCmabCacheTimeout(customTimeoutMinutes, TimeUnit.MINUTES) + .withCmabClient(mockCmabClient) + .build(mockContext); + + verify(mockBuilder).withCmabCacheSize(eq(customCacheSize)); + verify(mockBuilder).withCmabCacheTimeoutInSecs(eq(expectedTimeoutSeconds)); + verify(mockBuilder).withClient(eq(mockCmabClient)); + verify(mockBuilder).build(); + + // Verify OptimizelyManager constructor was called with the mocked CMAB service + verifyNew(OptimizelyManager.class).withArguments( + any(), // projectId + any(), // sdkKey + any(), // datafileConfig + any(), // logger + anyLong(), // datafileDownloadInterval + any(), // datafileHandler + any(), // errorHandler + anyLong(), // eventDispatchRetryInterval + any(), // eventHandler + any(), // eventProcessor + any(), // userProfileService + any(), // notificationCenter + any(), // defaultDecideOptions + any(), // odpManager + eq(mockDefaultCmabService), // cmabService - Should be our mocked service + any(), // vuid + any(), // customSdkName + any() // customSdkVersion + ); + + assertNotNull("Manager should be created successfully", manager); + } + + @Test + public void testCmabServiceDefaultConfigurationValidation() throws Exception { + // Default configuration values + int defaultCacheSize = 100; + int defaultTimeoutSeconds = 30 * 60; // 30 minutes = 1800 seconds + + DefaultCmabService.Builder mockBuilder = getMockDefaultCmabServiceBuilder(); + mockStatic(DefaultCmabService.class); + when(DefaultCmabService.builder()).thenReturn(mockBuilder); + + DefaultCmabService mockDefaultCmabService = mock(DefaultCmabService.class); + when(mockBuilder.build()).thenReturn(mockDefaultCmabService); + + // Use PowerMock to verify OptimizelyManager constructor is called with CMAB service + whenNew(OptimizelyManager.class).withAnyArguments().thenReturn(mock(OptimizelyManager.class)); + + // Build OptimizelyManager with NO CMAB configuration to test defaults + OptimizelyManager manager = OptimizelyManager.builder(testProjectId) + .build(mockContext); + + verify(mockBuilder).withCmabCacheSize(eq(defaultCacheSize)); + verify(mockBuilder).withCmabCacheTimeoutInSecs(eq(defaultTimeoutSeconds)); + verify(mockBuilder).withClient(any(DefaultCmabClient.class)); + verify(mockBuilder).build(); + + // Verify OptimizelyManager constructor was called with the mocked CMAB service + verifyNew(OptimizelyManager.class).withArguments( + any(), // projectId + any(), // sdkKey + any(), // datafileConfig + any(), // logger + anyLong(), // datafileDownloadInterval + any(), // datafileHandler + any(), // errorHandler + anyLong(), // eventDispatchRetryInterval + any(), // eventHandler + any(), // eventProcessor + any(), // userProfileService + any(), // notificationCenter + any(), // defaultDecideOptions + any(), // odpManager + eq(mockDefaultCmabService), // cmabService - Should be our mocked service + any(), // vuid + any(), // customSdkName + any() // customSdkVersion + ); + + // This test validates: + // 1. DefaultLRUCache created with default cache size (100) and timeout (1800 seconds) + // 2. DefaultCmabClient created with no arguments (using defaults) + // 3. CmabServiceOptions created with logger, mocked cache, and default client + // 4. DefaultCmabService created with the mocked service options + // 5. OptimizelyManager constructor receives the exact mocked CMAB service + // 6. All default parameters flow correctly through the creation chain + assertNotNull("Manager should be created successfully", manager); + } + } diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerIntervalTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerIntervalTest.java index 378d4ecb..8ff50546 100644 --- a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerIntervalTest.java +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyManagerIntervalTest.java @@ -115,6 +115,7 @@ public void testBuildWithDatafileDownloadInterval() throws Exception { any(NotificationCenter.class), any(), // nullable (DefaultDecideOptions) any(ODPManager.class), + any(), anyString(), any(), any()); @@ -144,6 +145,7 @@ public void testBuildWithDatafileDownloadIntervalDeprecated() throws Exception { any(NotificationCenter.class), any(), // nullable (DefaultDecideOptions) any(ODPManager.class), + any(), anyString(), any(), any()); @@ -185,6 +187,7 @@ public void testBuildWithEventDispatchInterval() throws Exception { any(NotificationCenter.class), any(), // nullable (DefaultDecideOptions) any(ODPManager.class), + any(), anyString(), any(), any()); @@ -229,6 +232,7 @@ public void testBuildWithEventDispatchRetryInterval() throws Exception { any(NotificationCenter.class), any(), // nullable (DefaultDecideOptions) any(ODPManager.class), + any(), anyString(), any(), any()); @@ -269,6 +273,7 @@ public void testBuildWithEventDispatchIntervalDeprecated() throws Exception { any(NotificationCenter.class), any(), // nullable (DefaultDecideOptions) any(ODPManager.class), + any(), anyString(), any(), any()); diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroidTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroidTest.java new file mode 100644 index 00000000..ad1f8bee --- /dev/null +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroidTest.java @@ -0,0 +1,413 @@ +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.optimizely.ab.android.sdk; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyForcedDecision; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionCallback; +import com.optimizely.ab.optimizelydecision.OptimizelyDecisionsCallback; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OptimizelyUserContextAndroid} + */ +@RunWith(MockitoJUnitRunner.class) +public class OptimizelyUserContextAndroidTest { + + @Mock + Optimizely mockOptimizely; + + @Mock + OptimizelyDecision mockDecision; + + @Mock + Map mockDecisionsMap; + + @Mock + OptimizelyDecisionCallback mockDecisionCallback; + + @Mock + OptimizelyDecisionsCallback mockDecisionsCallback; + + private static final String TEST_USER_ID = "testUser123"; + private static final String TEST_FLAG_KEY = "testFlag"; + private static final List TEST_FLAG_KEYS = Arrays.asList("flag1", "flag2"); + private static final List TEST_OPTIONS = Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT); + + private Map testAttributes; + private Map testForcedDecisions; + private List testQualifiedSegments; + + @Before + public void setup() { + testAttributes = new HashMap<>(); + testAttributes.put("isLoggedIn", true); + testAttributes.put("userType", "premium"); + + testForcedDecisions = new HashMap<>(); + testQualifiedSegments = Arrays.asList("segment1", "segment2"); + } + + @Test + public void testConstructor_withBasicParameters() { + // Test constructor with optimizely, userId, and attributes + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + assertNotNull(userContext); + assertEquals(TEST_USER_ID, userContext.getUserId()); + assertEquals(testAttributes, userContext.getAttributes()); + } + + @Test + public void testConstructor_withForcedDecisionsAndSegments() { + // Test constructor with forced decisions and qualified segments + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes, + testForcedDecisions, + testQualifiedSegments + ); + + assertNotNull(userContext); + assertEquals(TEST_USER_ID, userContext.getUserId()); + assertEquals(testAttributes, userContext.getAttributes()); + } + + @Test + public void testConstructor_withAllParameters() { + // Test constructor with all parameters including shouldIdentifyUser + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes, + testForcedDecisions, + testQualifiedSegments, + true + ); + + assertNotNull(userContext); + assertEquals(TEST_USER_ID, userContext.getUserId()); + assertEquals(testAttributes, userContext.getAttributes()); + } + + // =========================================== + // Tests for Sync Decide Methods + // =========================================== + + @Test + public void testDecide_withOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + doReturn(mockDecision).when(userContext).coreDecideSync(any(), any()); + + OptimizelyDecision result = userContext.decide(TEST_FLAG_KEY, TEST_OPTIONS); + + verify(userContext).coreDecideSync(TEST_FLAG_KEY, TEST_OPTIONS); + assertEquals(mockDecision, result); + } + + @Test + public void testDecide_withoutOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + doReturn(mockDecision).when(userContext).coreDecideSync(any(), any()); + + OptimizelyDecision result = userContext.decide(TEST_FLAG_KEY); + + verify(userContext).coreDecideSync(TEST_FLAG_KEY, Collections.emptyList()); + assertEquals(mockDecision, result); + } + + @Test + public void testDecideForKeys_withOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + doReturn(mockDecisionsMap).when(userContext).coreDecideForKeysSync(any(), any()); + + Map result = userContext.decideForKeys(TEST_FLAG_KEYS, TEST_OPTIONS); + + verify(userContext).coreDecideForKeysSync(TEST_FLAG_KEYS, TEST_OPTIONS); + assertEquals(mockDecisionsMap, result); + } + + @Test + public void testDecideForKeys_withoutOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + doReturn(mockDecisionsMap).when(userContext).coreDecideForKeysSync(any(), any()); + + Map result = userContext.decideForKeys(TEST_FLAG_KEYS); + + verify(userContext).coreDecideForKeysSync(TEST_FLAG_KEYS, Collections.emptyList()); + assertEquals(mockDecisionsMap, result); + } + + @Test + public void testDecideAll_withOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + doReturn(mockDecisionsMap).when(userContext).coreDecideAllSync(any()); + + Map result = userContext.decideAll(TEST_OPTIONS); + + verify(userContext).coreDecideAllSync(TEST_OPTIONS); + assertEquals(mockDecisionsMap, result); + } + + @Test + public void testDecideAll_withoutOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + doReturn(mockDecisionsMap).when(userContext).coreDecideAllSync(any()); + + Map result = userContext.decideAll(); + + verify(userContext).coreDecideAllSync(Collections.emptyList()); + assertEquals(mockDecisionsMap, result); + } + + // =========================================== + // Tests for Async Decide Methods (with callbacks) + // =========================================== + + @Test + public void testDecideAsync_withCallbackAndOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + userContext.decideAsync(TEST_FLAG_KEY, TEST_OPTIONS, mockDecisionCallback); + + verify(userContext).coreDecideAsync(TEST_FLAG_KEY, TEST_OPTIONS, mockDecisionCallback); + } + + @Test + public void testDecideAsync_withCallbackOnly() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + userContext.decideAsync(TEST_FLAG_KEY, mockDecisionCallback); + + verify(userContext).coreDecideAsync(TEST_FLAG_KEY, Collections.emptyList(), mockDecisionCallback); + } + + @Test + public void testDecideForKeysAsync_withCallbackAndOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + userContext.decideForKeysAsync(TEST_FLAG_KEYS, TEST_OPTIONS, mockDecisionsCallback); + + verify(userContext).coreDecideForKeysAsync(TEST_FLAG_KEYS, TEST_OPTIONS, mockDecisionsCallback); + } + + @Test + public void testDecideForKeysAsync_withCallbackOnly() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + userContext.decideForKeysAsync(TEST_FLAG_KEYS, mockDecisionsCallback); + + verify(userContext).coreDecideForKeysAsync(TEST_FLAG_KEYS, Collections.emptyList(), mockDecisionsCallback); + } + + @Test + public void testDecideAllAsync_withCallbackAndOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + userContext.decideAllAsync(TEST_OPTIONS, mockDecisionsCallback); + + verify(userContext).coreDecideAllAsync(TEST_OPTIONS, mockDecisionsCallback); + } + + @Test + public void testDecideAllAsync_withCallbackOnly() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + userContext.decideAllAsync(mockDecisionsCallback); + + verify(userContext).coreDecideAllAsync(Collections.emptyList(), mockDecisionsCallback); + } + + // =========================================== + // Tests for Blocking Async Decide Methods + // =========================================== + + @Test + public void testDecideAsync_blocking_withOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + doReturn(mockDecision).when(userContext).coreDecide(any(), any()); + + OptimizelyDecision result = userContext.decideAsync(TEST_FLAG_KEY, TEST_OPTIONS); + + verify(userContext).coreDecide(TEST_FLAG_KEY, TEST_OPTIONS); + assertEquals(mockDecision, result); + } + + @Test + public void testDecideAsync_blocking_withoutOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + doReturn(mockDecision).when(userContext).coreDecide(any(), any()); + + OptimizelyDecision result = userContext.decideAsync(TEST_FLAG_KEY); + + verify(userContext).coreDecide(TEST_FLAG_KEY, Collections.emptyList()); + assertEquals(mockDecision, result); + } + + @Test + public void testDecideForKeysAsync_blocking_withOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + doReturn(mockDecisionsMap).when(userContext).coreDecideForKeys(any(), any()); + + Map result = userContext.decideForKeysAsync(TEST_FLAG_KEYS, TEST_OPTIONS); + + verify(userContext).coreDecideForKeys(TEST_FLAG_KEYS, TEST_OPTIONS); + assertEquals(mockDecisionsMap, result); + } + + @Test + public void testDecideForKeysAsync_blocking_withoutOptions() throws Exception { + // Create spy of the actual class + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + doReturn(mockDecisionsMap).when(userContext).coreDecideForKeys(any(), any()); + + Map result = userContext.decideForKeysAsync(TEST_FLAG_KEYS); + + verify(userContext).coreDecideForKeys(TEST_FLAG_KEYS, Collections.emptyList()); + assertEquals(mockDecisionsMap, result); + } + + @Test + public void testDecideAllAsync_blocking_withOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + doReturn(mockDecisionsMap).when(userContext).coreDecideAll(any()); + + Map result = userContext.decideAllAsync(TEST_OPTIONS); + + verify(userContext).coreDecideAll(TEST_OPTIONS); + assertEquals(mockDecisionsMap, result); + } + + @Test + public void testDecideAllAsync_blocking_withoutOptions() throws Exception { + OptimizelyUserContextAndroid userContext = spy(new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + )); + + doReturn(mockDecisionsMap).when(userContext).coreDecideAll(any()); + + Map result = userContext.decideAllAsync(); + + verify(userContext).coreDecideAll(Collections.emptyList()); + assertEquals(mockDecisionsMap, result); + } + +} diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java new file mode 100644 index 00000000..1843b2b3 --- /dev/null +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java @@ -0,0 +1,126 @@ +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.optimizely.ab.android.sdk.cmab; + +import com.optimizely.ab.android.shared.Client; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.MockitoJUnitRunner; +import org.slf4j.Logger; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link DefaultCmabClient} + */ +@RunWith(MockitoJUnitRunner.class) +public class DefaultCmabClientTest { + + private Client mockClient; + private CmabClientHelperAndroid mockCmabClientHelper; + private DefaultCmabClient mockCmabClient; + private String testRuleId = "test-rule-123"; + private String testUserId = "test-user-456"; + private String testCmabUuid = "test-uuid-789"; + private Map testAttributes; + + @Before + public void setup() { + mockClient = mock(Client.class); + mockCmabClientHelper = spy(new CmabClientHelperAndroid()); + + testAttributes = new HashMap<>(); + testAttributes.put("age", 25); + testAttributes.put("country", "US"); + } + + @Test + public void testFetchDecisionSuccess() throws Exception { + HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); + ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); + + String mockResponseJson = "{\"variation_id\":\"variation_1\",\"status\":\"success\"}"; + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(200); + when(mockClient.readStream(mockUrlConnection)).thenReturn(mockResponseJson); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + doReturn("{\"user_id\":\"test-user-456\"}") + .when(mockCmabClientHelper) + .buildRequestJson(any(), any(), any(), any()); + doReturn(true) + .when(mockCmabClientHelper) + .validateResponse(any()); + doReturn("variation_1") + .when(mockCmabClientHelper) + .parseVariationId(any()); + + mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); + + String result = mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + + assertEquals("variation_1", result); + + verify(mockUrlConnection).setConnectTimeout(10*1000); + verify(mockUrlConnection).setReadTimeout(60*1000); + verify(mockUrlConnection).setRequestMethod("POST"); + verify(mockUrlConnection).setRequestProperty("content-type", "application/json"); + verify(mockUrlConnection).setDoOutput(true); + } + + @Test + public void testFetchDecisionConnectionFailure() throws Exception { + when(mockClient.openConnection(any(URL.class))).thenReturn(null); + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); + + String result = mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + assertNull(result); + } + + @Test + public void testRetryOnFailureWithRetryBackoff() throws Exception { + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenReturn(null); + + mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); + + String result = mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + assertNull(result); + + // Verify the retry configuration matches our constants + verify(mockClient).execute(any(Client.Request.class), eq(DefaultCmabClient.REQUEST_BACKOFF_TIMEOUT), eq(DefaultCmabClient.REQUEST_RETRIES_POWER)); + assertEquals("REQUEST_BACKOFF_TIMEOUT should be 1", 1, DefaultCmabClient.REQUEST_BACKOFF_TIMEOUT); + assertEquals("REQUEST_RETRIES_POWER should be 2", 2, DefaultCmabClient.REQUEST_RETRIES_POWER); + } +} diff --git a/build.gradle b/build.gradle index ad912630..6351c62a 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ buildscript { ext.version_name = System.getenv('GITHUB_TAG') if (version_name == null || version_name.isEmpty()) { - ext.version_name = 'debugVersion' + ext.version_name = version } ext.is_release_version = !version_name.endsWith("SNAPSHOT") @@ -76,11 +76,11 @@ allprojects { configurations.all { // no cache for SNAPSHOT dependency resolutionStrategy.cacheChangingModulesFor 0, 'seconds' - + // Exclude conflicting kotlin stdlib versions resolutionStrategy.eachDependency { details -> if (details.requested.group == 'org.jetbrains.kotlin') { - if (details.requested.name == 'kotlin-stdlib-jdk8' || + if (details.requested.name == 'kotlin-stdlib-jdk8' || details.requested.name == 'kotlin-stdlib-jdk7') { details.useTarget "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" details.because 'Unified kotlin stdlib to avoid conflicts' diff --git a/gradle.properties b/gradle.properties index ffde98ef..6d7a3be1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,6 @@ +# Maven local version +version = 3.1.0-SNAPSHOT + android.enableJetifier=true android.useAndroidX=true org.gradle.jvmargs=-Xmx1g diff --git a/shared/src/androidTest/java/com/optimizely/ab/android/shared/ClientTest.java b/shared/src/androidTest/java/com/optimizely/ab/android/shared/ClientTest.java index d35f644d..f24531b8 100644 --- a/shared/src/androidTest/java/com/optimizely/ab/android/shared/ClientTest.java +++ b/shared/src/androidTest/java/com/optimizely/ab/android/shared/ClientTest.java @@ -145,6 +145,20 @@ public void testExpBackoffFailure() { assertTrue(timeouts.contains(16)); } + @Test + public void testExpBackoffFailure_with_one_second_timeout() { + Client.Request request = mock(Client.Request.class); + when(request.execute()).thenReturn(null); + // one second timeout is a corner case - pow(1, 4) = 1 + assertNull(client.execute(request, 1, 2)); + ArgumentCaptor captor = ArgumentCaptor.forClass(Integer.class); + verify(logger, times(2)).info(eq("Request failed, waiting {} seconds to try again"), captor.capture()); + List timeouts = captor.getAllValues(); + assertTrue(timeouts.contains(1)); + assertTrue(timeouts.contains(1)); + } + + @Test public void testExpBackoffFailure_noRetriesWhenBackoffSetToZero() { Client.Request request = mock(Client.Request.class); diff --git a/shared/src/main/java/com/optimizely/ab/android/shared/Client.java b/shared/src/main/java/com/optimizely/ab/android/shared/Client.java index f2338eac..72484c3d 100644 --- a/shared/src/main/java/com/optimizely/ab/android/shared/Client.java +++ b/shared/src/main/java/com/optimizely/ab/android/shared/Client.java @@ -152,9 +152,12 @@ public String readStream(@NonNull URLConnection urlConnection) { */ public T execute(Request request, int timeout, int power) { int baseTimeout = timeout; - int maxTimeout = (int) Math.pow(baseTimeout, power); T response = null; - while(timeout <= maxTimeout) { + int attempts = 0; + int maxAttempts = power + 1; // power represents retries, so total attempts = power + 1 + + while(attempts < maxAttempts) { + attempts++; try { response = request.execute(); } catch (Exception e) { @@ -165,6 +168,9 @@ public T execute(Request request, int timeout, int power) { // retry is disabled when timeout set to 0 if (timeout == 0) break; + // don't sleep if this was the last attempt + if (attempts >= maxAttempts) break; + try { logger.info("Request failed, waiting {} seconds to try again", timeout); Thread.sleep(TimeUnit.MILLISECONDS.convert(timeout, TimeUnit.SECONDS));