diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 150952acd..8d3334961 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -95,6 +95,10 @@
android:label="@string/activity_name_feedback"
android:configChanges="orientation|screenSize">
+
+
values = Countly.sharedInstance().remoteConfig().testingGetAllVariants();
+ if (values == null) {
+ Countly.sharedInstance().L.w("No variants present");
+ return;
+ }
+ Countly.sharedInstance().L.d("Get all variants: " + values);
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("Stored Variant Values:\n");
+ for (Map.Entry entry : values.entrySet()) {
+ String key = entry.getKey();
+ String[] variants = entry.getValue();
+ sb.append(key).append(": ").append(Arrays.toString(variants)).append("\n");
+ }
+
+ Toast t = Toast.makeText(getApplicationContext(), sb.toString(), Toast.LENGTH_LONG);
+ t.setGravity(Gravity.BOTTOM, 0, 0);
+ t.show();
+ }
+
+ public void onClickEnrollVariant(View v) {
+ Map values = Countly.sharedInstance().remoteConfig().testingGetAllVariants();
+ if (values == null) {
+ Countly.sharedInstance().L.w("No variants present");
+ return;
+ }
+ Countly.sharedInstance().L.d("Get all variants: [" + values.toString() + "]");
+
+ // Get the first key and variant
+ String key = null;
+ String variant = null;
+ for (Map.Entry entry : values.entrySet()) {
+ key = entry.getKey();
+ variant = entry.getValue()[0]; // first variant
+ break; // Get only the first key-value pair
+ }
+
+ Countly.sharedInstance().remoteConfig().testingEnrollIntoVariant(key, variant, new RCVariantCallback() {
+ @Override
+ public void callback(RequestResponse result, String error) {
+ if (result == RequestResponse.SUCCESS) {
+ Toast.makeText(getApplicationContext(), "Fetch finished", Toast.LENGTH_SHORT).show();
+ } else {
+ Toast.makeText(getApplicationContext(), "Error: " + result, Toast.LENGTH_SHORT).show();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ Countly.sharedInstance().onStart(this);
+ }
+
+ @Override
+ public void onStop() {
+ Countly.sharedInstance().onStop();
+ super.onStop();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ Countly.sharedInstance().onConfigurationChanged(newConfig);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ly/count/android/demo/MainActivity.java b/app/src/main/java/ly/count/android/demo/MainActivity.java
index f50c5e24d..b08355fe4 100644
--- a/app/src/main/java/ly/count/android/demo/MainActivity.java
+++ b/app/src/main/java/ly/count/android/demo/MainActivity.java
@@ -130,6 +130,10 @@ public void onClickButtonRemoteConfig(View v) {
startActivity(new Intent(this, ActivityExampleRemoteConfig.class));
}
+ public void onClickButtonTests(View v) {
+ startActivity(new Intent(this, ActivityExampleTests.class));
+ }
+
public void onClickButtonDeviceId(View v) {
startActivity(new Intent(this, ActivityExampleDeviceId.class));
}
diff --git a/app/src/main/res/layout/activity_example_tests.xml b/app/src/main/res/layout/activity_example_tests.xml
new file mode 100644
index 000000000..c91a77b26
--- /dev/null
+++ b/app/src/main/res/layout/activity_example_tests.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 2d4b6172c..850992544 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -99,5 +99,13 @@
android:onClick="onClickButtonOthers"
/>
+
+
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/RemoteConfigVariantControlTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/RemoteConfigVariantControlTests.java
new file mode 100644
index 000000000..904a3e46a
--- /dev/null
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/RemoteConfigVariantControlTests.java
@@ -0,0 +1,252 @@
+package ly.count.android.sdk;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Map;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static androidx.test.InstrumentationRegistry.getContext;
+import static org.mockito.Mockito.mock;
+
+@RunWith(AndroidJUnit4.class)
+public class RemoteConfigVariantControlTests {
+ CountlyStore countlyStore;
+
+ @Before
+ public void setUp() {
+ Countly.sharedInstance().setLoggingEnabled(true);
+ countlyStore = new CountlyStore(getContext(), mock(ModuleLog.class));
+ countlyStore.clear();
+ }
+
+ @Test
+ public void testConvertVariantsJsonToMap_ValidInput_Multi() throws JSONException {
+ // Create a sample JSON object with variants
+ JSONObject variantsObj = new JSONObject();
+ JSONArray variantArray1 = new JSONArray();
+ variantArray1.put(new JSONObject().put("name", "Variant 1"));
+ variantArray1.put(new JSONObject().put("name", "Variant 2"));
+ JSONArray variantArray2 = new JSONArray();
+ variantArray2.put(new JSONObject().put("name", "Variant 3"));
+ variantArray2.put(new JSONObject().put("name", "Variant 4"));
+ variantsObj.put("key1", variantArray1);
+ variantsObj.put("key2", variantArray2);
+
+ // Call the function to convert variants JSON to a map
+ Map resultMap = ModuleRemoteConfig.convertVariantsJsonToMap(variantsObj);
+
+ // Assert the expected map values
+ Assert.assertEquals(2, resultMap.size());
+
+ // Assert the values for key1
+ String[] key1Variants = resultMap.get("key1");
+ Assert.assertEquals(2, key1Variants.length);
+ Assert.assertEquals("Variant 1", key1Variants[0]);
+ Assert.assertEquals("Variant 2", key1Variants[1]);
+
+ // Assert the values for key2
+ String[] key2Variants = resultMap.get("key2");
+ Assert.assertEquals(2, key2Variants.length);
+ Assert.assertEquals("Variant 3", key2Variants[0]);
+ Assert.assertEquals("Variant 4", key2Variants[1]);
+ }
+
+ @Test
+ public void testConvertVariantsJsonToMap_ValidInput_Single() throws JSONException {
+ // Create a sample JSON object with valid variants
+ JSONObject variantsObj = new JSONObject();
+ variantsObj.put("key1", new JSONArray().put(new JSONObject().put("name", "Variant 1")));
+ variantsObj.put("key2", new JSONArray().put(new JSONObject().put("name", "Variant 2")));
+
+ // Call the function to convert variants JSON to a map
+ Map resultMap = ModuleRemoteConfig.convertVariantsJsonToMap(variantsObj);
+
+ // Assert the expected map values
+ Assert.assertEquals(2, resultMap.size());
+
+ // Assert the values for key1
+ String[] key1Variants = resultMap.get("key1");
+ Assert.assertEquals(1, key1Variants.length);
+ Assert.assertEquals("Variant 1", key1Variants[0]);
+
+ // Assert the values for key2
+ String[] key2Variants = resultMap.get("key2");
+ Assert.assertEquals(1, key2Variants.length);
+ Assert.assertEquals("Variant 2", key2Variants[0]);
+ }
+
+ @Test
+ public void testConvertVariantsJsonToMap_InvalidInput() throws JSONException {
+ // Create a sample JSON object with invalid variants (missing "name" field)
+ JSONObject variantsObj = new JSONObject();
+ variantsObj.put("key1", new JSONArray().put(new JSONObject().put("invalid_key", "Invalid Value")));
+
+ // Call the function to convert variants JSON to a map
+ Map resultMap = ModuleRemoteConfig.convertVariantsJsonToMap(variantsObj);
+ Assert.assertEquals(1, resultMap.size());
+
+ // Assert the values for key1
+ String[] key1Variants = resultMap.get("key1");
+ Assert.assertEquals(0, key1Variants.length);
+ }
+
+ @Test
+ public void testConvertVariantsJsonToMap_InvalidJson() throws JSONException {
+ // Test with invalid JSON object
+ JSONObject variantsObj = new JSONObject();
+ variantsObj.put("key1", "Invalid JSON");
+
+ // Call the function to convert variants JSON to a map (expecting JSONException)
+ ModuleRemoteConfig.convertVariantsJsonToMap(variantsObj);
+
+ // Call the function to convert variants JSON to a map
+ Map resultMap = ModuleRemoteConfig.convertVariantsJsonToMap(variantsObj);
+ Assert.assertEquals(1, resultMap.size());
+
+ // Assert the values for key1
+ String[] key1Variants = resultMap.get("key1");
+ Assert.assertEquals(0, key1Variants.length);
+ }
+
+ @Test
+ public void testConvertVariantsJsonToMap_NoValues() throws JSONException {
+ // Create an empty JSON object
+ JSONObject variantsObj = new JSONObject();
+
+ // Call the function to convert variants JSON to a map
+ Map resultMap = ModuleRemoteConfig.convertVariantsJsonToMap(variantsObj);
+
+ // Assert that the map is empty
+ Assert.assertTrue(resultMap.isEmpty());
+ }
+
+ @Test
+ public void testConvertVariantsJsonToMap_DifferentStructures() throws JSONException {
+ // Test with JSON object having different structures
+ JSONObject variantsObj = new JSONObject();
+
+ // Structure 1: Empty JSON array
+ variantsObj.put("key1", new JSONArray());
+
+ // Structure 2: Single variant as JSON object
+ variantsObj.put("key2", new JSONArray().put(new JSONObject().put("name", "Variant 1")));
+
+ // Structure 3: Multiple variants as JSON objects
+ variantsObj.put("key3", new JSONArray().put(new JSONObject().put("name", "Variant 2")).put(new JSONObject().put("name", "Variant 3")));
+
+ // Call the function to convert variants JSON to a map
+ Map resultMap = ModuleRemoteConfig.convertVariantsJsonToMap(variantsObj);
+
+ // Assert the expected map values
+ Assert.assertEquals(3, resultMap.size());
+
+ // Assert the values for key1 (empty array)
+ String[] key1Variants = resultMap.get("key1");
+ Assert.assertEquals(0, key1Variants.length);
+
+ // Assert the values for key2 (single variant)
+ String[] key2Variants = resultMap.get("key2");
+ Assert.assertEquals(1, key2Variants.length);
+ Assert.assertEquals("Variant 1", key2Variants[0]);
+
+ // Assert the values for key3 (multiple variants)
+ String[] key3Variants = resultMap.get("key3");
+ Assert.assertEquals(2, key3Variants.length);
+ Assert.assertEquals("Variant 2", key3Variants[0]);
+ Assert.assertEquals("Variant 3", key3Variants[1]);
+ }
+
+ @Test
+ public void testConvertVariantsJsonToMap_NullJsonKey() throws JSONException {
+ // Test with a null JSON key
+ String variantsObj = "{\"null\":[{\"name\":\"null\"}]}";
+
+ // Call the function to convert variants JSON to a map (expecting JSONException)
+ Map resultMap = ModuleRemoteConfig.convertVariantsJsonToMap(new JSONObject(variantsObj));
+
+ // Assert the values for key1
+ String[] key1Variants = resultMap.get("null");
+ Assert.assertEquals(1, key1Variants.length);
+ Assert.assertEquals("null", key1Variants[0]);
+ }
+
+ @Test
+ public void testNormalFlow() {
+ CountlyConfig config = TestUtils.createVariantConfig(createIRGForSpecificResponse("{\"key\":[{\"name\":\"variant\"}]}"));
+ Countly countly = (new Countly()).init(config);
+
+ // Developer did not provide a callback
+ countly.moduleRemoteConfig.remoteConfigInterface.testingFetchVariantInformation(null);
+ Map values = countly.moduleRemoteConfig.remoteConfigInterface.testingGetAllVariants();
+ String[] variantArray = countly.moduleRemoteConfig.remoteConfigInterface.testingGetVariantsForKey("key");
+ String[] variantArrayFalse = countly.moduleRemoteConfig.remoteConfigInterface.testingGetVariantsForKey("key2");
+
+ //Assert the values
+ String[] key1Variants = values.get("key");
+ Assert.assertEquals(1, key1Variants.length);
+ Assert.assertEquals("variant", key1Variants[0]);
+ Assert.assertEquals(1, variantArray.length);
+ Assert.assertEquals("variant", variantArray[0]);
+ Assert.assertEquals(0, variantArrayFalse.length);
+ }
+
+ @Test
+ public void testNullVariant() {
+ CountlyConfig config = TestUtils.createVariantConfig(createIRGForSpecificResponse("{\"key\":[{\"name\":null}]}"));
+ Countly countly = (new Countly()).init(config);
+
+ // Developer did not provide a callback
+ countly.moduleRemoteConfig.remoteConfigInterface.testingFetchVariantInformation(null);
+ Map values = countly.moduleRemoteConfig.remoteConfigInterface.testingGetAllVariants();
+
+ // Assert the values
+ String[] key1Variants = values.get("key");
+ Assert.assertEquals(1, key1Variants.length);
+ Assert.assertEquals("null", key1Variants[0]); // TODO: is fine?
+ }
+
+ @Test
+ public void testFilteringWrongKeys() {
+ CountlyConfig config = TestUtils.createVariantConfig(createIRGForSpecificResponse("{\"key\":[{\"noname\":\"variant1\"},{\"name\":\"variant2\"}]}"));
+ Countly countly = (new Countly()).init(config);
+
+ // Developer did not provide a callback
+ countly.moduleRemoteConfig.remoteConfigInterface.testingFetchVariantInformation(null);
+ Map values = countly.moduleRemoteConfig.remoteConfigInterface.testingGetAllVariants();
+
+ //Assert the values
+ String[] key1Variants = values.get("key");
+ Assert.assertEquals(1, key1Variants.length);
+ Assert.assertEquals("variant2", key1Variants[0]);
+ }
+
+ ImmediateRequestGenerator createIRGForSpecificResponse(final String targetResponse) {
+ return new ImmediateRequestGenerator() {
+ @Override public ImmediateRequestI CreateImmediateRequestMaker() {
+ return new ImmediateRequestI() {
+ @Override public void doWork(String requestData, String customEndpoint, ConnectionProcessor cp, boolean requestShouldBeDelayed, boolean networkingIsEnabled, ImmediateRequestMaker.InternalImmediateRequestCallback callback, ModuleLog log) {
+ if (targetResponse == null) {
+ callback.callback(null);
+ return;
+ }
+
+ JSONObject jobj = null;
+
+ try {
+ jobj = new JSONObject(targetResponse);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+
+ callback.callback(jobj);
+ }
+ };
+ }
+ };
+ }
+}
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java
index 2fc774d07..8cc589df4 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java
@@ -65,6 +65,17 @@ public static CountlyConfig createConfigurationConfig(boolean enableServerConfig
return cc;
}
+ public static CountlyConfig createVariantConfig(ImmediateRequestGenerator irGen) {
+ CountlyConfig cc = (new CountlyConfig((Application) ApplicationProvider.getApplicationContext(), commonAppKey, commonURL))
+ .setDeviceId(commonDeviceId)
+ .setLoggingEnabled(true)
+ .enableCrashReporting();
+
+ cc.immediateRequestGenerator = irGen;
+
+ return cc;
+ }
+
public static CountlyConfig createConsentCountlyConfig(boolean requiresConsent, String[] givenConsent, ModuleBase testModuleListener, RequestQueueProvider rqp) {
CountlyConfig cc = (new CountlyConfig((Application) ApplicationProvider.getApplicationContext(), commonAppKey, commonURL))
.setDeviceId(commonDeviceId)
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTimeTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTimeTests.java
index ed8d075fa..b945aea0b 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTimeTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTimeTests.java
@@ -32,7 +32,7 @@ public void invalidGet() {
@Test
public void testInstant() {
UtilsTime.Instant i1 = UtilsTime.Instant.get(1579463653876L);
- Assert.assertEquals(0, i1.dow);
+ Assert.assertEquals(0, i1.dow); // TODO: "expected:<0> but was:<1>"
Assert.assertEquals(1579463653876L, i1.timestampMs);
//weird stuff to account for timezones and daylight saving
diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java
index 87f6b4b0e..76184635b 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java
@@ -713,6 +713,30 @@ public String prepareRemoteConfigRequest(@Nullable String keysInclude, @Nullable
return data;
}
+ /**
+ * To fetch all variants from the server. Something like this should be formed: method=ab_fetch_variants&app_key="APP_KEY"&device_id=DEVICE_ID
+ * API end point for this is /i/sdk
+ *
+ * @return
+ */
+ public String prepareFetchAllVariants() {
+ String data = "method=ab_fetch_variants"
+ + "&app_key=" + UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey())
+ + "&device_id=" + UtilsNetworking.urlEncodeString(deviceIdProvider_.getDeviceId());
+
+ return data;
+ }
+
+ public String prepareEnrollVariant(String key, String variant) {
+ String data = "method=ab_enroll_variant"
+ + "&app_key=" + UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey())
+ + "&device_id=" + UtilsNetworking.urlEncodeString(deviceIdProvider_.getDeviceId())
+ + "&key=" + UtilsNetworking.urlEncodeString(key)
+ + "&variant=" + UtilsNetworking.urlEncodeString(variant);
+
+ return data;
+ }
+
public String prepareRatingWidgetRequest(String widgetId) {
String data = prepareCommonRequestData()
+ "&widget_id=" + UtilsNetworking.urlEncodeString(widgetId)
diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java
index 427865b62..6d4e8be23 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java
@@ -1,5 +1,6 @@
package ly.count.android.sdk;
+import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.HashMap;
@@ -10,8 +11,9 @@
import org.json.JSONObject;
public class ModuleRemoteConfig extends ModuleBase {
+ ImmediateRequestGenerator immediateRequestGenerator;
boolean updateRemoteConfigAfterIdChange = false;
-
+ Map variantContainer; // Stores the fetched A/B test variants
RemoteConfig remoteConfigInterface = null;
//if set to true, it will automatically download remote configs on module startup
@@ -26,6 +28,7 @@ public class ModuleRemoteConfig extends ModuleBase {
L.v("[ModuleRemoteConfig] Initialising");
metricOverride = config.metricOverride;
+ immediateRequestGenerator = config.immediateRequestGenerator;
if (config.enableRemoteConfigAutomaticDownload) {
L.d("[ModuleRemoteConfig] Setting if remote config Automatic download will be enabled, " + config.enableRemoteConfigAutomaticDownload);
@@ -52,31 +55,27 @@ void updateRemoteConfigValues(@Nullable final String[] keysOnly, @Nullable final
try {
L.d("[ModuleRemoteConfig] Updating remote config values, requestShouldBeDelayed:[" + requestShouldBeDelayed + "]");
+ // checks
if (deviceIdProvider.getDeviceId() == null) {
//device ID is null, abort
L.d("[ModuleRemoteConfig] RemoteConfig value update was aborted, deviceID is null");
-
if (callback != null) {
callback.callback("Can't complete call, device ID is null");
}
-
return;
}
if (deviceIdProvider.isTemporaryIdEnabled() || requestQueueProvider.queueContainsTemporaryIdItems()) {
//temporary id mode enabled, abort
L.d("[ModuleRemoteConfig] RemoteConfig value update was aborted, temporary device ID mode is set");
-
if (callback != null) {
callback.callback("Can't complete call, temporary device ID is set");
}
-
return;
}
- //prepare metrics
+ //prepare metrics and request data
String preparedMetrics = deviceInfo.getMetrics(_cly.context_, deviceInfo, metricOverride);
-
String[] preparedKeys = prepareKeysIncludeExclude(keysOnly, keysExcept);
String requestData = requestQueueProvider.prepareRemoteConfigRequest(preparedKeys[0], preparedKeys[1], preparedMetrics);
L.d("[ModuleRemoteConfig] RemoteConfig requestData:[" + requestData + "]");
@@ -100,8 +99,8 @@ public void callback(JSONObject checkResponse) {
boolean clearOldValues = keysExcept == null && keysOnly == null;
mergeCheckResponseIntoCurrentValues(clearOldValues, checkResponse);
} catch (Exception ex) {
- L.e("[ModuleRemoteConfig] updateRemoteConfigValues - execute, Encountered critical issue while trying to download remote config information from the server, [" + ex.toString() + "]");
- error = "Encountered critical issue while trying to download remote config information from the server, [" + ex.toString() + "]";
+ L.e("[ModuleRemoteConfig] updateRemoteConfigValues - execute, Encountered internal issue while trying to download remote config information from the server, [" + ex.toString() + "]");
+ error = "Encountered internal issue while trying to download remote config information from the server, [" + ex.toString() + "]";
}
if (callback != null) {
@@ -110,13 +109,126 @@ public void callback(JSONObject checkResponse) {
}
}, L);
} catch (Exception ex) {
- L.e("[ModuleRemoteConfig] Encountered critical error while trying to perform a remote config update. " + ex.toString());
+ L.e("[ModuleRemoteConfig] Encountered internal error while trying to perform a remote config update. " + ex.toString());
if (callback != null) {
- callback.callback("Encountered critical error while trying to perform a remote config update");
+ callback.callback("Encountered internal error while trying to perform a remote config update");
}
}
}
+ /**
+ * Internal call for fetching all variants of A/B test experiments
+ *
+ * @param callback called after the fetch is done
+ */
+ void testingFetchVariantInformationInternal(@NonNull final RCVariantCallback callback) {
+ try {
+ L.d("[ModuleRemoteConfig] Fetching all A/B test variants");
+
+ if (deviceIdProvider.isTemporaryIdEnabled() || requestQueueProvider.queueContainsTemporaryIdItems() || deviceIdProvider.getDeviceId() == null) {
+ L.d("[ModuleRemoteConfig] Fetching all A/B test variants was aborted, temporary device ID mode is set or device ID is null.");
+ callback.callback(RequestResponse.ERROR, "Temporary device ID mode is set or device ID is null.");
+ return;
+ }
+
+ // prepare request data
+ String requestData = requestQueueProvider.prepareFetchAllVariants();
+
+ L.d("[ModuleRemoteConfig] Fetching all A/B test variants requestData:[" + requestData + "]");
+
+ ConnectionProcessor cp = requestQueueProvider.createConnectionProcessor();
+ final boolean networkingIsEnabled = cp.configProvider_.getNetworkingEnabled();
+
+ immediateRequestGenerator.CreateImmediateRequestMaker().doWork(requestData, "/i/sdk", cp, false, networkingIsEnabled, new ImmediateRequestMaker.InternalImmediateRequestCallback() {
+ @Override
+ public void callback(JSONObject checkResponse) {
+ L.d("[ModuleRemoteConfig] Processing Fetching all A/B test variants received response, received response is null:[" + (checkResponse == null) + "]");
+ if (checkResponse == null) {
+ callback.callback(RequestResponse.NETWORK_ISSUE, "Received response is null." );
+ return;
+ }
+
+ try {
+ Map parsedResponse = convertVariantsJsonToMap(checkResponse);
+ variantContainer = parsedResponse;
+ } catch (Exception ex) {
+ L.e("[ModuleRemoteConfig] testingFetchVariantInformationInternal - execute, Encountered internal issue while trying to fetch information from the server, [" + ex.toString() + "]");
+ }
+
+ callback.callback(RequestResponse.SUCCESS, null);
+ }
+ }, L);
+ } catch (Exception ex) {
+ L.e("[ModuleRemoteConfig] Encountered internal error while trying to fetch all A/B test variants. " + ex.toString());
+ callback.callback(RequestResponse.ERROR, "Encountered internal error while trying to fetch all A/B test variants.");
+ }
+ }
+
+ void testingEnrollIntoVariantInternal(@NonNull final String key, @NonNull final String variant, @NonNull final RCVariantCallback callback) {
+ try {
+ L.d("[ModuleRemoteConfig] Enrolling A/B test variants, Key/Variant pairs:[" + key + "][" + variant + "]");
+
+ if (deviceIdProvider.isTemporaryIdEnabled() || requestQueueProvider.queueContainsTemporaryIdItems() || deviceIdProvider.getDeviceId() == null) {
+ L.d("[ModuleRemoteConfig] Enrolling A/B test variants was aborted, temporary device ID mode is set or device ID is null.");
+ callback.callback(RequestResponse.ERROR, "Temporary device ID mode is set or device ID is null.");
+ return;
+ }
+
+ // check Key and Variant
+ if (TextUtils.isEmpty(key) || TextUtils.isEmpty(variant)) {
+ L.w("[ModuleRemoteConfig] Enrolling A/B test variants, Key/Variant pair is invalid. Aborting.");
+ callback.callback(RequestResponse.ERROR, "Provided key/variant pair is invalid.");
+ return;
+ }
+
+ // prepare request data
+ String requestData = requestQueueProvider.prepareEnrollVariant(key, variant);
+
+ L.d("[ModuleRemoteConfig] Enrolling A/B test variants requestData:[" + requestData + "]");
+
+ ConnectionProcessor cp = requestQueueProvider.createConnectionProcessor();
+ final boolean networkingIsEnabled = cp.configProvider_.getNetworkingEnabled();
+
+ immediateRequestGenerator.CreateImmediateRequestMaker().doWork(requestData, "/i/sdk", cp, false, networkingIsEnabled, new ImmediateRequestMaker.InternalImmediateRequestCallback() {
+ @Override
+ public void callback(JSONObject checkResponse) {
+ L.d("[ModuleRemoteConfig] Processing Fetching all A/B test variants received response, received response is null:[" + (checkResponse == null) + "]");
+ if (checkResponse == null) {
+ callback.callback(RequestResponse.NETWORK_ISSUE, "Received response is null.");
+ return;
+ }
+
+ try {
+ if (!isResponseValid(checkResponse)) {
+ callback.callback(RequestResponse.NETWORK_ISSUE, "Bad response from the server:" + checkResponse.toString());
+ return;
+ }
+
+ // Update Remote Config
+ if (remoteConfigAutomaticUpdateEnabled) {
+ updateRemoteConfigValues(null, null, false, new RemoteConfigCallback() {
+ @Override public void callback(String error) {
+ if (error == null) {
+ L.d("[ModuleRemoteConfig] Updated remote config after enrolling to a variant");
+ } else {
+ L.e("[ModuleRemoteConfig] Attempt to update the remote config after enrolling to a variant failed:" + error.toString());
+ }
+ }
+ });
+ }
+
+ callback.callback(RequestResponse.SUCCESS, null);
+ } catch (Exception ex) {
+ L.e("[ModuleRemoteConfig] testingEnrollIntoVariantInternal - execute, Encountered internal issue while trying to enroll to the variant, [" + ex.toString() + "]");
+ }
+ }
+ }, L);
+ } catch (Exception ex) {
+ L.e("[ModuleRemoteConfig] Encountered internal error while trying to enroll A/B test variants. " + ex.toString());
+ callback.callback(RequestResponse.ERROR, "Encountered internal error while trying to enroll A/B test variants.");
+ }
+ }
+
/**
* Merge the values acquired from the server into the current values.
* Clear if needed.
@@ -141,6 +253,85 @@ void mergeCheckResponseIntoCurrentValues(boolean clearOldValues, JSONObject chec
L.d("[ModuleRemoteConfig] Finished remote config saving");
}
+ /**
+ * Checks and evaluates the response from the server
+ *
+ * @param responseJson - JSONObject response
+ * @return
+ * @throws JSONException
+ */
+ boolean isResponseValid(@NonNull JSONObject responseJson) throws JSONException {
+ boolean result = false;
+
+ if (responseJson.get("result").equals("Success")) {
+ result = true;
+ }
+
+ return result;
+ }
+
+ /**
+ * Converts A/B testing variants fetched from the server (JSONObject) into a map
+ *
+ * @param variantsObj - JSON Object fetched from the server
+ * @return
+ * @throws JSONException
+ */
+ static Map convertVariantsJsonToMap(@NonNull JSONObject variantsObj) throws JSONException {
+ // Initialize the map to store the results
+ Map resultMap = new HashMap<>();
+
+ try {
+ // Get the keys of the JSON object using names() method
+ JSONArray keys = variantsObj.names();
+ if (keys != null) {
+ for (int i = 0; i < keys.length(); i++) {
+ String key = keys.getString(i);
+ Object value = variantsObj.get(key);
+
+ // Set the key and and an empty Array initially
+ String[] emptyArray = new String[0];
+ resultMap.put(key, emptyArray);
+
+ // Check if the value is a JSON array
+ if (value instanceof JSONArray) {
+ JSONArray jsonArray = (JSONArray) value;
+
+ // Check if the JSON array contains objects
+ if (jsonArray.length() > 0 && jsonArray.get(0) instanceof JSONObject) {
+ // Extract the values from the JSON objects
+ String[] variants = new String[jsonArray.length()];
+ int count = 0;
+ for (int j = 0; j < jsonArray.length(); j++) {
+ JSONObject variantObject = jsonArray.getJSONObject(j);
+ if (variantObject.has("name")) {
+ variants[count] = variantObject.getString("name");
+ count++;
+ }
+ }
+
+ // Map the key and its corresponding variants
+ if (count > 0) {
+ String[] filteredVariants = new String[count];
+ System.arraycopy(variants, 0, filteredVariants, 0, count);
+ resultMap.put(key, filteredVariants);
+ } // else if the JSON object had no key 'name' we return String[0]
+ } // else if values of JSON array are not JSON object(all?) or no values at all we return String[0]
+ } // else if value is not JSON array we return String[0]
+ }
+ }
+ } catch (Exception ex) {
+ Countly.sharedInstance().L.e("[ModuleRemoteConfig] convertVariantsJsonToMap, failed parsing:[" + ex.toString() + "]");
+ return new HashMap<>();
+ }
+
+ return resultMap;
+ }
+
+ /*
+ * Decide which keys to use
+ * Useful if both 'keysExcept' and 'keysOnly' set
+ * */
@NonNull String[] prepareKeysIncludeExclude(@Nullable final String[] keysOnly, @Nullable final String[] keysExcept) {
String[] res = new String[2];//0 - include, 1 - exclude
@@ -207,6 +398,29 @@ Map getAllRemoteConfigValuesInternal() {
}
}
+ /**
+ * Gets all AB testing variants stored in the memory
+ *
+ * @return
+ */
+ Map testingGetAllVariantsInternal() {
+ return variantContainer;
+ }
+
+ /**
+ * Get all variants for a given key if exists. Else returns an empty array.
+ *
+ * @param key
+ * @return
+ */
+ String[] testingGetVariantsForKeyInternal(String key) {
+ if (variantContainer.containsKey(key)) {
+ return variantContainer.get(key);
+ }
+
+ return new String[0];
+ }
+
static class RemoteConfigValueStore {
public JSONObject values = new JSONObject();
@@ -307,6 +521,12 @@ public void halt() {
remoteConfigInterface = null;
}
+ // ==================================================================
+ // ==================================================================
+ // INTERFACE
+ // ==================================================================
+ // ==================================================================
+
public class RemoteConfig {
/**
* Clear all stored remote config_ values
@@ -331,6 +551,96 @@ public Map getAllValues() {
}
}
+ /**
+ * Returns all variant information as a Map
+ *
+ * @return
+ */
+ public Map testingGetAllVariants() {
+ synchronized (_cly) {
+ L.i("[RemoteConfig] Calling 'testingGetAllVariants'");
+
+ if (!consentProvider.getConsent(Countly.CountlyFeatureNames.remoteConfig)) {
+ return null;
+ }
+
+ return testingGetAllVariantsInternal();
+ }
+ }
+
+ /**
+ * Returns variant information for a key as a String[]
+ *
+ * @param key - key value to get variant information for
+ * @return
+ */
+ public String[] testingGetVariantsForKey(String key) {
+ synchronized (_cly) {
+ L.i("[RemoteConfig] Calling 'testingGetVariantsForKey'");
+
+ if (!consentProvider.getConsent(Countly.CountlyFeatureNames.remoteConfig)) {
+ return null;
+ }
+
+ return testingGetVariantsForKeyInternal(key);
+ }
+ }
+
+ /**
+ * Fetches all variants of A/B testing experiments
+ *
+ * @param callback
+ */
+ public void testingFetchVariantInformation(RCVariantCallback callback) {
+ synchronized (_cly) {
+ L.i("[RemoteConfig] Calling 'testingFetchVariantInformation'");
+
+ if (!consentProvider.getConsent(Countly.CountlyFeatureNames.remoteConfig)) {
+ return;
+ }
+
+ if (callback == null) {
+ callback = new RCVariantCallback() {
+ @Override public void callback(RequestResponse result, String error) {
+ }
+ };
+ }
+
+ testingFetchVariantInformationInternal(callback);
+ }
+ }
+
+ /**
+ * Enrolls user for a specific variant of A/B testing experiment
+ *
+ * @param key - key value retrieved from the fetched variants
+ * @param variantName - name of the variant for the key to enroll
+ * @param callback
+ */
+ public void testingEnrollIntoVariant(String key, String variantName, RCVariantCallback callback) {
+ synchronized (_cly) {
+ L.i("[RemoteConfig] Calling 'testingEnrollIntoVariant'");
+
+ if (!consentProvider.getConsent(Countly.CountlyFeatureNames.remoteConfig)) {
+ return;
+ }
+
+ if (key == null || variantName == null) {
+ L.w("[RemoteConfig] testEnrollIntoVariant, passed key or variant is null. Aborting.");
+ return;
+ }
+
+ if (callback == null) {
+ callback = new RCVariantCallback() {
+ @Override public void callback(RequestResponse result, String error) {
+ }
+ };
+ }
+
+ testingEnrollIntoVariantInternal(key, variantName, callback);
+ }
+ }
+
/**
* Get the stored value for the provided remote config_ key
*
diff --git a/sdk/src/main/java/ly/count/android/sdk/RCVariantCallback.java b/sdk/src/main/java/ly/count/android/sdk/RCVariantCallback.java
new file mode 100644
index 000000000..4f666dbf4
--- /dev/null
+++ b/sdk/src/main/java/ly/count/android/sdk/RCVariantCallback.java
@@ -0,0 +1,12 @@
+package ly.count.android.sdk;
+
+public interface RCVariantCallback {
+
+ /**
+ * Called after fetching A/B test variants
+ *
+ * @param result provides an enum for the result of fetch request
+ * @param error provides an error string if it exists
+ */
+ void callback(RequestResponse result, String error);
+}
diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java
index 29eb4c14f..97745689b 100644
--- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java
+++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java
@@ -52,6 +52,10 @@ interface RequestQueueProvider {
String prepareRemoteConfigRequest(@Nullable String keysInclude, @Nullable String keysExclude, @NonNull String preparedMetrics);
+ String prepareFetchAllVariants(); // for fetching all A/B test variants
+
+ String prepareEnrollVariant(String key, String Variant); // for enrolling to an A/B test variant
+
String prepareRatingWidgetRequest(String widgetId);
String prepareFeedbackListRequest();
diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestResponse.java b/sdk/src/main/java/ly/count/android/sdk/RequestResponse.java
new file mode 100644
index 000000000..40853396a
--- /dev/null
+++ b/sdk/src/main/java/ly/count/android/sdk/RequestResponse.java
@@ -0,0 +1,12 @@
+package ly.count.android.sdk;
+
+/**
+ * Enum used throughout Countly for relaying result of A/B test variants fetch
+ * TODO: Make it camel case?
+ */
+public enum RequestResponse {
+ NETWORK_ISSUE,
+ SUCCESS,
+ ERROR,
+}
+