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..d7afccaa 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 @@ -58,6 +58,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.beans.DefaultPersistenceDelegate; import java.io.IOException; import java.io.InputStream; import java.util.Collections; @@ -90,6 +91,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 +114,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 +140,7 @@ public class OptimizelyManager { this.userProfileService = userProfileService; this.vuid = vuid; this.odpManager = odpManager; + this.cmabService = cmabService; this.notificationCenter = notificationCenter; this.defaultDecideOptions = defaultDecideOptions; @@ -646,6 +650,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 +786,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 +1067,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 +1196,15 @@ public OptimizelyManager build(Context context) { .build(); } + DefaultCmabService.Builder cmabBuilder = DefaultCmabService.builder(); + if (cmabClient == null) { + cmabClient = new DefaultCmabClient(); + } + cmabBuilder.withClient(cmabClient); + cmabBuilder.withCmabCacheSize(cmabCacheSize); + cmabBuilder.withCmabCacheTimeoutInSecs(cmabCacheTimeoutInSecs); + CmabService cmabService = cmabBuilder.build(); + return new OptimizelyManager(projectId, sdkKey, datafileConfig, logger, @@ -1173,6 +1218,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..ffc7789d --- /dev/null +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroid.java @@ -0,0 +1,221 @@ +// 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 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 { + public OptimizelyUserContextAndroid(@NonNull Optimizely optimizely, + @NonNull String userId, + @NonNull Map attributes) { + super(optimizely, userId, attributes); + } + + public OptimizelyUserContextAndroid(@NonNull Optimizely optimizely, + @NonNull String userId, + @NonNull Map attributes, + @Nullable Map forcedDecisionsMap, + @Nullable List qualifiedSegments) { + super(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments); + } + + 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 optimizely.decideSync(copy(), 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 decide(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 optimizely.decideForKeysSync(copy(), 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 decideForKeys(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 optimizely.decideAllSync(copy(), 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 decideAll(Collections.emptyList()); + } + + /** + * 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 OptimizelyDecisionCallback callback, + @Nonnull List options) { + optimizely.decideAsync(copy(), key, callback, options); + } + + /** + * 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) { + decideAsync(key, callback, Collections.emptyList()); + } + + /** + * 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 OptimizelyDecisionsCallback callback, + @Nonnull List options) { + optimizely.decideForKeysAsync(copy(), keys, callback, options); + } + + /** + * 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) { + decideForKeysAsync(keys, callback, Collections.emptyList()); + } + + /** + * 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 OptimizelyDecisionsCallback callback, + @Nonnull List options) { + optimizely.decideAllAsync(copy(), callback, options); + } + + /** + * 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) { + decideAllAsync(callback, Collections.emptyList()); + } + +} 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..d2cfa9f6 --- /dev/null +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt @@ -0,0 +1,114 @@ +// 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 androidx.annotation.VisibleForTesting +import com.optimizely.ab.android.shared.Client +import org.slf4j.Logger +import java.net.HttpURLConnection +import java.net.URL + +@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) +open class DefaultCmabClient(private val client: Client, private val logger: Logger) { + + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + 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.CMAB_PREDICTION_ENDPOINT, ruleId) + val requestBody: String = + CmabClientHelper.buildRequestJson(userId, ruleId, attributes, cmabUuid) + + val url = URL(apiEndpoint) + urlConnection = client.openConnection(url) + if (urlConnection == null) { + 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.INVALID_CMAB_FETCH_RESPONSE) + throw CmabInvalidResponseException(CmabClientHelper.INVALID_CMAB_FETCH_RESPONSE) + } + return@Request CmabClientHelper.parseVariationId(json) + } else { + val errorMessage: String = java.lang.String.format( + CmabClientHelper.CMAB_FETCH_FAILED, + urlConnection.responseMessage + ) + logger.error(errorMessage) + throw CmabFetchException(errorMessage) + } + } catch (e: Exception) { + val errorMessage: String = + java.lang.String.format(CmabClientHelper.CMAB_FETCH_FAILED, e.message) + logger.error(errorMessage) + throw CmabFetchException(errorMessage) + } finally { + if (urlConnection != null) { + try { + urlConnection.disconnect() + } catch (e: Exception) { + logger.error("Error closing connection", e) + } + } + } + } + val response = client.execute(request, REQUEST_BACKOFF_TIMEOUT, REQUEST_RETRIES_POWER) + val parser: ResponseJsonParser = ResponseJsonParserFactory.getParser() + try { + return parser.parseQualifiedSegments(response) + } catch (e: java.lang.Exception) { + logger.error("Audience segments fetch failed (Error Parsing Response)") + logger.debug(e.message) + } + return null + } + + 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..17d452d0 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,6 +25,8 @@ 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.CMABClient; +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; @@ -465,4 +467,149 @@ public void testBuildWithVuidEnabled() throws Exception { when(ODPManager.builder()).thenCallRealMethod(); } + + @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); + + // Create mocks for the CMAB service creation chain + Object mockDefaultLRUCache = PowerMockito.mock(Class.forName("com.optimizely.ab.cache.DefaultLRUCache")); + Object mockCmabServiceOptions = PowerMockito.mock(Class.forName("com.optimizely.ab.cmab.CmabServiceOptions")); + Object mockDefaultCmabService = PowerMockito.mock(Class.forName("com.optimizely.ab.cmab.DefaultCmabService")); + + // Mock the construction chain with parameter validation + whenNew(Class.forName("com.optimizely.ab.cache.DefaultLRUCache")) + .thenReturn(mockDefaultLRUCache); + + whenNew(Class.forName("com.optimizely.ab.cmab.CmabServiceOptions")) + .thenReturn(mockCmabServiceOptions); + + whenNew(Class.forName("com.optimizely.ab.cmab.DefaultCmabService")) + .thenReturn(mockDefaultCmabService); + + // Use PowerMock to verify OptimizelyManager constructor is called with CMAB service + whenNew(OptimizelyManager.class).withAnyArguments().thenReturn(mock(OptimizelyManager.class)); + + OptimizelyManager manager = OptimizelyManager.builder(testProjectId) + .withCmabCacheSize(customCacheSize) + .withCmabCacheTimeout(customTimeoutMinutes, TimeUnit.MINUTES) + .withCmabClient(mockCmabClient) + .build(mockContext); + + verifyNew(Class.forName("com.optimizely.ab.cache.DefaultLRUCache")) + .withArguments(eq(customCacheSize), eq(expectedTimeoutSeconds)); + + verifyNew(Class.forName("com.optimizely.ab.cmab.CmabServiceOptions")) + .withArguments(any(), eq(mockDefaultLRUCache), eq(mockCmabClient)); + + verifyNew(Class.forName("com.optimizely.ab.cmab.DefaultCmabService")) + .withArguments(eq(mockCmabServiceOptions)); + + // 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 + + // Create mocks for the CMAB service creation chain + Object mockDefaultLRUCache = PowerMockito.mock(Class.forName("com.optimizely.ab.cache.DefaultLRUCache")); + Object mockDefaultCmabClient = PowerMockito.mock(Class.forName("com.optimizely.ab.cmab.DefaultCmabClient")); + Object mockCmabServiceOptions = PowerMockito.mock(Class.forName("com.optimizely.ab.cmab.CmabServiceOptions")); + Object mockDefaultCmabService = PowerMockito.mock(Class.forName("com.optimizely.ab.cmab.DefaultCmabService")); + + // Mock the construction chain with parameter validation + whenNew(Class.forName("com.optimizely.ab.cache.DefaultLRUCache")) + .thenReturn(mockDefaultLRUCache); + + whenNew(Class.forName("com.optimizely.ab.cmab.DefaultCmabClient")) + .thenReturn(mockDefaultCmabClient); + + whenNew(Class.forName("com.optimizely.ab.cmab.CmabServiceOptions")) + .thenReturn(mockCmabServiceOptions); + + whenNew(Class.forName("com.optimizely.ab.cmab.DefaultCmabService")) + .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); + + verifyNew(Class.forName("com.optimizely.ab.cache.DefaultLRUCache")) + .withArguments(eq(defaultCacheSize), eq(defaultTimeoutSeconds)); + + // Verify DefaultCmabClient is created with default parameters + verifyNew(Class.forName("com.optimizely.ab.cmab.DefaultCmabClient")) + .withNoArguments(); + + // Verify CmabServiceOptions is created with logger, cache, and default client + verifyNew(Class.forName("com.optimizely.ab.cmab.CmabServiceOptions")) + .withArguments(any(), eq(mockDefaultLRUCache), eq(mockDefaultCmabClient)); + + verifyNew(Class.forName("com.optimizely.ab.cmab.DefaultCmabService")) + .withArguments(eq(mockCmabServiceOptions)); + + // 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/OptimizelyUserContextAndroidTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroidTest.java new file mode 100644 index 00000000..63329667 --- /dev/null +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/OptimizelyUserContextAndroidTest.java @@ -0,0 +1,307 @@ +// 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.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +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.verify; +import static org.mockito.Mockito.when; + +import javax.annotation.Nonnull; + +/** + * Tests for {@link OptimizelyUserContextAndroid} + */ +@RunWith(MockitoJUnitRunner.class) +public class OptimizelyUserContextAndroidTest { + + // Mock callback interfaces for testing async methods + public interface OptimizelyDecisionCallback { + void onDecision(@Nonnull OptimizelyDecision decision); + } + + public interface OptimizelyDecisionsCallback { + void onDecisions(@Nonnull Map decisions); + } + + @Mock + Optimizely mockOptimizely; + + @Mock + OptimizelyDecision mockDecision; + + @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"); + + // Setup mock return values + when(mockOptimizely.decideSync(any(), eq(TEST_FLAG_KEY), any())).thenReturn(mockDecision); + when(mockOptimizely.decideForKeysSync(any(), eq(TEST_FLAG_KEYS), any())).thenReturn(Collections.emptyMap()); + when(mockOptimizely.decideAllSync(any(), any())).thenReturn(Collections.emptyMap()); + } + + @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()); + } + + @Test + public void testDecide_withOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + OptimizelyDecision result = userContext.decide(TEST_FLAG_KEY, TEST_OPTIONS); + + verify(mockOptimizely).decideSync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEY), eq(TEST_OPTIONS)); + assertEquals(mockDecision, result); + } + + @Test + public void testDecide_withoutOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + OptimizelyDecision result = userContext.decide(TEST_FLAG_KEY); + + verify(mockOptimizely).decideSync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEY), eq(Collections.emptyList())); + assertEquals(mockDecision, result); + } + + @Test + public void testDecideForKeys_withOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + Map result = userContext.decideForKeys(TEST_FLAG_KEYS, TEST_OPTIONS); + + verify(mockOptimizely).decideForKeysSync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEYS), eq(TEST_OPTIONS)); + assertNotNull(result); + } + + @Test + public void testDecideForKeys_withoutOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + Map result = userContext.decideForKeys(TEST_FLAG_KEYS); + + verify(mockOptimizely).decideForKeysSync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEYS), eq(Collections.emptyList())); + assertNotNull(result); + } + + @Test + public void testDecideAll_withOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + Map result = userContext.decideAll(TEST_OPTIONS); + + verify(mockOptimizely).decideAllSync(any(OptimizelyUserContext.class), eq(TEST_OPTIONS)); + assertNotNull(result); + } + + @Test + public void testDecideAll_withoutOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + Map result = userContext.decideAll(); + + verify(mockOptimizely).decideAllSync(any(OptimizelyUserContext.class), eq(Collections.emptyList())); + assertNotNull(result); + } + + // =========================================== + // Tests for Async Methods + // =========================================== + + @Test + public void testDecideAsync_withOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + userContext.decideAsync(TEST_FLAG_KEY, mockDecisionCallback, TEST_OPTIONS); + + verify(mockOptimizely).decideAsync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEY), eq(mockDecisionCallback), eq(TEST_OPTIONS)); + } + + @Test + public void testDecideAsync_withoutOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + userContext.decideAsync(TEST_FLAG_KEY, mockDecisionCallback); + + verify(mockOptimizely).decideAsync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEY), eq(mockDecisionCallback), eq(Collections.emptyList())); + } + + @Test + public void testDecideForKeysAsync_withOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + userContext.decideForKeysAsync(TEST_FLAG_KEYS, mockDecisionsCallback, TEST_OPTIONS); + + verify(mockOptimizely).decideForKeysAsync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEYS), eq(mockDecisionsCallback), eq(TEST_OPTIONS)); + } + + @Test + public void testDecideForKeysAsync_withoutOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + userContext.decideForKeysAsync(TEST_FLAG_KEYS, mockDecisionsCallback); + + verify(mockOptimizely).decideForKeysAsync(any(OptimizelyUserContext.class), eq(TEST_FLAG_KEYS), eq(mockDecisionsCallback), eq(Collections.emptyList())); + } + + @Test + public void testDecideAllAsync_withOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + userContext.decideAllAsync(mockDecisionsCallback, TEST_OPTIONS); + + verify(mockOptimizely).decideAllAsync(any(OptimizelyUserContext.class), eq(mockDecisionsCallback), eq(TEST_OPTIONS)); + } + + @Test + public void testDecideAllAsync_withoutOptions() { + OptimizelyUserContextAndroid userContext = new OptimizelyUserContextAndroid( + mockOptimizely, + TEST_USER_ID, + testAttributes + ); + + userContext.decideAllAsync(mockDecisionsCallback); + + verify(mockOptimizely).decideAllAsync(any(OptimizelyUserContext.class), eq(mockDecisionsCallback), eq(Collections.emptyList())); + } + +} 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..8f87f154 --- /dev/null +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java @@ -0,0 +1,165 @@ +// 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.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +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.*; +import static org.powermock.api.mockito.PowerMockito.mockStatic; +import static org.powermock.api.mockito.PowerMockito.when; + +/** + * Tests for {@link DefaultCmabClient} + */ +@RunWith(PowerMockRunner.class) +@PrepareForTest({CmabClientHelper.class}) +@PowerMockIgnore({"javax.net.ssl.*", "javax.security.*"}) +public class DefaultCmabClientTest { + + private Client mockClient; + private Logger mockLogger; + private HttpURLConnection mockUrlConnection; + private ByteArrayOutputStream mockOutputStream; + + private DefaultCmabClient cmabClient; + 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); + mockLogger = mock(Logger.class); + mockUrlConnection = mock(HttpURLConnection.class); + mockOutputStream = mock(ByteArrayOutputStream.class); + + cmabClient = new DefaultCmabClient(mockClient, mockLogger); + + testAttributes = new HashMap<>(); + testAttributes.put("age", 25); + testAttributes.put("country", "US"); + + // Mock static methods + mockStatic(CmabClientHelper.class); + } + + @Test + public void testFetchDecisionSuccess() throws Exception { + // Mock successful HTTP response + 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), eq(2), eq(2))).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + // Mock the helper methods + when(CmabClientHelper.buildRequestJson(testUserId, testRuleId, testAttributes, testCmabUuid)) + .thenReturn("{\"user_id\":\"test-user-456\"}"); + when(CmabClientHelper.validateResponse(mockResponseJson)).thenReturn(true); + when(CmabClientHelper.parseVariationId(mockResponseJson)).thenReturn("variation_1"); + + String result = cmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + + assertEquals("variation_1", result); + verify(mockUrlConnection).setConnectTimeout(10000); + verify(mockUrlConnection).setReadTimeout(60000); + verify(mockUrlConnection).setRequestMethod("POST"); + verify(mockUrlConnection).setRequestProperty("content-type", "application/json"); + verify(mockUrlConnection).setDoOutput(true); + verify(mockLogger).debug("Successfully fetched CMAB decision: {}", mockResponseJson); + } + + @Test + public void testFetchDecisionConnectionFailure() throws Exception { + // Mock connection failure + when(mockClient.openConnection(any(URL.class))).thenReturn(null); + when(mockClient.execute(any(Client.Request.class), eq(2), eq(2))).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + // Mock the helper methods + when(CmabClientHelper.buildRequestJson(testUserId, testRuleId, testAttributes, testCmabUuid)) + .thenReturn("{\"user_id\":\"test-user-456\"}"); + + String result = cmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + + assertNull(result); + } + + @Test + public void testConnectionTimeouts() throws Exception { + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(200); + when(mockClient.readStream(mockUrlConnection)).thenReturn("{\"variation_id\":\"test\"}"); + 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(); + }); + + when(CmabClientHelper.buildRequestJson(any(), any(), any(), any())).thenReturn("{}"); + when(CmabClientHelper.validateResponse(any())).thenReturn(true); + when(CmabClientHelper.parseVariationId(any())).thenReturn("test"); + + cmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + + verify(mockUrlConnection).setConnectTimeout(10 * 1000); // 10 seconds + verify(mockUrlConnection).setReadTimeout(60 * 1000); // 60 seconds + } + + @Test + public void testRetryOnFailureWithRetryBackoff() throws Exception { + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(500); + when(mockUrlConnection.getResponseMessage()).thenReturn("Server Error"); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + + when(mockClient.execute(any(Client.Request.class), eq(1), eq(2))).thenReturn(null); + + when(CmabClientHelper.buildRequestJson(any(), any(), any(), any())).thenReturn("{}"); + + String result = cmabClient.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/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));