From a6b8ef569cdbf28ce2806fdcbe1114476a874f3c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 16 Dec 2025 16:37:30 +0300 Subject: [PATCH 01/42] feat: list filter utils --- .../android/sdk/UtilsListingFilters.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java new file mode 100644 index 000000000..985a7acf2 --- /dev/null +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java @@ -0,0 +1,71 @@ +package ly.count.android.sdk; + +import androidx.annotation.NonNull; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +public class UtilsListingFilters { + + private UtilsListingFilters() { + } + + static boolean applyEventFilter(@NonNull String eventName, @NonNull ConfigurationProvider configProvider) { + return applyListFilter(eventName, configProvider.getEventFilterSet(), configProvider.getFilterIsWhitelist()); + } + + static boolean applyUserPropertyFilter(@NonNull String propertyName, @NonNull ConfigurationProvider configProvider) { + return applyListFilter(propertyName, configProvider.getUserPropertyFilterSet(), configProvider.getFilterIsWhitelist()); + } + + static void applySegmentationFilter(@NonNull Map segmentation, @NonNull ConfigurationProvider configProvider, @NonNull ModuleLog L) { + if (segmentation.isEmpty()) { + return; + } + applyMapFilter(segmentation, configProvider.getSegmentationFilterSet(), configProvider.getFilterIsWhitelist(), L); + } + + static void applyEventSegmentationFilter(@NonNull String eventName, @NonNull Map segmentation, + @NonNull ConfigurationProvider configProvider, @NonNull ModuleLog L) { + if (segmentation.isEmpty() || configProvider.getEventSegmentationFilterMap().isEmpty()) { + return; + } + + Set segmentationSet = configProvider.getEventSegmentationFilterMap().get(eventName); + if (segmentationSet == null || segmentationSet.isEmpty()) { + // No rules defined for this event so allow everything + return; + } + applyMapFilter(segmentation, segmentationSet, configProvider.getFilterIsWhitelist(), L); + } + + private static void applyMapFilter(@NonNull Map map, @NonNull Set filterSet, boolean isWhitelist, @NonNull ModuleLog L) { + if (filterSet.isEmpty()) { + // No rules defined so allow everything + return; + } + + Iterator> iterator = map.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + String key = entry.getKey(); + + boolean contains = filterSet.contains(key); + + // Whitelist: remove if NOT in list + // Blacklist: remove if IN list + if ((isWhitelist && !contains) || (!isWhitelist && contains)) { + iterator.remove(); + L.d("[UtilsListingFilters] applyMapFilter, removed key: " + key + (isWhitelist ? "not in whitelist" : "blacklisted")); + } + } + } + + private static boolean applyListFilter(String item, @NonNull Set filterSet, boolean isWhitelist) { + if (filterSet.isEmpty()) { + // No rules defined so allow everything + return true; + } + return isWhitelist == filterSet.contains(item); + } +} From 59b62cddd73b482a2addcc21762a52117467079f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 16 Dec 2025 16:37:49 +0300 Subject: [PATCH 02/42] feat: add methods to the config provider --- .../count/android/sdk/ConfigurationProvider.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java index ec42ae165..ed8b2de05 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java @@ -1,5 +1,8 @@ package ly.count.android.sdk; +import java.util.Map; +import java.util.Set; + interface ConfigurationProvider { boolean getNetworkingEnabled(); @@ -31,4 +34,17 @@ interface ConfigurationProvider { int getBOMDuration(); int getRequestTimeoutDurationMillis(); + + int getUserPropertyCacheLimit(); + + // LISTING FILTERS + boolean getFilterIsWhitelist(); + + Set getEventFilterSet(); + + Set getUserPropertyFilterSet(); + + Set getSegmentationFilterSet(); + + Map> getEventSegmentationFilterMap(); } From f8cb6841f7b89ad0765390434ea2e0357b42ae84 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 16 Dec 2025 16:38:20 +0300 Subject: [PATCH 03/42] feat: add new params to the tests --- .../android/sdk/ConnectionProcessorTests.java | 27 +++++++++++++++++++ .../android/sdk/ModuleConfigurationTests.java | 11 +++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java index 03cf9137e..17a3be946 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -30,7 +30,10 @@ of this software and associated documentation files (the "Software"), to deal import java.net.URL; import java.net.URLConnection; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -130,6 +133,30 @@ public void setUp() { @Override public int getRequestTimeoutDurationMillis() { return 30_000; } + + @Override public int getUserPropertyCacheLimit() { + return 100; + } + + @Override public boolean getFilterIsWhitelist() { + return false; + } + + @Override public Set getEventFilterSet() { + return new HashSet<>(); + } + + @Override public Set getUserPropertyFilterSet() { + return new HashSet<>(); + } + + @Override public Set getSegmentationFilterSet() { + return new HashSet<>(); + } + + @Override public Map> getEventSegmentationFilterMap() { + return new ConcurrentHashMap<>(); + } }; Countly.sharedInstance().setLoggingEnabled(true); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 625d43649..1ff135e10 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -4,6 +4,7 @@ import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -1123,7 +1124,15 @@ private void initServerConfigWithValues(BiConsumer config .segmentationValuesLimit(25) .breadcrumbLimit(90) .traceLengthLimit(78) - .traceLinesLimit(89); + .traceLinesLimit(89) + .userPropertyCacheLimit(67) + + // Filters + .filterPreset("Whitelisting") + .eventFilterList(new HashSet<>()) + .userPropertyFilterList(new HashSet<>()) + .segmentationFilterList(new HashSet<>()) + .eventSegmentationFilterMap(new ConcurrentHashMap<>()); String serverConfig = builder.build(); CountlyConfig countlyConfig = TestUtils.createBaseConfig().setLoggingEnabled(false); From f96c8cb4dee6167efd64b1b18468126e975a4c50 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 16 Dec 2025 16:38:34 +0300 Subject: [PATCH 04/42] feat: update test builder --- .../android/sdk/ServerConfigBuilder.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index 003c68726..458bd0138 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -2,6 +2,9 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.junit.Assert; @@ -13,7 +16,10 @@ import static ly.count.android.sdk.ModuleConfiguration.keyRCustomEventTracking; import static ly.count.android.sdk.ModuleConfiguration.keyRDropOldRequestTime; import static ly.count.android.sdk.ModuleConfiguration.keyREnterContentZone; +import static ly.count.android.sdk.ModuleConfiguration.keyREventFilterList; import static ly.count.android.sdk.ModuleConfiguration.keyREventQueueSize; +import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationFilterList; +import static ly.count.android.sdk.ModuleConfiguration.keyRFilterPreset; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitBreadcrumb; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitKeyLength; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitSegValues; @@ -25,11 +31,14 @@ import static ly.count.android.sdk.ModuleConfiguration.keyRNetworking; import static ly.count.android.sdk.ModuleConfiguration.keyRRefreshContentZone; import static ly.count.android.sdk.ModuleConfiguration.keyRReqQueueSize; +import static ly.count.android.sdk.ModuleConfiguration.keyRSegmentationFilterList; import static ly.count.android.sdk.ModuleConfiguration.keyRServerConfigUpdateInterval; import static ly.count.android.sdk.ModuleConfiguration.keyRSessionTracking; import static ly.count.android.sdk.ModuleConfiguration.keyRSessionUpdateInterval; import static ly.count.android.sdk.ModuleConfiguration.keyRTimestamp; import static ly.count.android.sdk.ModuleConfiguration.keyRTracking; +import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyCacheLimit; +import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyFilterList; import static ly.count.android.sdk.ModuleConfiguration.keyRVersion; import static ly.count.android.sdk.ModuleConfiguration.keyRViewTracking; @@ -169,6 +178,36 @@ ServerConfigBuilder traceLinesLimit(int limit) { return this; } + ServerConfigBuilder userPropertyCacheLimit(int limit) { + config.put(keyRUserPropertyCacheLimit, limit); + return this; + } + + ServerConfigBuilder filterPreset(String preset) { + config.put(keyRFilterPreset, preset); + return this; + } + + ServerConfigBuilder eventFilterList(Set filterList) { + config.put(keyREventFilterList, filterList); + return this; + } + + ServerConfigBuilder userPropertyFilterList(Set filterList) { + config.put(keyRUserPropertyFilterList, filterList); + return this; + } + + ServerConfigBuilder segmentationFilterList(Set filterList) { + config.put(keyRSegmentationFilterList, filterList); + return this; + } + + ServerConfigBuilder eventSegmentationFilterMap(Map> filterMap) { + config.put(keyREventSegmentationFilterList, filterMap); + return this; + } + ServerConfigBuilder defaults() { // Feature flags tracking(true); @@ -198,6 +237,13 @@ ServerConfigBuilder defaults() { breadcrumbLimit(Countly.maxBreadcrumbCountDefault); traceLengthLimit(Countly.maxStackTraceLineLengthDefault); traceLinesLimit(Countly.maxStackTraceLinesPerThreadDefault); + userPropertyCacheLimit(100); + + filterPreset("Blacklisting"); + eventFilterList(new JSONArray()); + userPropertyFilterList(new JSONArray()); + segmentationFilterList(new JSONArray()); + eventSegmentationFilterMap(new JSONObject()); return this; } @@ -221,6 +267,7 @@ void validateAgainst(Countly countly) { validateFeatureFlags(countly); validateIntervalsAndSizes(countly); validateLimits(countly); + validateFilterSettings(countly); } private void validateFeatureFlags(Countly countly) { @@ -260,5 +307,22 @@ private void validateLimits(Countly countly) { Assert.assertEquals(config.get(keyRLimitBreadcrumb), countly.config_.sdkInternalLimits.maxBreadcrumbCount); Assert.assertEquals(config.get(keyRLimitTraceLength), countly.config_.sdkInternalLimits.maxStackTraceLineLength); Assert.assertEquals(config.get(keyRLimitTraceLine), countly.config_.sdkInternalLimits.maxStackTraceLinesPerThread); + Assert.assertEquals(config.get(keyRUserPropertyCacheLimit), countly.moduleConfiguration.getUserPropertyCacheLimit()); + } + + private void validateFilterSettings(Countly countly) { + Assert.assertEquals(config.get(keyRFilterPreset), countly.moduleConfiguration.currentVFilterPreset); + + JSONArray eventFilterList = (JSONArray) config.get(keyREventFilterList); + Assert.assertEquals(Objects.requireNonNull(eventFilterList).toString(), countly.moduleConfiguration.getEventFilterSet().toString()); + + JSONArray userPropertyFilterList = (JSONArray) config.get(keyRUserPropertyFilterList); + Assert.assertEquals(Objects.requireNonNull(userPropertyFilterList).toString(), countly.moduleConfiguration.getUserPropertyFilterSet().toString()); + + JSONArray segmentationFilterList = (JSONArray) config.get(keyRSegmentationFilterList); + Assert.assertEquals(Objects.requireNonNull(segmentationFilterList).toString(), countly.moduleConfiguration.getSegmentationFilterSet().toString()); + + JSONObject eventSegmentationFilterMap = (JSONObject) config.get(keyREventSegmentationFilterList); + Assert.assertEquals(Objects.requireNonNull(eventSegmentationFilterMap).toString(), countly.moduleConfiguration.getEventSegmentationFilterMap().toString()); } } \ No newline at end of file From 07281afc743d43ed3cc1942558ad0b27147c1553 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 16 Dec 2025 16:39:07 +0300 Subject: [PATCH 05/42] feat: integrate new features to the module --- .../android/sdk/ModuleConfiguration.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index df7abc8cc..4c50e0488 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -2,7 +2,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.HashSet; import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -47,6 +52,12 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRBOMRQPercentage = "bom_rqp"; final static String keyRBOMRequestAge = "bom_ra"; final static String keyRBOMDuration = "bom_d"; + final static String keyRUserPropertyCacheLimit = "upcl"; + final static String keyRFilterPreset = "filter_preset"; + final static String keyREventFilterList = "eb"; + final static String keyRUserPropertyFilterList = "upb"; + final static String keyRSegmentationFilterList = "sb"; + final static String keyREventSegmentationFilterList = "esb"; // json // FLAGS boolean currentVTracking = true; boolean currentVNetworking = true; @@ -64,6 +75,14 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { double currentVBOMRQPercentage = 0.5; int currentVBOMRequestAge = 24; // in hours int currentVBOMDuration = 60; // in seconds + int currentVUserPropertyCacheLimit = 100; + + // FILTERS + String currentVFilterPreset = "Blacklisting"; + Set currentVEventFilterList = new HashSet<>(); + Set currentVUserPropertyFilterList = new HashSet<>(); + Set currentVSegmentationFilterList = new HashSet<>(); + Map> currentVEventSegmentationFilterList = new ConcurrentHashMap<>(); // SERVER CONFIGURATION PARAMS Integer serverConfigUpdateInterval; // in hours @@ -207,6 +226,8 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { currentVBOMRQPercentage = extractValue(keyRBOMRQPercentage, sb, currentVBOMRQPercentage, currentVBOMRQPercentage, Double.class, (Double value) -> value > 0.0 && value < 1.0); currentVBOMRequestAge = extractValue(keyRBOMRequestAge, sb, currentVBOMRequestAge, currentVBOMRequestAge, Integer.class, (Integer value) -> value > 0); currentVBOMDuration = extractValue(keyRBOMDuration, sb, currentVBOMDuration, currentVBOMDuration, Integer.class, (Integer value) -> value > 0); + currentVUserPropertyCacheLimit = extractValue(keyRUserPropertyCacheLimit, sb, currentVUserPropertyCacheLimit, currentVUserPropertyCacheLimit, Integer.class, (Integer value) -> value > 0); + currentVFilterPreset = extractValue(keyRFilterPreset, sb, currentVFilterPreset, currentVFilterPreset, String.class, (String value) -> value.equals("Blacklisting") || value.equals("Whitelisting")); clyConfig.setMaxRequestQueueSize(extractValue(keyRReqQueueSize, sb, clyConfig.maxRequestQueueSize, clyConfig.maxRequestQueueSize, Integer.class, (Integer value) -> value > 0)); clyConfig.setEventQueueSizeToSend(extractValue(keyREventQueueSize, sb, clyConfig.eventQueueSizeThreshold, Countly.sharedInstance().EVENT_QUEUE_SIZE_THRESHOLD, Integer.class, (Integer value) -> value > 0)); @@ -222,6 +243,8 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { clyConfig.setRequiresConsent(extractValue(keyRConsentRequired, sb, clyConfig.shouldRequireConsent, clyConfig.shouldRequireConsent)); clyConfig.setRequestDropAgeHours(extractValue(keyRDropOldRequestTime, sb, clyConfig.dropAgeHours, clyConfig.dropAgeHours, Integer.class, (Integer value) -> value >= 0)); + updateListingFilters(); + String updatedValues = sb.toString(); if (!updatedValues.isEmpty()) { L.i("[ModuleConfiguration] updateConfigVariables, SDK configuration has changed, notifying the SDK, new values: [" + updatedValues + "]"); @@ -229,6 +252,43 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { } } + private void updateListingFilters() { + JSONArray eventFilterListJSARR = latestRetrievedConfiguration.optJSONArray(keyREventFilterList); + JSONArray userPropertyFilterListJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyFilterList); + JSONArray segmentationFilterListJSARR = latestRetrievedConfiguration.optJSONArray(keyRSegmentationFilterList); + JSONObject eventSegmentationFilterListJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationFilterList); + + extractFilterSetFromJSONArray(eventFilterListJSARR, currentVEventFilterList); + extractFilterSetFromJSONArray(userPropertyFilterListJSARR, currentVUserPropertyFilterList); + extractFilterSetFromJSONArray(segmentationFilterListJSARR, currentVSegmentationFilterList); + if (eventSegmentationFilterListJSOBJ != null) { + currentVEventSegmentationFilterList.clear(); + Iterator keys = eventSegmentationFilterListJSOBJ.keys(); + while (keys.hasNext()) { + String key = keys.next(); + JSONArray jsonArray = eventSegmentationFilterListJSOBJ.optJSONArray(key); + if (jsonArray != null) { + Set filterSet = new HashSet<>(); + extractFilterSetFromJSONArray(jsonArray, filterSet); + currentVEventSegmentationFilterList.put(key, filterSet); + } + } + } + } + + private void extractFilterSetFromJSONArray(@Nullable JSONArray jsonArray, @NonNull Set targetSet) { + if (jsonArray == null) { + return; + } + targetSet.clear(); + for (int i = 0; i < jsonArray.length(); i++) { + String item = jsonArray.optString(i, null); + if (item != null) { + targetSet.add(item); + } + } + } + boolean validateServerConfig(@NonNull JSONObject config) { JSONObject newInner = config.optJSONObject(keyRConfig); @@ -293,6 +353,7 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { case keyRLimitBreadcrumb: case keyRLimitTraceLine: case keyRLimitTraceLength: + case keyRUserPropertyCacheLimit: isValid = value instanceof Integer && ((Integer) value) > 0; break; @@ -310,6 +371,19 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { case keyRBOMRQPercentage: isValid = value instanceof Double && ((Double) value > 0.0 && (Double) value < 1.0); break; + + // --- Filtering keys --- + case keyRFilterPreset: + isValid = value instanceof String && (value.equals("Blacklisting") || value.equals("Whitelisting")); + break; + case keyREventFilterList: + case keyRUserPropertyFilterList: + case keyRSegmentationFilterList: + isValid = value instanceof JSONArray; + break; + case keyREventSegmentationFilterList: + isValid = value instanceof JSONObject; + break; // --- Unknown keys --- default: L.w("[ModuleConfiguration] removeUnsupportedKeys, Unknown key: [" + key + "], removing it. value: [" + value + "]"); @@ -498,4 +572,28 @@ public boolean getTrackingEnabled() { @Override public int getRequestTimeoutDurationMillis() { return _cly.config_.requestTimeoutDuration * 1000; } + + @Override public int getUserPropertyCacheLimit() { + return currentVUserPropertyCacheLimit; + } + + @Override public boolean getFilterIsWhitelist() { + return currentVFilterPreset.equals("Whitelisting"); + } + + @Override public Set getEventFilterSet() { + return currentVEventFilterList; + } + + @Override public Set getUserPropertyFilterSet() { + return currentVUserPropertyFilterList; + } + + @Override public Set getSegmentationFilterSet() { + return currentVSegmentationFilterList; + } + + @Override public Map> getEventSegmentationFilterMap() { + return currentVEventSegmentationFilterList; + } } From 65e0fc84ca835d15563de9047768a8618e842311 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 16 Dec 2025 16:45:09 +0300 Subject: [PATCH 06/42] fix: test fixes --- .../android/sdk/ModuleConfigurationTests.java | 2 +- .../android/sdk/ServerConfigBuilder.java | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 1ff135e10..e3b3917ce 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -645,7 +645,7 @@ public void invalidConfigResponses_AreRejected() { */ @Test public void configurationParameterCount() { - int configParameterCount = 31; // plus config, timestamp and version parameters + int configParameterCount = 37; // plus config, timestamp and version parameters, UPDATE: list filters, and user property cache limit int count = 0; for (Field field : ModuleConfiguration.class.getDeclaredFields()) { if (field.getName().startsWith("keyR")) { diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index 458bd0138..a5ea17e9f 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -1,10 +1,11 @@ package ly.count.android.sdk; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; -import org.json.JSONArray; +import java.util.concurrent.ConcurrentHashMap; import org.json.JSONException; import org.json.JSONObject; import org.junit.Assert; @@ -240,10 +241,10 @@ ServerConfigBuilder defaults() { userPropertyCacheLimit(100); filterPreset("Blacklisting"); - eventFilterList(new JSONArray()); - userPropertyFilterList(new JSONArray()); - segmentationFilterList(new JSONArray()); - eventSegmentationFilterMap(new JSONObject()); + eventFilterList(new HashSet<>()); + userPropertyFilterList(new HashSet<>()); + segmentationFilterList(new HashSet<>()); + eventSegmentationFilterMap(new ConcurrentHashMap<>()); return this; } @@ -313,16 +314,16 @@ private void validateLimits(Countly countly) { private void validateFilterSettings(Countly countly) { Assert.assertEquals(config.get(keyRFilterPreset), countly.moduleConfiguration.currentVFilterPreset); - JSONArray eventFilterList = (JSONArray) config.get(keyREventFilterList); + Set eventFilterList = (Set) config.get(keyREventFilterList); Assert.assertEquals(Objects.requireNonNull(eventFilterList).toString(), countly.moduleConfiguration.getEventFilterSet().toString()); - JSONArray userPropertyFilterList = (JSONArray) config.get(keyRUserPropertyFilterList); + Set userPropertyFilterList = (Set) config.get(keyRUserPropertyFilterList); Assert.assertEquals(Objects.requireNonNull(userPropertyFilterList).toString(), countly.moduleConfiguration.getUserPropertyFilterSet().toString()); - JSONArray segmentationFilterList = (JSONArray) config.get(keyRSegmentationFilterList); + Set segmentationFilterList = (Set) config.get(keyRSegmentationFilterList); Assert.assertEquals(Objects.requireNonNull(segmentationFilterList).toString(), countly.moduleConfiguration.getSegmentationFilterSet().toString()); - JSONObject eventSegmentationFilterMap = (JSONObject) config.get(keyREventSegmentationFilterList); + Map> eventSegmentationFilterMap = (Map>) config.get(keyREventSegmentationFilterList); Assert.assertEquals(Objects.requireNonNull(eventSegmentationFilterMap).toString(), countly.moduleConfiguration.getEventSegmentationFilterMap().toString()); } } \ No newline at end of file From c8af3cbd21a27543bff2c39ff7c3bb5a3c9fca10 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 19 Dec 2025 13:58:57 +0300 Subject: [PATCH 07/42] refactor: update list filters according to individual filtering --- .../android/sdk/ConfigurationProvider.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java index ed8b2de05..74e78a8dd 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java @@ -38,13 +38,22 @@ interface ConfigurationProvider { int getUserPropertyCacheLimit(); // LISTING FILTERS - boolean getFilterIsWhitelist(); - Set getEventFilterSet(); + FilterList> getEventFilterList(); - Set getUserPropertyFilterSet(); + FilterList> getUserPropertyFilterList(); - Set getSegmentationFilterSet(); + FilterList> getSegmentationFilterList(); - Map> getEventSegmentationFilterMap(); + FilterList>> getEventSegmentationFilterList(); + + class FilterList { + T filterList; + boolean isWhitelist; + + FilterList(T filterList, boolean isWhitelist) { + this.filterList = filterList; + this.isWhitelist = isWhitelist; + } + } } From 453b9f70040fc8392054f2f84338ee94a0887e50 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 19 Dec 2025 13:59:23 +0300 Subject: [PATCH 08/42] refactor: individual listing impl --- .../android/sdk/ModuleConfiguration.java | 116 ++++++++++++------ 1 file changed, 77 insertions(+), 39 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index 4c50e0488..95f094ec9 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -53,11 +53,15 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRBOMRequestAge = "bom_ra"; final static String keyRBOMDuration = "bom_d"; final static String keyRUserPropertyCacheLimit = "upcl"; - final static String keyRFilterPreset = "filter_preset"; - final static String keyREventFilterList = "eb"; - final static String keyRUserPropertyFilterList = "upb"; - final static String keyRSegmentationFilterList = "sb"; - final static String keyREventSegmentationFilterList = "esb"; // json + final static String keyREventBlacklist = "eb"; + final static String keyRUserPropertyBlacklist = "upb"; + final static String keyRSegmentationBlacklist = "sb"; + final static String keyREventSegmentationBlacklist = "esb"; // json + final static String keyREventWhitelist = "ew"; + final static String keyRUserPropertyWhitelist = "upw"; + final static String keyRSegmentationWhitelist = "sw"; + final static String keyREventSegmentationWhitelist = "esw"; // json + // FLAGS boolean currentVTracking = true; boolean currentVNetworking = true; @@ -78,11 +82,10 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { int currentVUserPropertyCacheLimit = 100; // FILTERS - String currentVFilterPreset = "Blacklisting"; - Set currentVEventFilterList = new HashSet<>(); - Set currentVUserPropertyFilterList = new HashSet<>(); - Set currentVSegmentationFilterList = new HashSet<>(); - Map> currentVEventSegmentationFilterList = new ConcurrentHashMap<>(); + FilterList> currentVEventFilterList = new FilterList<>(new HashSet<>(), false); + FilterList> currentVUserPropertyFilterList = new FilterList<>(new HashSet<>(), false); + FilterList> currentVSegmentationFilterList = new FilterList<>(new HashSet<>(), false); + FilterList>> currentVEventSegmentationFilterList = new FilterList<>(new ConcurrentHashMap<>(), false); // SERVER CONFIGURATION PARAMS Integer serverConfigUpdateInterval; // in hours @@ -227,7 +230,6 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { currentVBOMRequestAge = extractValue(keyRBOMRequestAge, sb, currentVBOMRequestAge, currentVBOMRequestAge, Integer.class, (Integer value) -> value > 0); currentVBOMDuration = extractValue(keyRBOMDuration, sb, currentVBOMDuration, currentVBOMDuration, Integer.class, (Integer value) -> value > 0); currentVUserPropertyCacheLimit = extractValue(keyRUserPropertyCacheLimit, sb, currentVUserPropertyCacheLimit, currentVUserPropertyCacheLimit, Integer.class, (Integer value) -> value > 0); - currentVFilterPreset = extractValue(keyRFilterPreset, sb, currentVFilterPreset, currentVFilterPreset, String.class, (String value) -> value.equals("Blacklisting") || value.equals("Whitelisting")); clyConfig.setMaxRequestQueueSize(extractValue(keyRReqQueueSize, sb, clyConfig.maxRequestQueueSize, clyConfig.maxRequestQueueSize, Integer.class, (Integer value) -> value > 0)); clyConfig.setEventQueueSizeToSend(extractValue(keyREventQueueSize, sb, clyConfig.eventQueueSizeThreshold, Countly.sharedInstance().EVENT_QUEUE_SIZE_THRESHOLD, Integer.class, (Integer value) -> value > 0)); @@ -253,24 +255,63 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { } private void updateListingFilters() { - JSONArray eventFilterListJSARR = latestRetrievedConfiguration.optJSONArray(keyREventFilterList); - JSONArray userPropertyFilterListJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyFilterList); - JSONArray segmentationFilterListJSARR = latestRetrievedConfiguration.optJSONArray(keyRSegmentationFilterList); - JSONObject eventSegmentationFilterListJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationFilterList); - - extractFilterSetFromJSONArray(eventFilterListJSARR, currentVEventFilterList); - extractFilterSetFromJSONArray(userPropertyFilterListJSARR, currentVUserPropertyFilterList); - extractFilterSetFromJSONArray(segmentationFilterListJSARR, currentVSegmentationFilterList); - if (eventSegmentationFilterListJSOBJ != null) { - currentVEventSegmentationFilterList.clear(); - Iterator keys = eventSegmentationFilterListJSOBJ.keys(); + JSONArray eventBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventBlacklist); + JSONArray eventWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventWhitelist); + JSONArray userPropertyBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyBlacklist); + JSONArray userPropertyWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyWhitelist); + JSONArray segmentationBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyRSegmentationBlacklist); + JSONArray segmentationWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyRSegmentationWhitelist); + JSONObject eventSegmentationBlacklistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationBlacklist); + JSONObject eventSegmentationWhitelistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationWhitelist); + + if (eventWhitelistJSARR != null) { + extractFilterSetFromJSONArray(eventWhitelistJSARR, currentVEventFilterList.filterList); + currentVEventFilterList.isWhitelist = true; + } else if (eventBlacklistJSARR != null) { + extractFilterSetFromJSONArray(eventBlacklistJSARR, currentVEventFilterList.filterList); + currentVEventFilterList.isWhitelist = false; + } + + if (userPropertyWhitelistJSARR != null) { + extractFilterSetFromJSONArray(userPropertyWhitelistJSARR, currentVUserPropertyFilterList.filterList); + currentVUserPropertyFilterList.isWhitelist = true; + } else if (userPropertyBlacklistJSARR != null) { + extractFilterSetFromJSONArray(userPropertyBlacklistJSARR, currentVUserPropertyFilterList.filterList); + currentVUserPropertyFilterList.isWhitelist = false; + } + + if (segmentationWhitelistJSARR != null) { + extractFilterSetFromJSONArray(segmentationWhitelistJSARR, currentVSegmentationFilterList.filterList); + currentVSegmentationFilterList.isWhitelist = true; + } else if (segmentationBlacklistJSARR != null) { + extractFilterSetFromJSONArray(segmentationBlacklistJSARR, currentVSegmentationFilterList.filterList); + currentVSegmentationFilterList.isWhitelist = false; + } + + if (eventSegmentationWhitelistJSOBJ != null) { + currentVEventSegmentationFilterList.filterList.clear(); + currentVEventSegmentationFilterList.isWhitelist = true; + Iterator keys = eventSegmentationWhitelistJSOBJ.keys(); while (keys.hasNext()) { String key = keys.next(); - JSONArray jsonArray = eventSegmentationFilterListJSOBJ.optJSONArray(key); + JSONArray jsonArray = eventSegmentationWhitelistJSOBJ.optJSONArray(key); if (jsonArray != null) { Set filterSet = new HashSet<>(); extractFilterSetFromJSONArray(jsonArray, filterSet); - currentVEventSegmentationFilterList.put(key, filterSet); + currentVEventSegmentationFilterList.filterList.put(key, filterSet); + } + } + } else if (eventSegmentationBlacklistJSOBJ != null) { + currentVEventSegmentationFilterList.filterList.clear(); + currentVEventSegmentationFilterList.isWhitelist = false; + Iterator keys = eventSegmentationBlacklistJSOBJ.keys(); + while (keys.hasNext()) { + String key = keys.next(); + JSONArray jsonArray = eventSegmentationBlacklistJSOBJ.optJSONArray(key); + if (jsonArray != null) { + Set filterSet = new HashSet<>(); + extractFilterSetFromJSONArray(jsonArray, filterSet); + currentVEventSegmentationFilterList.filterList.put(key, filterSet); } } } @@ -373,15 +414,16 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { break; // --- Filtering keys --- - case keyRFilterPreset: - isValid = value instanceof String && (value.equals("Blacklisting") || value.equals("Whitelisting")); - break; - case keyREventFilterList: - case keyRUserPropertyFilterList: - case keyRSegmentationFilterList: + case keyREventBlacklist: + case keyRSegmentationBlacklist: + case keyRUserPropertyBlacklist: + case keyREventWhitelist: + case keyRSegmentationWhitelist: + case keyRUserPropertyWhitelist: isValid = value instanceof JSONArray; break; - case keyREventSegmentationFilterList: + case keyREventSegmentationBlacklist: + case keyREventSegmentationWhitelist: isValid = value instanceof JSONObject; break; // --- Unknown keys --- @@ -577,23 +619,19 @@ public boolean getTrackingEnabled() { return currentVUserPropertyCacheLimit; } - @Override public boolean getFilterIsWhitelist() { - return currentVFilterPreset.equals("Whitelisting"); - } - - @Override public Set getEventFilterSet() { + @Override public FilterList> getEventFilterList() { return currentVEventFilterList; } - @Override public Set getUserPropertyFilterSet() { + @Override public FilterList> getUserPropertyFilterList() { return currentVUserPropertyFilterList; } - @Override public Set getSegmentationFilterSet() { + @Override public FilterList> getSegmentationFilterList() { return currentVSegmentationFilterList; } - @Override public Map> getEventSegmentationFilterMap() { + @Override public FilterList>> getEventSegmentationFilterList() { return currentVEventSegmentationFilterList; } } From 0ac74e1eaf8893e4845789b785295d14f6c03a08 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 19 Dec 2025 13:59:38 +0300 Subject: [PATCH 09/42] refactor: test helper update --- .../android/sdk/ServerConfigBuilder.java | 89 ++++++++++++------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index a5ea17e9f..eebb94f3b 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -17,10 +17,11 @@ import static ly.count.android.sdk.ModuleConfiguration.keyRCustomEventTracking; import static ly.count.android.sdk.ModuleConfiguration.keyRDropOldRequestTime; import static ly.count.android.sdk.ModuleConfiguration.keyREnterContentZone; -import static ly.count.android.sdk.ModuleConfiguration.keyREventFilterList; +import static ly.count.android.sdk.ModuleConfiguration.keyREventBlacklist; import static ly.count.android.sdk.ModuleConfiguration.keyREventQueueSize; -import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationFilterList; -import static ly.count.android.sdk.ModuleConfiguration.keyRFilterPreset; +import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationBlacklist; +import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationWhitelist; +import static ly.count.android.sdk.ModuleConfiguration.keyREventWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitBreadcrumb; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitKeyLength; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitSegValues; @@ -32,14 +33,16 @@ import static ly.count.android.sdk.ModuleConfiguration.keyRNetworking; import static ly.count.android.sdk.ModuleConfiguration.keyRRefreshContentZone; import static ly.count.android.sdk.ModuleConfiguration.keyRReqQueueSize; -import static ly.count.android.sdk.ModuleConfiguration.keyRSegmentationFilterList; +import static ly.count.android.sdk.ModuleConfiguration.keyRSegmentationBlacklist; +import static ly.count.android.sdk.ModuleConfiguration.keyRSegmentationWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyRServerConfigUpdateInterval; import static ly.count.android.sdk.ModuleConfiguration.keyRSessionTracking; import static ly.count.android.sdk.ModuleConfiguration.keyRSessionUpdateInterval; import static ly.count.android.sdk.ModuleConfiguration.keyRTimestamp; import static ly.count.android.sdk.ModuleConfiguration.keyRTracking; +import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyBlacklist; import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyCacheLimit; -import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyFilterList; +import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyRVersion; import static ly.count.android.sdk.ModuleConfiguration.keyRViewTracking; @@ -184,28 +187,39 @@ ServerConfigBuilder userPropertyCacheLimit(int limit) { return this; } - ServerConfigBuilder filterPreset(String preset) { - config.put(keyRFilterPreset, preset); - return this; - } - - ServerConfigBuilder eventFilterList(Set filterList) { - config.put(keyREventFilterList, filterList); + ServerConfigBuilder eventFilterList(Set filterList, boolean isWhitelist) { + if (isWhitelist) { + config.put(keyREventWhitelist, filterList); + } else { + config.put(keyREventBlacklist, filterList); + } return this; } - ServerConfigBuilder userPropertyFilterList(Set filterList) { - config.put(keyRUserPropertyFilterList, filterList); + ServerConfigBuilder userPropertyFilterList(Set filterList, boolean isWhitelist) { + if (isWhitelist) { + config.put(keyRUserPropertyWhitelist, filterList); + } else { + config.put(keyRUserPropertyBlacklist, filterList); + } return this; } - ServerConfigBuilder segmentationFilterList(Set filterList) { - config.put(keyRSegmentationFilterList, filterList); + ServerConfigBuilder segmentationFilterList(Set filterList, boolean isWhitelist) { + if (isWhitelist) { + config.put(keyRSegmentationWhitelist, filterList); + } else { + config.put(keyRSegmentationBlacklist, filterList); + } return this; } - ServerConfigBuilder eventSegmentationFilterMap(Map> filterMap) { - config.put(keyREventSegmentationFilterList, filterMap); + ServerConfigBuilder eventSegmentationFilterMap(Map> filterMap, boolean isWhitelist) { + if (isWhitelist) { + config.put(keyREventSegmentationWhitelist, filterMap); + } else { + config.put(keyREventSegmentationBlacklist, filterMap); + } return this; } @@ -240,11 +254,10 @@ ServerConfigBuilder defaults() { traceLinesLimit(Countly.maxStackTraceLinesPerThreadDefault); userPropertyCacheLimit(100); - filterPreset("Blacklisting"); - eventFilterList(new HashSet<>()); - userPropertyFilterList(new HashSet<>()); - segmentationFilterList(new HashSet<>()); - eventSegmentationFilterMap(new ConcurrentHashMap<>()); + eventFilterList(new HashSet<>(), false); + userPropertyFilterList(new HashSet<>(), false); + segmentationFilterList(new HashSet<>(), false); + eventSegmentationFilterMap(new ConcurrentHashMap<>(), false); return this; } @@ -312,18 +325,28 @@ private void validateLimits(Countly countly) { } private void validateFilterSettings(Countly countly) { - Assert.assertEquals(config.get(keyRFilterPreset), countly.moduleConfiguration.currentVFilterPreset); - - Set eventFilterList = (Set) config.get(keyREventFilterList); - Assert.assertEquals(Objects.requireNonNull(eventFilterList).toString(), countly.moduleConfiguration.getEventFilterSet().toString()); + Set eventFilterList = (Set) config.get(keyREventSegmentationBlacklist); + if (eventFilterList == null) { + eventFilterList = (Set) config.get(keyREventSegmentationWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(eventFilterList).toString(), countly.moduleConfiguration.getEventFilterList().filterList.toString()); - Set userPropertyFilterList = (Set) config.get(keyRUserPropertyFilterList); - Assert.assertEquals(Objects.requireNonNull(userPropertyFilterList).toString(), countly.moduleConfiguration.getUserPropertyFilterSet().toString()); + Set userPropertyFilterList = (Set) config.get(keyRUserPropertyBlacklist); + if (userPropertyFilterList == null) { + userPropertyFilterList = (Set) config.get(keyRUserPropertyWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(userPropertyFilterList).toString(), countly.moduleConfiguration.getUserPropertyFilterList().filterList.toString()); - Set segmentationFilterList = (Set) config.get(keyRSegmentationFilterList); - Assert.assertEquals(Objects.requireNonNull(segmentationFilterList).toString(), countly.moduleConfiguration.getSegmentationFilterSet().toString()); + Set segmentationFilterList = (Set) config.get(keyRSegmentationBlacklist); + if (segmentationFilterList == null) { + segmentationFilterList = (Set) config.get(keyRSegmentationWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(segmentationFilterList).toString(), countly.moduleConfiguration.getSegmentationFilterList().filterList.toString()); - Map> eventSegmentationFilterMap = (Map>) config.get(keyREventSegmentationFilterList); - Assert.assertEquals(Objects.requireNonNull(eventSegmentationFilterMap).toString(), countly.moduleConfiguration.getEventSegmentationFilterMap().toString()); + Map> eventSegmentationFilterMap = (Map>) config.get(keyREventSegmentationBlacklist); + if (eventSegmentationFilterMap == null) { + eventSegmentationFilterMap = (Map>) config.get(keyREventSegmentationWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(eventSegmentationFilterMap).toString(), countly.moduleConfiguration.getEventSegmentationFilterList().filterList.toString()); } } \ No newline at end of file From e1e207995b2d8d003c1f3596e591b7c74151741b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 19 Dec 2025 13:59:53 +0300 Subject: [PATCH 10/42] refactor: helper individual udpate --- .../count/android/sdk/UtilsListingFilters.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java index 985a7acf2..53bebb174 100644 --- a/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java @@ -11,32 +11,36 @@ private UtilsListingFilters() { } static boolean applyEventFilter(@NonNull String eventName, @NonNull ConfigurationProvider configProvider) { - return applyListFilter(eventName, configProvider.getEventFilterSet(), configProvider.getFilterIsWhitelist()); + ConfigurationProvider.FilterList> eventFilterList = configProvider.getEventFilterList(); + return applyListFilter(eventName, eventFilterList.filterList, eventFilterList.isWhitelist); } static boolean applyUserPropertyFilter(@NonNull String propertyName, @NonNull ConfigurationProvider configProvider) { - return applyListFilter(propertyName, configProvider.getUserPropertyFilterSet(), configProvider.getFilterIsWhitelist()); + ConfigurationProvider.FilterList> userPropertyFilterList = configProvider.getUserPropertyFilterList(); + return applyListFilter(propertyName, userPropertyFilterList.filterList, userPropertyFilterList.isWhitelist); } static void applySegmentationFilter(@NonNull Map segmentation, @NonNull ConfigurationProvider configProvider, @NonNull ModuleLog L) { if (segmentation.isEmpty()) { return; } - applyMapFilter(segmentation, configProvider.getSegmentationFilterSet(), configProvider.getFilterIsWhitelist(), L); + + applyMapFilter(segmentation, configProvider.getSegmentationFilterList().filterList, configProvider.getSegmentationFilterList().isWhitelist, L); } static void applyEventSegmentationFilter(@NonNull String eventName, @NonNull Map segmentation, @NonNull ConfigurationProvider configProvider, @NonNull ModuleLog L) { - if (segmentation.isEmpty() || configProvider.getEventSegmentationFilterMap().isEmpty()) { + ConfigurationProvider.FilterList>> eventSegmentationFilterList = configProvider.getEventSegmentationFilterList(); + if (segmentation.isEmpty() || eventSegmentationFilterList.filterList.isEmpty()) { return; } - Set segmentationSet = configProvider.getEventSegmentationFilterMap().get(eventName); + Set segmentationSet = eventSegmentationFilterList.filterList.get(eventName); if (segmentationSet == null || segmentationSet.isEmpty()) { // No rules defined for this event so allow everything return; } - applyMapFilter(segmentation, segmentationSet, configProvider.getFilterIsWhitelist(), L); + applyMapFilter(segmentation, segmentationSet, eventSegmentationFilterList.isWhitelist, L); } private static void applyMapFilter(@NonNull Map map, @NonNull Set filterSet, boolean isWhitelist, @NonNull ModuleLog L) { From 1d1850c51c2e6d0de2e105cf6660bc7e1fb4e14f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 19 Dec 2025 14:00:18 +0300 Subject: [PATCH 11/42] feat: tests update --- .../android/sdk/ConnectionProcessorTests.java | 20 ++++++++----------- .../android/sdk/ModuleConfigurationTests.java | 9 ++++----- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java index 17a3be946..35aec523b 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -138,24 +138,20 @@ public void setUp() { return 100; } - @Override public boolean getFilterIsWhitelist() { - return false; - } - - @Override public Set getEventFilterSet() { - return new HashSet<>(); + @Override public FilterList> getEventFilterList() { + return new FilterList<>(new HashSet<>(), false); } - @Override public Set getUserPropertyFilterSet() { - return new HashSet<>(); + @Override public FilterList> getUserPropertyFilterList() { + return new FilterList<>(new HashSet<>(), false); } - @Override public Set getSegmentationFilterSet() { - return new HashSet<>(); + @Override public FilterList> getSegmentationFilterList() { + return new FilterList<>(new HashSet<>(), false); } - @Override public Map> getEventSegmentationFilterMap() { - return new ConcurrentHashMap<>(); + @Override public FilterList>> getEventSegmentationFilterList() { + return new FilterList<>(new ConcurrentHashMap<>(), false); } }; diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index e3b3917ce..79715b33d 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1128,11 +1128,10 @@ private void initServerConfigWithValues(BiConsumer config .userPropertyCacheLimit(67) // Filters - .filterPreset("Whitelisting") - .eventFilterList(new HashSet<>()) - .userPropertyFilterList(new HashSet<>()) - .segmentationFilterList(new HashSet<>()) - .eventSegmentationFilterMap(new ConcurrentHashMap<>()); + .eventFilterList(new HashSet<>(), false) + .userPropertyFilterList(new HashSet<>(), false) + .segmentationFilterList(new HashSet<>(), false) + .eventSegmentationFilterMap(new ConcurrentHashMap<>(), false); String serverConfig = builder.build(); CountlyConfig countlyConfig = TestUtils.createBaseConfig().setLoggingEnabled(false); From 0d5bb3b225c46dd350acc487add15a0e5e36d5f1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 19 Dec 2025 14:01:11 +0300 Subject: [PATCH 12/42] feat: tests fix misclass --- .../java/ly/count/android/sdk/ServerConfigBuilder.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index eebb94f3b..b6c8bdd20 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -325,9 +325,9 @@ private void validateLimits(Countly countly) { } private void validateFilterSettings(Countly countly) { - Set eventFilterList = (Set) config.get(keyREventSegmentationBlacklist); + Set eventFilterList = (Set) config.get(keyREventBlacklist); if (eventFilterList == null) { - eventFilterList = (Set) config.get(keyREventSegmentationWhitelist); + eventFilterList = (Set) config.get(keyREventWhitelist); } Assert.assertEquals(Objects.requireNonNull(eventFilterList).toString(), countly.moduleConfiguration.getEventFilterList().filterList.toString()); From 93ea2d5a7ef10055f9f1a7cc14f47e62ce5f2d97 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 19 Dec 2025 14:01:49 +0300 Subject: [PATCH 13/42] fix: update count --- .../java/ly/count/android/sdk/ModuleConfigurationTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 79715b33d..5a44cbe9a 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -645,7 +645,7 @@ public void invalidConfigResponses_AreRejected() { */ @Test public void configurationParameterCount() { - int configParameterCount = 37; // plus config, timestamp and version parameters, UPDATE: list filters, and user property cache limit + int configParameterCount = 40; // plus config, timestamp and version parameters, UPDATE: list filters, and user property cache limit int count = 0; for (Field field : ModuleConfiguration.class.getDeclaredFields()) { if (field.getName().startsWith("keyR")) { From f43ab84febf0ae525bc1cbf6c5d963ad67e2aab5 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 22 Dec 2025 15:23:17 +0300 Subject: [PATCH 14/42] feat: some logging --- .../android/sdk/ModuleConfiguration.java | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index 95f094ec9..2e2046e71 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -255,6 +255,11 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { } private void updateListingFilters() { + L.d("[ModuleConfiguration] updateListingFilters, current listing filters before updating: \n" + + "Event Filter List: " + currentVEventFilterList.filterList + ", isWhitelist: " + currentVEventFilterList.isWhitelist + "\n" + + "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist); JSONArray eventBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventBlacklist); JSONArray eventWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventWhitelist); JSONArray userPropertyBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyBlacklist); @@ -264,50 +269,50 @@ private void updateListingFilters() { JSONObject eventSegmentationBlacklistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationBlacklist); JSONObject eventSegmentationWhitelistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationWhitelist); - if (eventWhitelistJSARR != null) { - extractFilterSetFromJSONArray(eventWhitelistJSARR, currentVEventFilterList.filterList); - currentVEventFilterList.isWhitelist = true; - } else if (eventBlacklistJSARR != null) { + if (eventBlacklistJSARR != null) { extractFilterSetFromJSONArray(eventBlacklistJSARR, currentVEventFilterList.filterList); currentVEventFilterList.isWhitelist = false; + } else if (eventWhitelistJSARR != null) { + extractFilterSetFromJSONArray(eventWhitelistJSARR, currentVEventFilterList.filterList); + currentVEventFilterList.isWhitelist = true; } - if (userPropertyWhitelistJSARR != null) { - extractFilterSetFromJSONArray(userPropertyWhitelistJSARR, currentVUserPropertyFilterList.filterList); - currentVUserPropertyFilterList.isWhitelist = true; - } else if (userPropertyBlacklistJSARR != null) { + if (userPropertyBlacklistJSARR != null) { extractFilterSetFromJSONArray(userPropertyBlacklistJSARR, currentVUserPropertyFilterList.filterList); currentVUserPropertyFilterList.isWhitelist = false; + } else if (userPropertyWhitelistJSARR != null) { + extractFilterSetFromJSONArray(userPropertyWhitelistJSARR, currentVUserPropertyFilterList.filterList); + currentVUserPropertyFilterList.isWhitelist = true; } - if (segmentationWhitelistJSARR != null) { - extractFilterSetFromJSONArray(segmentationWhitelistJSARR, currentVSegmentationFilterList.filterList); - currentVSegmentationFilterList.isWhitelist = true; - } else if (segmentationBlacklistJSARR != null) { + if (segmentationBlacklistJSARR != null) { extractFilterSetFromJSONArray(segmentationBlacklistJSARR, currentVSegmentationFilterList.filterList); currentVSegmentationFilterList.isWhitelist = false; + } else if (segmentationWhitelistJSARR != null) { + extractFilterSetFromJSONArray(segmentationWhitelistJSARR, currentVSegmentationFilterList.filterList); + currentVSegmentationFilterList.isWhitelist = true; } - if (eventSegmentationWhitelistJSOBJ != null) { + if (eventSegmentationBlacklistJSOBJ != null) { currentVEventSegmentationFilterList.filterList.clear(); - currentVEventSegmentationFilterList.isWhitelist = true; - Iterator keys = eventSegmentationWhitelistJSOBJ.keys(); + currentVEventSegmentationFilterList.isWhitelist = false; + Iterator keys = eventSegmentationBlacklistJSOBJ.keys(); while (keys.hasNext()) { String key = keys.next(); - JSONArray jsonArray = eventSegmentationWhitelistJSOBJ.optJSONArray(key); + JSONArray jsonArray = eventSegmentationBlacklistJSOBJ.optJSONArray(key); if (jsonArray != null) { Set filterSet = new HashSet<>(); extractFilterSetFromJSONArray(jsonArray, filterSet); currentVEventSegmentationFilterList.filterList.put(key, filterSet); } } - } else if (eventSegmentationBlacklistJSOBJ != null) { + } else if (eventSegmentationWhitelistJSOBJ != null) { currentVEventSegmentationFilterList.filterList.clear(); - currentVEventSegmentationFilterList.isWhitelist = false; - Iterator keys = eventSegmentationBlacklistJSOBJ.keys(); + currentVEventSegmentationFilterList.isWhitelist = true; + Iterator keys = eventSegmentationWhitelistJSOBJ.keys(); while (keys.hasNext()) { String key = keys.next(); - JSONArray jsonArray = eventSegmentationBlacklistJSOBJ.optJSONArray(key); + JSONArray jsonArray = eventSegmentationWhitelistJSOBJ.optJSONArray(key); if (jsonArray != null) { Set filterSet = new HashSet<>(); extractFilterSetFromJSONArray(jsonArray, filterSet); @@ -315,6 +320,12 @@ private void updateListingFilters() { } } } + + L.d("[ModuleConfiguration] updateListingFilters, current listing filters after updating: \n" + + "Event Filter List: " + currentVEventFilterList.filterList + ", isWhitelist: " + currentVEventFilterList.isWhitelist + "\n" + + "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist); } private void extractFilterSetFromJSONArray(@Nullable JSONArray jsonArray, @NonNull Set targetSet) { From ca4b650a60779bf23367d3f99e6606d617b0314c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 15:49:31 +0300 Subject: [PATCH 15/42] feat: jte --- .../android/sdk/ConfigurationProvider.java | 2 ++ .../count/android/sdk/ModuleConfiguration.java | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java index 74e78a8dd..75a13679c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java @@ -47,6 +47,8 @@ interface ConfigurationProvider { FilterList>> getEventSegmentationFilterList(); + Set getJourneyTriggerEvents(); + class FilterList { T filterList; boolean isWhitelist; diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index 2e2046e71..cacb5d5d8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -61,6 +61,7 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRUserPropertyWhitelist = "upw"; final static String keyRSegmentationWhitelist = "sw"; final static String keyREventSegmentationWhitelist = "esw"; // json + final static String keyRJourneyTriggerEvents = "jte"; // FLAGS boolean currentVTracking = true; @@ -86,6 +87,7 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { FilterList> currentVUserPropertyFilterList = new FilterList<>(new HashSet<>(), false); FilterList> currentVSegmentationFilterList = new FilterList<>(new HashSet<>(), false); FilterList>> currentVEventSegmentationFilterList = new FilterList<>(new ConcurrentHashMap<>(), false); + Set currentVJourneyTriggerEvents = new HashSet<>(); // SERVER CONFIGURATION PARAMS Integer serverConfigUpdateInterval; // in hours @@ -259,7 +261,8 @@ private void updateListingFilters() { "Event Filter List: " + currentVEventFilterList.filterList + ", isWhitelist: " + currentVEventFilterList.isWhitelist + "\n" + "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + - "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist); + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist + "\n" + + "Journey Trigger Events: " + currentVJourneyTriggerEvents); JSONArray eventBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventBlacklist); JSONArray eventWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventWhitelist); JSONArray userPropertyBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyBlacklist); @@ -268,6 +271,7 @@ private void updateListingFilters() { JSONArray segmentationWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyRSegmentationWhitelist); JSONObject eventSegmentationBlacklistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationBlacklist); JSONObject eventSegmentationWhitelistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationWhitelist); + JSONArray journeyTriggerEventsJSARR = latestRetrievedConfiguration.optJSONArray(keyRJourneyTriggerEvents); if (eventBlacklistJSARR != null) { extractFilterSetFromJSONArray(eventBlacklistJSARR, currentVEventFilterList.filterList); @@ -321,11 +325,16 @@ private void updateListingFilters() { } } + if (journeyTriggerEventsJSARR != null) { + extractFilterSetFromJSONArray(journeyTriggerEventsJSARR, currentVJourneyTriggerEvents); + } + L.d("[ModuleConfiguration] updateListingFilters, current listing filters after updating: \n" + "Event Filter List: " + currentVEventFilterList.filterList + ", isWhitelist: " + currentVEventFilterList.isWhitelist + "\n" + "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + - "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist); + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist + "\n" + + "Journey Trigger Events: " + currentVJourneyTriggerEvents); } private void extractFilterSetFromJSONArray(@Nullable JSONArray jsonArray, @NonNull Set targetSet) { @@ -431,6 +440,7 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { case keyREventWhitelist: case keyRSegmentationWhitelist: case keyRUserPropertyWhitelist: + case keyRJourneyTriggerEvents: isValid = value instanceof JSONArray; break; case keyREventSegmentationBlacklist: @@ -645,4 +655,8 @@ public boolean getTrackingEnabled() { @Override public FilterList>> getEventSegmentationFilterList() { return currentVEventSegmentationFilterList; } + + @Override public Set getJourneyTriggerEvents() { + return currentVJourneyTriggerEvents; + } } From 088ef6ae6cb473a570efc822a71757271e4850fe Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 15:53:24 +0300 Subject: [PATCH 16/42] fix: for tests --- .../ly/count/android/sdk/ModuleConfigurationTests.java | 5 +++-- .../java/ly/count/android/sdk/ServerConfigBuilder.java | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 5a44cbe9a..04f866534 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -645,7 +645,7 @@ public void invalidConfigResponses_AreRejected() { */ @Test public void configurationParameterCount() { - int configParameterCount = 40; // plus config, timestamp and version parameters, UPDATE: list filters, and user property cache limit + int configParameterCount = 41; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, and journey trigger events int count = 0; for (Field field : ModuleConfiguration.class.getDeclaredFields()) { if (field.getName().startsWith("keyR")) { @@ -1131,7 +1131,8 @@ private void initServerConfigWithValues(BiConsumer config .eventFilterList(new HashSet<>(), false) .userPropertyFilterList(new HashSet<>(), false) .segmentationFilterList(new HashSet<>(), false) - .eventSegmentationFilterMap(new ConcurrentHashMap<>(), false); + .eventSegmentationFilterMap(new ConcurrentHashMap<>(), false) + .journeyTriggerEvents(new HashSet<>()); String serverConfig = builder.build(); CountlyConfig countlyConfig = TestUtils.createBaseConfig().setLoggingEnabled(false); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index b6c8bdd20..b8b526fac 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -22,6 +22,7 @@ import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationBlacklist; import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyREventWhitelist; +import static ly.count.android.sdk.ModuleConfiguration.keyRJourneyTriggerEvents; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitBreadcrumb; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitKeyLength; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitSegValues; @@ -223,6 +224,11 @@ ServerConfigBuilder eventSegmentationFilterMap(Map> filterMa return this; } + ServerConfigBuilder journeyTriggerEvents(Set journeyTriggerEvents) { + config.put(keyRJourneyTriggerEvents, journeyTriggerEvents); + return this; + } + ServerConfigBuilder defaults() { // Feature flags tracking(true); @@ -258,6 +264,7 @@ ServerConfigBuilder defaults() { userPropertyFilterList(new HashSet<>(), false); segmentationFilterList(new HashSet<>(), false); eventSegmentationFilterMap(new ConcurrentHashMap<>(), false); + journeyTriggerEvents(new HashSet<>()); return this; } @@ -348,5 +355,8 @@ private void validateFilterSettings(Countly countly) { eventSegmentationFilterMap = (Map>) config.get(keyREventSegmentationWhitelist); } Assert.assertEquals(Objects.requireNonNull(eventSegmentationFilterMap).toString(), countly.moduleConfiguration.getEventSegmentationFilterList().filterList.toString()); + + Set journeyTriggerEvents = (Set) config.get(keyRJourneyTriggerEvents); + Assert.assertEquals(Objects.requireNonNull(journeyTriggerEvents).toString(), countly.moduleConfiguration.getJourneyTriggerEvents().toString()); } } \ No newline at end of file From 8c51b48af4f8c7d95bbcf39a5010b96e4366a1ea Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 16:12:42 +0300 Subject: [PATCH 17/42] feat: jte implementation --- .../ly/count/android/sdk/ConnectionQueue.java | 13 +++++++++++- .../ly/count/android/sdk/ModuleContent.java | 8 ++++--- .../ly/count/android/sdk/ModuleEvents.java | 5 ++++- .../count/android/sdk/ModuleRequestQueue.java | 21 ++++++++++++++++++- .../android/sdk/RequestQueueProvider.java | 2 ++ 5 files changed, 43 insertions(+), 6 deletions(-) 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 1fa0927a0..10266b4cf 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -583,6 +583,17 @@ public void sendMetricsRequest(@NonNull String preparedMetrics) { * @throws IllegalStateException if context, app key, store, or server URL have not been set */ public void recordEvents(final String events) { + recordEvents(events, null); + } + + /** + * Records the specified events and sends them to the server. + * + * @param events URL-encoded JSON string of event data + * @param callback InternalRequestCallback to be called when request is finished + * @throws IllegalStateException if context, app key, store, or server URL have not been set + */ + public void recordEvents(final String events, InternalRequestCallback callback) { if (!checkInternalState()) { return; } @@ -595,7 +606,7 @@ public void recordEvents(final String events) { final String data = prepareCommonRequestData() + "&events=" + events; - addRequestToQueue(data, false, null); + addRequestToQueue(data, false, callback); tick(); } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 0e5151bf2..733f0d874 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -319,7 +319,7 @@ private void exitContentZoneInternal() { waitForDelay = 0; } - private void refreshContentZoneInternal() { + void refreshContentZoneInternal(boolean callRQFlush) { if (!configProvider.getRefreshContentZoneEnabled()) { return; } @@ -333,7 +333,9 @@ private void refreshContentZoneInternal() { exitContentZoneInternal(); } - _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); + if (callRQFlush) { + _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); + } enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS); } @@ -379,7 +381,7 @@ public void refreshContentZone() { return; } - refreshContentZoneInternal(); + refreshContentZoneInternal(true); } } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java index c0b73246d..352f54d30 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java @@ -230,6 +230,9 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map(); @@ -244,7 +247,7 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map requestQueueRemoveWithoutAppKey(String[] storedRequest * They will be sent either if the exceed the Threshold size or if their sending is forced */ protected void sendEventsIfNeeded(boolean forceSendingEvents) { + sendEventsIfNeeded(forceSendingEvents, false); + } + + /** + * Check if events from event queue need to be added to the request queue + * They will be sent either if they exceed the Threshold size or if their sending is forced + */ + protected void sendEventsIfNeeded(boolean forceSendingEvents, boolean triggerRefreshContentZone) { int eventsInEventQueue = storageProvider.getEventQueueSize(); L.v("[ModuleRequestQueue] forceSendingEvents, forced:[" + forceSendingEvents + "], event count:[" + eventsInEventQueue + "]"); + InternalRequestCallback callback = null; + if (triggerRefreshContentZone) { + callback = new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + if (success) { + _cly.moduleContent.refreshContentZoneInternal(false); + } + } + }; + } + if ((forceSendingEvents && eventsInEventQueue > 0) || eventsInEventQueue >= _cly.EVENT_QUEUE_SIZE_THRESHOLD) { - requestQueueProvider.recordEvents(storageProvider.getEventsForRequestAndEmptyEventQueue()); + requestQueueProvider.recordEvents(storageProvider.getEventsForRequestAndEmptyEventQueue(), callback); } } 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 19fa17936..f9bea9977 100644 --- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java @@ -29,6 +29,8 @@ interface RequestQueueProvider { void recordEvents(final String events); + void recordEvents(final String events, @Nullable InternalRequestCallback callback); + void sendConsentChanges(String formattedConsentChanges); void sendAPMCustomTrace(String key, Long durationMs, Long startMs, Long endMs, String customMetrics); From 2b5b31778bacdaea0ebce9994294c7c3f9a7bfb0 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 16:26:04 +0300 Subject: [PATCH 18/42] feat: apply event filter --- sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java index 352f54d30..fb49202fe 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java @@ -230,6 +230,11 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map Date: Tue, 3 Feb 2026 09:06:21 +0300 Subject: [PATCH 19/42] feat: add event segmentation filter --- .../main/java/ly/count/android/sdk/ModuleEvents.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java index fb49202fe..2fceeaf40 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java @@ -235,13 +235,19 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map(); } + + // apply event segmentation listing filters + UtilsListingFilters.applyEventSegmentationFilter(key, segmentation, configProvider, L); + + // apply journey trigger events here + boolean triggerRefreshContentZone = configProvider.getJourneyTriggerEvents().contains(key); + + String keyTruncated = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, L, "[ModuleEvents] recordEventInternal"); + UtilsInternalLimits.applySdkInternalLimitsToSegmentation(segmentation, _cly.config_.sdkInternalLimits, L, "[ModuleEvents] recordEventInternal"); if (viewNameRecordingEnabled) { From ef568b0fb4d6f36c571d651da705d3201e4e3efe Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 09:31:20 +0300 Subject: [PATCH 20/42] refactor: util listing filter --- sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java index 53bebb174..763bfff4a 100644 --- a/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java @@ -5,7 +5,7 @@ import java.util.Map; import java.util.Set; -public class UtilsListingFilters { +final class UtilsListingFilters { private UtilsListingFilters() { } From 04db1f772d33b3d96c14a33dc67ab11b22cac52d Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 09:32:04 +0300 Subject: [PATCH 21/42] feat: user property segment --- .../java/ly/count/android/sdk/ModuleUserProfile.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java index 30922b146..412530ade 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java @@ -219,6 +219,12 @@ void modifyCustomData(String key, Object value, String mod) { return; } + // apply user property filter + if (!UtilsListingFilters.applyUserPropertyFilter(key, configProvider)) { + L.w("[ModuleUserProfile] modifyCustomData, key: [" + key + "] is filtered out by user property filter, omitting call"); + return; + } + Object valueAdded; String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, _cly.L, "[ModuleUserProfile] modifyCustomData"); if (value instanceof String) { @@ -299,6 +305,11 @@ void setPropertiesInternal(@NonNull Map data) { } if (!isNamed) { + // user property filter + if (!UtilsListingFilters.applyUserPropertyFilter(key, configProvider)) { + L.w("[ModuleUserProfile] setPropertiesInternal, key: [" + key + "] is filtered out by user property filter, omitting call"); + continue; + } String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); if (UtilsInternalLimits.isSupportedDataType(value)) { dataCustomFields.put(truncatedKey, value); From 48fb934ed18b26ac1788bce7bf8eeb5894c48283 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 09:54:09 +0300 Subject: [PATCH 22/42] feat: add segmentation filter too --- sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java index 2fceeaf40..bea8ebb67 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java @@ -240,7 +240,10 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map(); } - // apply event segmentation listing filters + // apply segmentation listing filters + UtilsListingFilters.applySegmentationFilter(segmentation, configProvider, L); + + // then apply specific event segmentation listing filters if any UtilsListingFilters.applyEventSegmentationFilter(key, segmentation, configProvider, L); // apply journey trigger events here From 7ca6d7bed31b5638c6de79d0c60a5c3f879352b4 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 13:51:21 +0300 Subject: [PATCH 23/42] fix: old tests --- .../android/sdk/ConnectionProcessorTests.java | 5 + .../ly/count/android/sdk/CountlyTests.java | 13 +- .../sdk/InternalRequestCallbackTests.java | 172 +++++++++++++++--- 3 files changed, 154 insertions(+), 36 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java index cc4d827f2..89d9baa33 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -29,6 +29,7 @@ of this software and associated documentation files (the "Software"), to deal import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -153,6 +154,10 @@ public void setUp() { @Override public FilterList>> getEventSegmentationFilterList() { return new FilterList<>(new ConcurrentHashMap<>(), false); } + + @Override public Set getJourneyTriggerEvents() { + return Collections.emptySet(); + } }; Countly.sharedInstance().setLoggingEnabled(true); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java index 54388a139..17c290ea6 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java @@ -37,6 +37,7 @@ of this software and associated documentation files (the "Software"), to deal import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.anyInt; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.mock; @@ -401,7 +402,7 @@ public void testOnStop_reallyStopping_emptyEventQueue() { assertEquals(0, mCountly.getActivityCount()); assertTrue(mCountly.getPrevSessionDurationStartTime() > 0); verify(requestQueueProvider).endSession(0); - verify(requestQueueProvider, times(1)).recordEvents(anyString()); // not 0 anymore, it will send orientation event + verify(requestQueueProvider, times(1)).recordEvents(anyString(), isNull()); // not 0 anymore, it will send orientation event } /** @@ -428,7 +429,7 @@ public void testOnStop_reallyStopping_nonEmptyEventQueue() { assertEquals(0, mCountly.getActivityCount()); assertTrue(mCountly.getPrevSessionDurationStartTime() > 0); verify(requestQueueProvider).endSession(0); - verify(requestQueueProvider).recordEvents(eventStr); + verify(requestQueueProvider).recordEvents(eventStr, null); } @Test @@ -493,7 +494,7 @@ public void testSendEventsIfNeeded_equalToThreshold() { mCountly.moduleRequestQueue.sendEventsIfNeeded(false); verify(mCountly.config_.storageProvider, times(1)).getEventsForRequestAndEmptyEventQueue(); - verify(requestQueueProvider, times(1)).recordEvents(eventData); + verify(requestQueueProvider, times(1)).recordEvents(eventData, null); } @Test @@ -509,7 +510,7 @@ public void testSendEventsIfNeeded_moreThanThreshold() { mCountly.moduleRequestQueue.sendEventsIfNeeded(false); verify(mCountly.config_.storageProvider, times(1)).getEventsForRequestAndEmptyEventQueue(); - verify(requestQueueProvider, times(1)).recordEvents(eventData); + verify(requestQueueProvider, times(1)).recordEvents(eventData, null); } @Test @@ -534,7 +535,7 @@ public void testOnTimer_activeSession_emptyEventQueue() { mCountly.onTimer(); verify(requestQueueProvider).updateSession(0); - verify(requestQueueProvider, times(1)).recordEvents(anyString()); // not 0 anymore, it will send orientation event + verify(requestQueueProvider, times(1)).recordEvents(anyString(), isNull()); // not 0 anymore, it will send orientation event } @Test @@ -551,7 +552,7 @@ public void testOnTimer_activeSession_nonEmptyEventQueue() { mCountly.onTimer(); verify(requestQueueProvider).updateSession(0); - verify(requestQueueProvider).recordEvents(eventData); + verify(requestQueueProvider).recordEvents(eventData, null); } @Test diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java index fb0f5e509..d22c918e3 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java @@ -5,7 +5,9 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; +import java.util.Collections; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -355,11 +357,25 @@ public void onRequestCompleted(String response, boolean success) { HealthTracker healthTracker = mock(HealthTracker.class); RequestInfoProvider requestInfoProvider = new RequestInfoProvider() { - @Override public boolean isHttpPostForced() { return false; } - @Override public boolean isDeviceAppCrawler() { return false; } - @Override public boolean ifShouldIgnoreCrawlers() { return false; } - @Override public int getRequestDropAgeHours() { return 1; } - @Override public String getRequestSalt() { return null; } + @Override public boolean isHttpPostForced() { + return false; + } + + @Override public boolean isDeviceAppCrawler() { + return false; + } + + @Override public boolean ifShouldIgnoreCrawlers() { + return false; + } + + @Override public int getRequestDropAgeHours() { + return 1; + } + + @Override public String getRequestSalt() { + return null; + } }; ConnectionProcessor cp = new ConnectionProcessor( @@ -421,11 +437,25 @@ public void onRequestCompleted(String response, boolean success) { HealthTracker healthTracker = mock(HealthTracker.class); RequestInfoProvider requestInfoProvider = new RequestInfoProvider() { - @Override public boolean isHttpPostForced() { return false; } - @Override public boolean isDeviceAppCrawler() { return true; } - @Override public boolean ifShouldIgnoreCrawlers() { return true; } - @Override public int getRequestDropAgeHours() { return 0; } - @Override public String getRequestSalt() { return null; } + @Override public boolean isHttpPostForced() { + return false; + } + + @Override public boolean isDeviceAppCrawler() { + return true; + } + + @Override public boolean ifShouldIgnoreCrawlers() { + return true; + } + + @Override public int getRequestDropAgeHours() { + return 0; + } + + @Override public String getRequestSalt() { + return null; + } }; ConnectionProcessor cp = new ConnectionProcessor( @@ -819,21 +849,89 @@ private static class CountlyResponseStream extends ByteArrayInputStream { */ private ConfigurationProvider createConfigurationProvider() { return new ConfigurationProvider() { - @Override public boolean getNetworkingEnabled() { return true; } - @Override public boolean getTrackingEnabled() { return true; } - @Override public boolean getSessionTrackingEnabled() { return false; } - @Override public boolean getViewTrackingEnabled() { return false; } - @Override public boolean getCustomEventTrackingEnabled() { return false; } - @Override public boolean getContentZoneEnabled() { return false; } - @Override public boolean getCrashReportingEnabled() { return true; } - @Override public boolean getLocationTrackingEnabled() { return true; } - @Override public boolean getRefreshContentZoneEnabled() { return true; } - @Override public boolean getBOMEnabled() { return false; } - @Override public int getBOMAcceptedTimeoutSeconds() { return 10; } - @Override public double getBOMRQPercentage() { return 0.5; } - @Override public int getBOMRequestAge() { return 24; } - @Override public int getBOMDuration() { return 60; } - @Override public int getRequestTimeoutDurationMillis() { return 30_000; } + @Override public boolean getNetworkingEnabled() { + return true; + } + + @Override public boolean getTrackingEnabled() { + return true; + } + + @Override public boolean getSessionTrackingEnabled() { + return false; + } + + @Override public boolean getViewTrackingEnabled() { + return false; + } + + @Override public boolean getCustomEventTrackingEnabled() { + return false; + } + + @Override public boolean getContentZoneEnabled() { + return false; + } + + @Override public boolean getCrashReportingEnabled() { + return true; + } + + @Override public boolean getLocationTrackingEnabled() { + return true; + } + + @Override public boolean getRefreshContentZoneEnabled() { + return true; + } + + @Override public boolean getBOMEnabled() { + return false; + } + + @Override public int getBOMAcceptedTimeoutSeconds() { + return 10; + } + + @Override public double getBOMRQPercentage() { + return 0.5; + } + + @Override public int getBOMRequestAge() { + return 24; + } + + @Override public int getBOMDuration() { + return 60; + } + + @Override public int getRequestTimeoutDurationMillis() { + return 30_000; + } + + @Override public int getUserPropertyCacheLimit() { + return 0; + } + + @Override public FilterList> getEventFilterList() { + return null; + } + + @Override public FilterList> getUserPropertyFilterList() { + return null; + } + + @Override public FilterList> getSegmentationFilterList() { + return null; + } + + @Override public FilterList>> getEventSegmentationFilterList() { + return null; + } + + @Override public Set getJourneyTriggerEvents() { + return Collections.emptySet(); + } }; } @@ -842,11 +940,25 @@ private ConfigurationProvider createConfigurationProvider() { */ private RequestInfoProvider createRequestInfoProvider() { return new RequestInfoProvider() { - @Override public boolean isHttpPostForced() { return false; } - @Override public boolean isDeviceAppCrawler() { return false; } - @Override public boolean ifShouldIgnoreCrawlers() { return false; } - @Override public int getRequestDropAgeHours() { return 0; } - @Override public String getRequestSalt() { return null; } + @Override public boolean isHttpPostForced() { + return false; + } + + @Override public boolean isDeviceAppCrawler() { + return false; + } + + @Override public boolean ifShouldIgnoreCrawlers() { + return false; + } + + @Override public int getRequestDropAgeHours() { + return 0; + } + + @Override public String getRequestSalt() { + return null; + } }; } } From e1f33ba868260be1f00a0ac1f352c39d8d1e02cc Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 13:51:50 +0300 Subject: [PATCH 24/42] feat: integrate user property cache limit --- .../java/ly/count/android/sdk/ModuleConfiguration.java | 2 +- .../java/ly/count/android/sdk/ModuleUserProfile.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index cacb5d5d8..b79103d4f 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -80,7 +80,7 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { double currentVBOMRQPercentage = 0.5; int currentVBOMRequestAge = 24; // in hours int currentVBOMDuration = 60; // in seconds - int currentVUserPropertyCacheLimit = 100; + int currentVUserPropertyCacheLimit = 10_000; // FILTERS FilterList> currentVEventFilterList = new FilterList<>(new HashSet<>(), false); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java index 412530ade..446b751e0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java @@ -310,6 +310,7 @@ void setPropertiesInternal(@NonNull Map data) { L.w("[ModuleUserProfile] setPropertiesInternal, key: [" + key + "] is filtered out by user property filter, omitting call"); continue; } + String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); if (UtilsInternalLimits.isSupportedDataType(value)) { dataCustomFields.put(truncatedKey, value); @@ -329,6 +330,15 @@ void setPropertiesInternal(@NonNull Map data) { custom.putAll(dataCustomFields); + int cacheLimit = configProvider.getUserPropertyCacheLimit(); + while (custom.size() > cacheLimit) { + Iterator iterator = custom.keySet().iterator(); + if (iterator.hasNext()) { + iterator.next(); + iterator.remove(); + } + } + isSynced = false; } From f1005bd3ad216b1a8715c6c023f7f0c003cec3f8 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 13:53:56 +0300 Subject: [PATCH 25/42] fix: revert back the limit1 --- sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index b79103d4f..cacb5d5d8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -80,7 +80,7 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { double currentVBOMRQPercentage = 0.5; int currentVBOMRequestAge = 24; // in hours int currentVBOMDuration = 60; // in seconds - int currentVUserPropertyCacheLimit = 10_000; + int currentVUserPropertyCacheLimit = 100; // FILTERS FilterList> currentVEventFilterList = new FilterList<>(new HashSet<>(), false); From c8dce25be0759e05b38a2a9d312b4292a6f584f1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 14:02:22 +0300 Subject: [PATCH 26/42] feat: apply limit to mods too --- .../ly/count/android/sdk/ModuleUserProfile.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java index 446b751e0..15e8cf7c8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java @@ -252,7 +252,10 @@ void modifyCustomData(String key, Object value, String mod) { } ob.accumulate(mod, valueAdded); } + customMods.put(truncatedKey, ob); + applyUserPropertyCacheLimit(customMods); + isSynced = false; } catch (JSONException e) { e.printStackTrace(); @@ -329,17 +332,20 @@ void setPropertiesInternal(@NonNull Map data) { } custom.putAll(dataCustomFields); + applyUserPropertyCacheLimit(custom); + isSynced = false; + } + + private void applyUserPropertyCacheLimit(Map map) { int cacheLimit = configProvider.getUserPropertyCacheLimit(); - while (custom.size() > cacheLimit) { - Iterator iterator = custom.keySet().iterator(); + while (map.size() > cacheLimit) { + Iterator iterator = map.keySet().iterator(); if (iterator.hasNext()) { iterator.next(); iterator.remove(); } } - - isSynced = false; } /** From a12f2e2d82276b7fd45402deb9a2a1d08e42cece Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 14:34:50 +0300 Subject: [PATCH 27/42] fix: reset eq size --- sdk/src/main/java/ly/count/android/sdk/Countly.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index 61c7bba70..8570d93c6 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -960,6 +960,9 @@ public synchronized void halt() { moduleHealthCheck = null; moduleContent = null; + // Reset configuration values that may have been changed during runtime + EVENT_QUEUE_SIZE_THRESHOLD = 100; + COUNTLY_SDK_VERSION_STRING = DEFAULT_COUNTLY_SDK_VERSION_STRING; COUNTLY_SDK_NAME = DEFAULT_COUNTLY_SDK_NAME; From 503c7fb38794960f58a936c16a109e972f7bb9ca Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 14:51:01 +0300 Subject: [PATCH 28/42] feat: more strict validation --- .../android/sdk/ModuleConfigurationTests.java | 1101 +++++++++++++++++ .../android/sdk/ServerConfigBuilder.java | 12 + 2 files changed, 1113 insertions(+) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 04f866534..ac96a5b02 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -7,6 +7,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; @@ -1286,4 +1287,1104 @@ private void base_allFeatures(Consumer consumer, int hc, in validateCounts(counts, hc, fc, rc, cc, scc); } + + // ================ Event Filter Tests ================ + + /** + * Tests that event blacklist properly blocks filtered events. + * Events in the blacklist should not be recorded. + */ + @Test + public void eventFilter_blacklist_blocksFilteredEvents() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_event"); + blacklist.add("another_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record a blocked event - should not be recorded + Countly.sharedInstance().events().recordEvent("blocked_event"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Record another blocked event - should not be recorded + Countly.sharedInstance().events().recordEvent("another_blocked"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Record an allowed event - should be recorded + Countly.sharedInstance().events().recordEvent("allowed_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + Assert.assertTrue(countlyStore.getEvents()[0].contains("allowed_event")); + + // Verify the filter state + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("blocked_event")); + } + + /** + * Tests that event whitelist only allows specified events. + * Only events in the whitelist should be recorded. + */ + @Test + public void eventFilter_whitelist_onlyAllowsSpecifiedEvents() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_event"); + whitelist.add("another_allowed"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(whitelist, true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record an allowed event - should be recorded + Countly.sharedInstance().events().recordEvent("allowed_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Record another allowed event - should be recorded + Countly.sharedInstance().events().recordEvent("another_allowed"); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + // Record an event not in whitelist - should not be recorded + Countly.sharedInstance().events().recordEvent("not_in_whitelist"); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + Assert.assertFalse(countlyStore.getEvents()[0].contains("not_in_whitelist")); + Assert.assertFalse(countlyStore.getEvents()[1].contains("not_in_whitelist")); + + // Verify the filter state + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("allowed_event")); + } + + /** + * Tests that an empty event filter allows all events. + * When no filter rules are defined, all events should pass through. + */ + @Test + public void eventFilter_emptyFilter_allowsAllEvents() throws JSONException { + Set emptySet = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(emptySet, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // All events should be recorded with empty filter + Countly.sharedInstance().events().recordEvent("event_1"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("event_2"); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("any_event"); + Assert.assertEquals(3, countlyStore.getEventQueueSize()); + } + + /** + * Tests that internal events bypass event filters. + * Internal SDK events like views should not be affected by event filters. + */ + @Test + public void eventFilter_internalEventsNotAffected() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("[CLY]_view"); // Try to block view events + blacklist.add("test_blocked"); // A custom event to block + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + int initialQueueSize = countlyStore.getEventQueueSize(); + + // View events should still be recorded (internal events bypass filters) + Countly.sharedInstance().views().startView("test_view"); + Assert.assertEquals(initialQueueSize + 1, countlyStore.getEventQueueSize()); + + // Custom blocked event should be blocked + Countly.sharedInstance().events().recordEvent("test_blocked"); + Assert.assertEquals(initialQueueSize + 1, countlyStore.getEventQueueSize()); // Still same, blocked + + Assert.assertTrue(countlyStore.getEvents()[initialQueueSize].contains("[CLY]_view")); + } + + // ================ User Property Filter Tests ================ + + /** + * Tests that user property blacklist properly blocks filtered properties. + * Properties in the blacklist should not be recorded. + */ + @Test + public void userPropertyFilter_blacklist_blocksFilteredProperties() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_prop"); + blacklist.add("another_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Set properties - blocked ones should be filtered + Map properties = new HashMap<>(); + properties.put("blocked_prop", "value1"); + properties.put("another_blocked", "value2"); + properties.put("allowed_prop", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Only allowed_prop should be set in custom properties + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("allowed_prop")); + Assert.assertFalse(Countly.sharedInstance().moduleUserProfile.custom.containsKey("blocked_prop")); + Assert.assertFalse(Countly.sharedInstance().moduleUserProfile.custom.containsKey("another_blocked")); + + // Save and verify request only contains allowed property + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest(TestUtils.map(), TestUtils.map("allowed_prop", "value3")); + } + + /** + * Tests that user property whitelist only allows specified properties. + */ + @Test + public void userPropertyFilter_whitelist_onlyAllowsSpecifiedProperties() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_prop"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(whitelist, true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new HashMap<>(); + properties.put("allowed_prop", "value1"); + properties.put("not_allowed", "value2"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("allowed_prop")); + Assert.assertFalse(Countly.sharedInstance().moduleUserProfile.custom.containsKey("not_allowed")); + + // Save and verify request only contains allowed property + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest(TestUtils.map(), TestUtils.map("allowed_prop", "value1")); + } + + /** + * Tests that named user properties bypass filters. + * Named properties like name, email, username should not be filtered. + */ + @Test + public void userPropertyFilter_namedPropertiesBypassFilter() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("name"); + blacklist.add("email"); + blacklist.add("custom_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new HashMap<>(); + properties.put("name", "John Doe"); + properties.put("email", "john@example.com"); + properties.put("custom_blocked", "blocked_value"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Named properties should be set despite being in blacklist + Assert.assertEquals("John Doe", Countly.sharedInstance().moduleUserProfile.name); + Assert.assertEquals("john@example.com", Countly.sharedInstance().moduleUserProfile.email); + + // Save and verify named properties are in request but custom_blocked is not + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest( + TestUtils.map("name", "John Doe", "email", "john@example.com"), + TestUtils.map() // custom_blocked should be filtered out + ); + } + + /** + * Tests that modifyCustomData respects user property filters. + */ + @Test + public void userPropertyFilter_modifyCustomData_respectsFilter() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_prop"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Try to increment a blocked property - should be ignored + Countly.sharedInstance().userProfile().incrementBy("blocked_prop", 5); + Assert.assertNull(Countly.sharedInstance().moduleUserProfile.customMods); + + // Increment an allowed property - should work + Countly.sharedInstance().userProfile().incrementBy("allowed_prop", 5); + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.customMods); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.customMods.containsKey("allowed_prop")); + + // Save and verify request only contains allowed property with increment + Countly.sharedInstance().userProfile().save(); + JSONObject expectedMod = new JSONObject(); + expectedMod.put("$inc", 5); + ModuleUserProfileTests.validateUserProfileRequest( + TestUtils.map(), + TestUtils.map("allowed_prop", expectedMod) + ); + } + + // ================ Segmentation Filter Tests ================ + + /** + * Tests that segmentation blacklist removes filtered keys from event segmentation. + */ + @Test + public void segmentationFilter_blacklist_removesFilteredKeys() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_key"); + blacklist.add("another_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(blacklist, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new HashMap<>(); + segmentation.put("blocked_key", "value1"); + segmentation.put("another_blocked", "value2"); + segmentation.put("allowed_key", "value3"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + // Verify only allowed_key is in the recorded event + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("allowed_key", "value3"), 0, 1, 0, 1); + } + + /** + * Tests that segmentation whitelist only keeps specified keys. + */ + @Test + public void segmentationFilter_whitelist_onlyKeepsSpecifiedKeys() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_key"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(whitelist, true).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new HashMap<>(); + segmentation.put("allowed_key", "value1"); + segmentation.put("not_allowed_1", "value2"); + segmentation.put("not_allowed_2", "value3"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("allowed_key", "value1"), 0, 1, 0, 1); + } + + /** + * Tests that empty segmentation filter allows all keys. + */ + @Test + public void segmentationFilter_emptyFilter_allowsAllKeys() throws JSONException { + Set emptySet = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(emptySet, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new HashMap<>(); + segmentation.put("key1", "value1"); + segmentation.put("key2", "value2"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("key1", "value1", "key2", "value2"), 0, 1, 0, 1); + } + + // ================ Event Segmentation Filter Tests ================ + + /** + * Tests that event-specific segmentation blacklist only affects specified events. + */ + @Test + public void eventSegmentationFilter_blacklist_affectsSpecificEvents() throws JSONException { + Map> filterMap = new ConcurrentHashMap<>(); + Set event1Filter = new HashSet<>(); + event1Filter.add("blocked_for_event1"); + filterMap.put("event1", event1Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(filterMap, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // For event1, blocked_for_event1 should be removed + Map segmentation1 = new HashMap<>(); + segmentation1.put("blocked_for_event1", "value1"); + segmentation1.put("allowed_key", "value2"); + Countly.sharedInstance().events().recordEvent("event1", segmentation1); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event1", TestUtils.map("allowed_key", "value2"), 0, 1, 0, 1); + + // For event2, blocked_for_event1 should NOT be removed (filter only applies to event1) + Map segmentation2 = new HashMap<>(); + segmentation2.put("blocked_for_event1", "value1"); + segmentation2.put("other_key", "value2"); + Countly.sharedInstance().events().recordEvent("event2", segmentation2); + + Assert.assertEquals(2, TestUtils.getCurrentRQ().length); + validateEventInRQ("event2", TestUtils.map("blocked_for_event1", "value1", "other_key", "value2"), 1, 2, 0, 1); + } + + /** + * Tests that event-specific segmentation whitelist only keeps specified keys for that event. + */ + @Test + public void eventSegmentationFilter_whitelist_onlyKeepsSpecifiedKeysForEvent() throws JSONException { + Map> filterMap = new ConcurrentHashMap<>(); + Set event1Filter = new HashSet<>(); + event1Filter.add("allowed_for_event1"); + filterMap.put("event1", event1Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(filterMap, true).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // For event1, only allowed_for_event1 should remain + Map segmentation = new HashMap<>(); + segmentation.put("allowed_for_event1", "value1"); + segmentation.put("not_allowed", "value2"); + Countly.sharedInstance().events().recordEvent("event1", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event1", TestUtils.map("allowed_for_event1", "value1"), 0, 1, 0, 1); + } + + /** + * Tests that events without specific rules pass all segmentation. + */ + @Test + public void eventSegmentationFilter_noRulesForEvent_allowsAllSegmentation() throws JSONException { + Map> filterMap = new ConcurrentHashMap<>(); + Set event1Filter = new HashSet<>(); + event1Filter.add("some_key"); + filterMap.put("event1", event1Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(filterMap, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // event2 has no rules, so all segmentation should pass + Map segmentation = new HashMap<>(); + segmentation.put("any_key", "value1"); + segmentation.put("any_other_key", "value2"); + Countly.sharedInstance().events().recordEvent("event2", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event2", TestUtils.map("any_key", "value1", "any_other_key", "value2"), 0, 1, 0, 1); + } + + /** + * Tests that both general segmentation filter and event-specific filter are applied. + */ + @Test + public void segmentationFilters_combined_bothFiltersApplied() throws JSONException { + // General segmentation blacklist + Set generalBlacklist = new HashSet<>(); + generalBlacklist.add("general_blocked"); + + // Event-specific blacklist + Map> eventFilterMap = new ConcurrentHashMap<>(); + Set eventFilter = new HashSet<>(); + eventFilter.add("event_specific_blocked"); + eventFilterMap.put("test_event", eventFilter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .segmentationFilterList(generalBlacklist, false) + .eventSegmentationFilterMap(eventFilterMap, false) + .eventQueueSize(1) + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new HashMap<>(); + segmentation.put("general_blocked", "value1"); + segmentation.put("event_specific_blocked", "value2"); + segmentation.put("allowed_key", "value3"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + // Both general and event-specific blocked keys should be removed + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("allowed_key", "value3"), 0, 1, 0, 1); + } + + // ================ Journey Trigger Events Tests ================ + + /** + * Tests that journey trigger events are correctly configured. + */ + @Test + public void journeyTriggerEvents_configuredCorrectly() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("trigger_event"); + triggerEvents.add("another_trigger"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Verify trigger events are configured + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("trigger_event")); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("another_trigger")); + Assert.assertEquals(2, Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().size()); + } + + /** + * Tests that empty journey trigger events set is handled correctly. + */ + @Test + public void journeyTriggerEvents_emptySet_noRefresh() throws JSONException { + Set emptyTriggerEvents = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().journeyTriggerEvents(emptyTriggerEvents).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().isEmpty()); + } + + /** + * Tests that journey trigger events can be updated via server config. + */ + @Test + public void journeyTriggerEvents_serverConfigUpdate() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("new_trigger_event"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().journeyTriggerEvents(triggerEvents).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("new_trigger_event")); + } + + // ================ User Property Cache Limit Tests ================ + + /** + * Tests that user property cache limit default value is correct. + */ + @Test + public void userPropertyCacheLimit_defaultValue() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(100, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that user property cache limit can be updated via server configuration. + */ + @Test + public void userPropertyCacheLimit_serverConfigUpdate() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(50).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(50, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that user property cache limit can be configured via SDK behavior settings. + * This tests loading from local configuration rather than server response. + */ + @Test + public void userPropertyCacheLimit_configuredViaSdkBehaviorSettings() throws JSONException { + // Create a server config with custom value and use it as SDK behavior settings + String serverConfig = new ServerConfigBuilder().defaults().userPropertyCacheLimit(75).build(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.setSDKBehaviorSettings(serverConfig); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(75, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that user property cache limit enforcement removes oldest properties when exceeded. + * When more custom properties are set than the limit, oldest ones should be removed. + */ + @Test + public void userPropertyCacheLimit_enforcesLimitOnSetProperties() throws JSONException { + // Set a small cache limit + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(3).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(3, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + + // Set more properties than the limit + Map properties = new HashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + properties.put("prop4", "value4"); + properties.put("prop5", "value5"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Only 3 properties should remain (the limit) + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that user property cache limit does not affect properties when under limit. + */ + @Test + public void userPropertyCacheLimit_allowsPropertiesUnderLimit() throws JSONException { + // Set a cache limit higher than properties we'll add + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(10).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Set fewer properties than the limit + Map properties = new HashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // All properties should remain + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop1")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop2")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop3")); + } + + /** + * Tests that named properties are not affected by user property cache limit. + * Named properties (name, email, etc.) should be separate from custom properties limit. + */ + @Test + public void userPropertyCacheLimit_namedPropertiesNotAffected() throws JSONException { + // Set a small cache limit for custom properties + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Set named properties plus custom properties exceeding limit + Map properties = new HashMap<>(); + properties.put("name", "John Doe"); + properties.put("email", "john@example.com"); + properties.put("username", "johndoe"); + properties.put("custom1", "value1"); + properties.put("custom2", "value2"); + properties.put("custom3", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Named properties should be set regardless of limit + Assert.assertEquals("John Doe", Countly.sharedInstance().moduleUserProfile.name); + Assert.assertEquals("john@example.com", Countly.sharedInstance().moduleUserProfile.email); + Assert.assertEquals("johndoe", Countly.sharedInstance().moduleUserProfile.username); + + // Custom properties should be limited to 2 + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that cache limit enforcement works across multiple setProperties calls. + */ + @Test + public void userPropertyCacheLimit_enforcesAcrossMultipleCalls() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(3).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // First call - add 2 properties + Map props1 = new HashMap<>(); + props1.put("prop1", "value1"); + props1.put("prop2", "value2"); + Countly.sharedInstance().userProfile().setProperties(props1); + + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + + // Second call - add 2 more (total 4, but limit is 3) + Map props2 = new HashMap<>(); + props2.put("prop3", "value3"); + props2.put("prop4", "value4"); + Countly.sharedInstance().userProfile().setProperties(props2); + + // Should be limited to 3 + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that cache limit of 1 only keeps one property. + */ + @Test + public void userPropertyCacheLimit_limitOfOne() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new HashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Only 1 property should remain + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that cache limit is enforced on modification operations (incrementBy). + * When using incrementBy on multiple properties exceeding the limit, oldest should be removed. + */ + @Test + public void userPropertyCacheLimit_enforcesOnIncrementBy() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Add multiple increment operations exceeding the limit + Countly.sharedInstance().userProfile().incrementBy("counter1", 1); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter2", 2); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter3", 3); + // Should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter4", 4); + // Still limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that cache limit is enforced on multiply operations. + */ + @Test + public void userPropertyCacheLimit_enforcesOnMultiply() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Countly.sharedInstance().userProfile().multiply("value1", 2); + Countly.sharedInstance().userProfile().multiply("value2", 3); + Countly.sharedInstance().userProfile().multiply("value3", 4); + + // Should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that cache limit is enforced on push operations. + */ + @Test + public void userPropertyCacheLimit_enforcesOnPush() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Countly.sharedInstance().userProfile().push("array1", "item1"); + Countly.sharedInstance().userProfile().push("array2", "item2"); + Countly.sharedInstance().userProfile().push("array3", "item3"); + + // Should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that cache limit is enforced on mixed modification operations. + */ + @Test + public void userPropertyCacheLimit_enforcesOnMixedMods() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(3).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Countly.sharedInstance().userProfile().incrementBy("counter", 1); + Countly.sharedInstance().userProfile().multiply("multiplier", 2); + Countly.sharedInstance().userProfile().push("array", "item"); + Countly.sharedInstance().userProfile().saveMax("maxValue", 100); + Countly.sharedInstance().userProfile().saveMin("minValue", 1); + + // Should be limited to 3 + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that updating the same property doesn't increase count. + */ + @Test + public void userPropertyCacheLimit_samePropertyUpdateDoesNotIncreaseCount() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Increment same property multiple times + Countly.sharedInstance().userProfile().incrementBy("counter", 1); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter", 2); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter", 3); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + // Add a different property + Countly.sharedInstance().userProfile().incrementBy("otherCounter", 1); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that custom properties and customMods have separate limits. + * Both should respect the same cache limit independently. + */ + @Test + public void userPropertyCacheLimit_separateLimitsForCustomAndMods() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Add properties via setProperties + Map properties = new HashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + Countly.sharedInstance().userProfile().setProperties(properties); + + // Custom should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + + // Add modifications + Countly.sharedInstance().userProfile().incrementBy("counter1", 1); + Countly.sharedInstance().userProfile().incrementBy("counter2", 2); + Countly.sharedInstance().userProfile().incrementBy("counter3", 3); + + // CustomMods should also be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + // Both limits are independent + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + // ================ Filter Configuration Parsing Tests ================ + + /** + * Tests that filter configuration is correctly parsed from server response. + */ + @Test + public void filterConfigParsing_allFiltersCorrectlyParsed() throws JSONException { + Set eventBlacklist = new HashSet<>(); + eventBlacklist.add("blocked_event"); + + Set userPropertyBlacklist = new HashSet<>(); + userPropertyBlacklist.add("blocked_prop"); + + Set segmentationBlacklist = new HashSet<>(); + segmentationBlacklist.add("blocked_seg"); + + Map> eventSegBlacklist = new ConcurrentHashMap<>(); + Set eventSpecificSeg = new HashSet<>(); + eventSpecificSeg.add("specific_key"); + eventSegBlacklist.put("specific_event", eventSpecificSeg); + + Set journeyTriggers = new HashSet<>(); + journeyTriggers.add("journey_event"); + + ServerConfigBuilder builder = new ServerConfigBuilder().defaults() + .eventFilterList(eventBlacklist, false) + .userPropertyFilterList(userPropertyBlacklist, false) + .segmentationFilterList(segmentationBlacklist, false) + .eventSegmentationFilterMap(eventSegBlacklist, false) + .journeyTriggerEvents(journeyTriggers) + .userPropertyCacheLimit(200); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse(builder.build()); + Countly.sharedInstance().init(countlyConfig); + + // Verify all filters are correctly configured + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("blocked_event")); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getUserPropertyFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getUserPropertyFilterList().filterList.contains("blocked_prop")); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getSegmentationFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getSegmentationFilterList().filterList.contains("blocked_seg")); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventSegmentationFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventSegmentationFilterList().filterList.containsKey("specific_event")); + + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("journey_event")); + + Assert.assertEquals(200, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that blacklist mode is correctly set when using blacklist. + */ + @Test + public void filterConfigParsing_blacklistModeSet() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_event"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Should be blacklist mode + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("blocked_event")); + } + + /** + * Tests that whitelist mode is correctly set when using whitelist. + */ + @Test + public void filterConfigParsing_whitelistModeSet() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_event"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(whitelist, true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Should be whitelist mode + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("allowed_event")); + } + + // ================ Edge Case Tests ================ + + /** + * Tests filter behavior with special characters in event names. + */ + @Test + public void edgeCase_specialCharactersInEventName() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("event with spaces"); + blacklist.add("event-with-dashes"); + blacklist.add("event_with_underscores"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // All should be blocked + Countly.sharedInstance().events().recordEvent("event with spaces"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("event-with-dashes"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("event_with_underscores"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // This should pass + Countly.sharedInstance().events().recordEvent("normal_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + } + + /** + * Tests filter behavior with empty segmentation map. + */ + @Test + public void edgeCase_emptySegmentationMap() throws JSONException { + Set segBlacklist = new HashSet<>(); + segBlacklist.add("some_key"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(segBlacklist, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record event with empty segmentation - should work fine + Countly.sharedInstance().events().recordEvent("test_event", new HashMap<>()); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + } + + /** + * Tests filter behavior with null segmentation. + */ + @Test + public void edgeCase_nullSegmentation() throws JSONException { + Set segBlacklist = new HashSet<>(); + segBlacklist.add("some_key"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(segBlacklist, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record event with null segmentation - should work fine + Countly.sharedInstance().events().recordEvent("test_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + } + + /** + * Tests that filter configuration update works during runtime. + */ + @Test + public void edgeCase_filterConfigurationRuntimeUpdate() throws JSONException { + // Start with no filters + Set emptySet = new HashSet<>(); + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(emptySet, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Events should be allowed + Countly.sharedInstance().events().recordEvent("test_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Reinitialize with filter + Countly.sharedInstance().halt(); + countlyStore.clear(); // Clear storage for fresh start + + Set blacklist = new HashSet<>(); + blacklist.add("test_event"); + CountlyConfig config2 = TestUtils.createBaseConfig().enableManualSessionControl(); + config2.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(config2); + + // Now event should be blocked + Countly.sharedInstance().events().recordEvent("test_event"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); // 0 because blocked + + // Different event should pass + Countly.sharedInstance().events().recordEvent("other_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + } + + /** + * Tests multiple events with different filter rules applied correctly. + */ + @Test + public void edgeCase_multipleEventsWithDifferentFilters() throws JSONException { + Map> eventSegFilterMap = new ConcurrentHashMap<>(); + + Set event1Filter = new HashSet<>(); + event1Filter.add("key_a"); + eventSegFilterMap.put("event1", event1Filter); + + Set event2Filter = new HashSet<>(); + event2Filter.add("key_b"); + eventSegFilterMap.put("event2", event2Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(eventSegFilterMap, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // event1: key_a should be blocked, key_b allowed + Map seg1 = new HashMap<>(); + seg1.put("key_a", "value"); + seg1.put("key_b", "value"); + Countly.sharedInstance().events().recordEvent("event1", seg1); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event1", TestUtils.map("key_b", "value"), 0, 1, 0, 1); + + // event2: key_b should be blocked, key_a allowed + Map seg2 = new HashMap<>(); + seg2.put("key_a", "value"); + seg2.put("key_b", "value"); + Countly.sharedInstance().events().recordEvent("event2", seg2); + + Assert.assertEquals(2, TestUtils.getCurrentRQ().length); + validateEventInRQ("event2", TestUtils.map("key_a", "value"), 1, 2, 0, 1); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index b8b526fac..607ede8f3 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -189,36 +189,48 @@ ServerConfigBuilder userPropertyCacheLimit(int limit) { } ServerConfigBuilder eventFilterList(Set filterList, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity if (isWhitelist) { + config.remove(keyREventBlacklist); config.put(keyREventWhitelist, filterList); } else { + config.remove(keyREventWhitelist); config.put(keyREventBlacklist, filterList); } return this; } ServerConfigBuilder userPropertyFilterList(Set filterList, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity if (isWhitelist) { + config.remove(keyRUserPropertyBlacklist); config.put(keyRUserPropertyWhitelist, filterList); } else { + config.remove(keyRUserPropertyWhitelist); config.put(keyRUserPropertyBlacklist, filterList); } return this; } ServerConfigBuilder segmentationFilterList(Set filterList, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity if (isWhitelist) { + config.remove(keyRSegmentationBlacklist); config.put(keyRSegmentationWhitelist, filterList); } else { + config.remove(keyRSegmentationWhitelist); config.put(keyRSegmentationBlacklist, filterList); } return this; } ServerConfigBuilder eventSegmentationFilterMap(Map> filterMap, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity if (isWhitelist) { + config.remove(keyREventSegmentationBlacklist); config.put(keyREventSegmentationWhitelist, filterMap); } else { + config.remove(keyREventSegmentationWhitelist); config.put(keyREventSegmentationBlacklist, filterMap); } return this; From 90b1f8b22f4a8fd9ab05778fc850866140a80412 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 21:51:41 +0300 Subject: [PATCH 29/42] feat: jte trigger test --- sdk/src/androidTest/AndroidManifest.xml | 10 +- .../android/sdk/ModuleConfigurationTests.java | 110 ++++++++++++++++++ .../res/xml/network_security_config.xml | 8 ++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 sdk/src/androidTest/res/xml/network_security_config.xml diff --git a/sdk/src/androidTest/AndroidManifest.xml b/sdk/src/androidTest/AndroidManifest.xml index d79e9e896..c790cbd36 100644 --- a/sdk/src/androidTest/AndroidManifest.xml +++ b/sdk/src/androidTest/AndroidManifest.xml @@ -1,7 +1,11 @@ - - - + + + triggerEvents = new HashSet<>(); + triggerEvents.add("jte_event"); + + // Use high event queue threshold to verify force flush behavior + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .eventQueueSize(100) // High threshold + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + + // Record a non-JTE event - should NOT force flush (queue threshold is 100) + Countly.sharedInstance().events().recordEvent("regular_event"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); // Still in event queue + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Record a JTE event - should force flush and have callback_id + Countly.sharedInstance().events().recordEvent("jte_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); // Force flushed to RQ + Assert.assertEquals(0, countlyStore.getEventQueueSize()); // Event queue emptied + + // Verify the request contains callback_id (indicates refresh callback registered) + Map[] rq = TestUtils.getCurrentRQ(); + Assert.assertTrue(rq[0].containsKey("callback_id")); + } + + /** + * Tests that when a journey trigger event is recorded and successfully delivered, + * refreshContentZone is called. + * Uses a mocked ConnectionProcessor to simulate HTTP responses through the SDK's normal flow. + */ + @Test + public void journeyTriggerEvents_contentZoneRefreshFlow() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("purchase_complete"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(new Dispatcher() { + @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { + MockResponse response = new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json"); + if (recordedRequest.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + } else if (recordedRequest.getPath().contains("&method=sc")) { + try { + return response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) + .build()); + } catch (JSONException ignored) { + } + } + return response.setBody("{\"result\": \"Success\"}"); + } + }); + + // Start server on localhost - both server and SDK run inside emulator + server.start(); + String serverUrl = server.url("/").toString(); + // Remove trailing slash + serverUrl = serverUrl.substring(0, serverUrl.length() - 1); + + countlyConfig.metricProviderOverride = new MockedMetricProvider(); + countlyConfig.setServerURL(serverUrl); + Countly.sharedInstance().init(countlyConfig); + Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; + Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; + + // Wait for server config to be fetched and applied + Thread.sleep(2000); + // verify that enter is called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - this adds request with callback to RQ + Countly.sharedInstance().events().recordEvent("purchase_complete"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + // Get the callback_id from the request + Map[] rq = TestUtils.getCurrentRQ(); + String callbackId = rq[0].get("callback_id"); + Assert.assertNotNull(callbackId); + Thread.sleep(1000); + Assert.assertEquals(2, contentRequestCount.get()); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + server.shutdown(); + } + } + // ================ User Property Cache Limit Tests ================ /** diff --git a/sdk/src/androidTest/res/xml/network_security_config.xml b/sdk/src/androidTest/res/xml/network_security_config.xml new file mode 100644 index 000000000..7c5b45b40 --- /dev/null +++ b/sdk/src/androidTest/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 0.0.0.0 + localhost + 127.0.0.1 + + \ No newline at end of file From 322b9626b4721894fddfca38a2d69a9ef8cc39b9 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 3 Feb 2026 21:56:44 +0300 Subject: [PATCH 30/42] fix: remove unnecessary test case --- .../android/sdk/ModuleConfigurationTests.java | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 4953d8867..5a2cc6bfb 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1798,23 +1798,6 @@ public void journeyTriggerEvents_emptySet_noRefresh() throws JSONException { Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().isEmpty()); } - /** - * Tests that journey trigger events can be updated via server config. - */ - @Test - public void journeyTriggerEvents_serverConfigUpdate() throws JSONException { - Set triggerEvents = new HashSet<>(); - triggerEvents.add("new_trigger_event"); - - CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); - countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( - new ServerConfigBuilder().defaults().journeyTriggerEvents(triggerEvents).build() - ); - Countly.sharedInstance().init(countlyConfig); - - Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("new_trigger_event")); - } - /** * Tests that recording a journey trigger event forces event flush and registers * a callback for content zone refresh. From b87ffca3df987b595db5ee3b4fc32d2271c0684d Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 00:31:50 +0300 Subject: [PATCH 31/42] fix: add null check for tests --- sdk/src/main/java/ly/count/android/sdk/Countly.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index 8570d93c6..00e1419b7 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -990,11 +990,13 @@ void onStartInternal(Activity activity) { ++activityCount_; if (activityCount_ == 1) { // start the timer in the first activity - moduleConfiguration.fetchIfTimeIsUpForFetchingServerConfig(); + if (moduleConfiguration != null) { + moduleConfiguration.fetchIfTimeIsUpForFetchingServerConfig(); + } //if we open the first activity //and we are not using manual session control, //begin a session - if (!moduleSessions.manualSessionControlEnabled) { + if (moduleSessions != null && !moduleSessions.manualSessionControlEnabled) { moduleSessions.beginSessionInternal(); } } @@ -1017,7 +1019,7 @@ void onStopInternal() { } --activityCount_; - if (activityCount_ == 0 && !moduleSessions.manualSessionControlEnabled) { + if (activityCount_ == 0 && moduleSessions != null && !moduleSessions.manualSessionControlEnabled) { // if we don't use manual session control // Called when final Activity is stopped. // Sends an end session event to the server, also sends any unsent custom events. @@ -1168,7 +1170,8 @@ public void setLoggingEnabled(final boolean enableLogging) { * @param customHeaderValues map of header key/value pairs to add/override * @return Returns the same Countly instance for convenient chaining */ - /* package */ synchronized void addCustomNetworkRequestHeaders(Map customHeaderValues) { + /* package */ + synchronized void addCustomNetworkRequestHeaders(Map customHeaderValues) { if (!isInitialized()) { L.e("[addCustomNetworkRequestHeaders] SDK must be initialised before calling this method"); return; From c61c8776b60926c8ec263c2a449621472aa1ab60 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 00:32:19 +0300 Subject: [PATCH 32/42] feat: add mock web server --- sdk/build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sdk/build.gradle b/sdk/build.gradle index 8f9b2a812..079565e4b 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -64,9 +64,8 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'junit:junit:4.13.2' - androidTestImplementation "org.mockito:mockito-core:${mockitoVersion}" androidTestImplementation "org.mockito:mockito-android:${mockitoVersion}" - //androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.9.0" + androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.12.0" } //Plugin for generating test coverage reports. From 8214fafc3b162fa28cc290d4c3c58c19d5087078 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 00:33:33 +0300 Subject: [PATCH 33/42] feat: add no content shown while some is showed --- .../android/sdk/ModuleConfigurationTests.java | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 5a2cc6bfb..acb3e504d 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1,6 +1,10 @@ package ly.count.android.sdk; +import android.app.Activity; import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.Stage; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; @@ -9,6 +13,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -31,8 +36,63 @@ public class ModuleConfigurationTests { private CountlyStore countlyStore; private Countly countly; + /** + * Finishes all running TransparentActivity instances and waits for them to be destroyed. + * This prevents crashes when halt() is called while activities are still running. + */ + private void finishAllTransparentActivities() { + // First, finish all activities + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)) { + if (activity instanceof TransparentActivity) { + activity.finish(); + } + } + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.STARTED)) { + if (activity instanceof TransparentActivity) { + activity.finish(); + } + } + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.CREATED)) { + if (activity instanceof TransparentActivity) { + activity.finish(); + } + } + }); + + // Wait until all TransparentActivity instances are destroyed + long startTime = System.currentTimeMillis(); + long timeout = 5000; // 5 second timeout + + while (System.currentTimeMillis() - startTime < timeout) { + final boolean[] hasRunningActivity = { false }; + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + for (Stage stage : new Stage[] { Stage.RESUMED, Stage.STARTED, Stage.CREATED, Stage.STOPPED, Stage.PAUSED }) { + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(stage)) { + if (activity instanceof TransparentActivity) { + hasRunningActivity[0] = true; + return; + } + } + } + }); + + if (!hasRunningActivity[0]) { + return; // All activities destroyed + } + + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + } + } + } + @Before public void setUp() { + // Finish any stale TransparentActivity instances from previous tests + // before calling halt() to prevent NPE crashes + finishAllTransparentActivities(); countlyStore = TestUtils.getCountlyStore(); countlyStore.clear(); Countly.sharedInstance().halt(); @@ -40,6 +100,7 @@ public void setUp() { @After public void tearDown() { + finishAllTransparentActivities(); TestUtils.getCountlyStore().clear(); Countly.sharedInstance().halt(); } @@ -1421,6 +1482,38 @@ public void eventFilter_internalEventsNotAffected() throws JSONException { // ================ User Property Filter Tests ================ + /** + * Tests that an empty user property filter allows all properties. + * When no filter rules are defined, all properties should pass through. + */ + @Test + public void userPropertyFilter_emptyFilter_allowsAllProperties() throws JSONException { + Set emptySet = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(emptySet, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // All properties should be allowed with empty filter + Map properties = new HashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("any_prop", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop1")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop2")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("any_prop")); + + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest(TestUtils.map(), + TestUtils.map("prop1", "value1", "prop2", "value2", "any_prop", "value3")); + } + /** * Tests that user property blacklist properly blocks filtered properties. * Properties in the blacklist should not be recorded. @@ -1903,6 +1996,250 @@ public void journeyTriggerEvents_contentZoneRefreshFlow() throws Exception { } } + /** + * Tests that journey trigger does NOT send content request when event request fails. + * The content refresh callback is only called on success, so failed event requests + * should not trigger content zone refresh. + */ + @Test + public void journeyTriggerEvents_noContentRequestOnEventFailure() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + final AtomicInteger eventRequestCount = new AtomicInteger(0); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(new Dispatcher() { + @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { + // Track content requests + if (recordedRequest.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + return new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"result\": \"Success\"}"); + } + // Server config request - return JTE config without auto content zone + if (recordedRequest.getPath().contains("&method=sc")) { + try { + return new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .build()); + } catch (JSONException ignored) { + } + } + // Event request - return failure (500) + if (recordedRequest.getPath().contains("events=")) { + eventRequestCount.incrementAndGet(); + return new MockResponse().setResponseCode(500) + .setBody("Internal Server Error"); + } + return new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"result\": \"Success\"}"); + } + }); + + server.start(); + String serverUrl = server.url("/").toString(); + serverUrl = serverUrl.substring(0, serverUrl.length() - 1); + + countlyConfig.metricProviderOverride = new MockedMetricProvider(); + countlyConfig.setServerURL(serverUrl); + Countly.sharedInstance().init(countlyConfig); + Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; + Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; + + Thread.sleep(1000); + int initialContentCount = contentRequestCount.get(); + + // Record JTE event - this should try to send but fail + Countly.sharedInstance().events().recordEvent("journey_event"); + Thread.sleep(2000); + + // Event request should have been attempted + Assert.assertTrue(eventRequestCount.get() >= 1); + + // Content request should NOT have been made since event failed + // The callback only fires on success + Assert.assertEquals(initialContentCount, contentRequestCount.get()); + + server.shutdown(); + } + } + + /** + * Tests that journey trigger skips content refresh when already in content zone. + * When isCurrentlyInContentZone is true, refreshContentZone should skip. + */ + @Test + public void journeyTriggerEvents_skipsRefreshWhenInContentZone() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + triggerEvents.add("journey_event_2"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + // Provide custom lifecycle observer that always returns false to prevent auto-sessions + // when TransparentActivity is launched + countlyConfig.lifecycleObserver = () -> false; + + try (MockWebServer server = new MockWebServer()) { + AtomicInteger contentRequestCount = new AtomicInteger(0); + AtomicBoolean returnContent = new AtomicBoolean(false); + server.setDispatcher(new Dispatcher() { + @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { + MockResponse response = new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json"); + if (recordedRequest.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + // Return valid content JSON when returnContent is true + // This will set isCurrentlyInContentZone=true in ModuleContent + if (returnContent.get()) { + String contentJson = "{\"html\":\"https://countly.com\",\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100},\"l\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100}}}"; + return response.setBody(contentJson); + } + } else if (recordedRequest.getPath().contains("&method=sc")) { + try { + return response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) + .sessionTracking(false) // Disable to prevent auto sessions + .build()); + } catch (JSONException ignored) { + } + } + return response.setBody("{\"result\": \"Success\"}"); + } + }); + // Start server on localhost - both server and SDK run inside emulator + server.start(); + String serverUrl = server.url("/").toString(); + // Remove trailing slash + serverUrl = serverUrl.substring(0, serverUrl.length() - 1); + + countlyConfig.metricProviderOverride = new MockedMetricProvider(); + countlyConfig.setServerURL(serverUrl); + Countly.sharedInstance().init(countlyConfig); + Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; + Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; + + // Wait for server config to be fetched and applied + Thread.sleep(2000); + // verify that enter is called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - this adds request with callback to RQ + returnContent.set(true); + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + Thread.sleep(2000); // Allow time for content to be fetched and TransparentActivity to launch + Assert.assertEquals(2, contentRequestCount.get()); + + // Note: RQ may contain session requests from activity lifecycle when TransparentActivity launches + // This is expected behavior - the core test is verifying content refresh is skipped + + // Record another JTE - should NOT trigger content refresh since isCurrentlyInContentZone=true + Countly.sharedInstance().events().recordEvent("journey_event_2"); + Thread.sleep(1000); + // Content request count should NOT increase since we're already in content zone, so refresh should skip + Assert.assertEquals(2, contentRequestCount.get()); + server.shutdown(); + + // Finish all TransparentActivity instances before calling exitContentZone + // This ensures the activity lifecycle completes while SDK is still initialized + finishAllTransparentActivities(); + Thread.sleep(2000); // Wait for activity lifecycle to complete + + Countly.sharedInstance().contents().exitContentZone(); + } + } + + /** + * Tests that multiple journey trigger events each trigger their own flush. + * Each JTE event should be immediately flushed with its own callback_id. + */ + @Test + public void journeyTriggerEvents_multipleEventsEachFlush() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("jte_1"); + triggerEvents.add("jte_2"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .eventQueueSize(100) // High threshold to verify force flush + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + + // Record first JTE event + Countly.sharedInstance().events().recordEvent("jte_1"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + String firstCallbackId = TestUtils.getCurrentRQ()[0].get("callback_id"); + Assert.assertNotNull(firstCallbackId); + + // Record second JTE event + Countly.sharedInstance().events().recordEvent("jte_2"); + Assert.assertEquals(2, TestUtils.getCurrentRQ().length); + String secondCallbackId = TestUtils.getCurrentRQ()[1].get("callback_id"); + Assert.assertNotNull(secondCallbackId); + + // Each event should have its own callback_id + Assert.assertNotEquals(firstCallbackId, secondCallbackId); + } + + /** + * Tests that non-journey events stay queued while JTE events are flushed immediately. + * This verifies the selective flush behavior. + */ + @Test + public void journeyTriggerEvents_nonJteStaysQueued() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .eventQueueSize(100) // High threshold + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Record a non-JTE event - should stay in queue + Countly.sharedInstance().events().recordEvent("regular_event"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Record another non-JTE event + Countly.sharedInstance().events().recordEvent("another_regular"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + // Record a JTE event - should flush ALL events + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Verify the flushed request contains all 3 events + Map[] rq = TestUtils.getCurrentRQ(); + String events = rq[0].get("events"); + Assert.assertNotNull(events); + Assert.assertTrue(events.contains("regular_event")); + Assert.assertTrue(events.contains("another_regular")); + Assert.assertTrue(events.contains("journey_event")); + } + // ================ User Property Cache Limit Tests ================ /** @@ -2092,6 +2429,32 @@ public void userPropertyCacheLimit_limitOfOne() throws JSONException { Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.custom.size()); } + /** + * Tests that cache limit of 0 is treated as invalid and the default limit (100) is used. + * The SDK validation requires values > 0, so 0 is rejected. + */ + @Test + public void userPropertyCacheLimit_limitOfZero_usesDefault() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(0).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new HashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Limit of 0 is invalid (SDK requires > 0), so default (100) is used + // Both properties should be stored + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + + // Verify default limit is applied + Assert.assertEquals(100, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + /** * Tests that cache limit is enforced on modification operations (incrementBy). * When using incrementBy on multiple properties exceeding the limit, oldest should be removed. From fb158c41c558bc09fabfa2f6adb930d1a315e83d Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 09:40:20 +0300 Subject: [PATCH 34/42] feat: add jte retry --- .../android/sdk/ModuleConfigurationTests.java | 392 ++++++++++-------- .../ly/count/android/sdk/ModuleContent.java | 60 ++- 2 files changed, 262 insertions(+), 190 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index acb3e504d..ce5d2aa1d 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1943,57 +1943,42 @@ public void journeyTriggerEvents_contentZoneRefreshFlow() throws Exception { final AtomicInteger contentRequestCount = new AtomicInteger(0); - CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); - try (MockWebServer server = new MockWebServer()) { - server.setDispatcher(new Dispatcher() { - @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { - MockResponse response = new MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json"); - if (recordedRequest.getPath().contains("&method=queue")) { - contentRequestCount.incrementAndGet(); - } else if (recordedRequest.getPath().contains("&method=sc")) { - try { - return response.setBody(new ServerConfigBuilder().defaults() - .journeyTriggerEvents(triggerEvents) - .contentZone(true) - .build()); - } catch (JSONException ignored) { - } - } - return response.setBody("{\"result\": \"Success\"}"); - } - }); - - // Start server on localhost - both server and SDK run inside emulator - server.start(); - String serverUrl = server.url("/").toString(); - // Remove trailing slash - serverUrl = serverUrl.substring(0, serverUrl.length() - 1); - - countlyConfig.metricProviderOverride = new MockedMetricProvider(); - countlyConfig.setServerURL(serverUrl); - Countly.sharedInstance().init(countlyConfig); - Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; - Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; - - // Wait for server config to be fetched and applied - Thread.sleep(2000); - // verify that enter is called - Assert.assertEquals(1, contentRequestCount.get()); - - // Record JTE event - this adds request with callback to RQ - Countly.sharedInstance().events().recordEvent("purchase_complete"); - Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + } - // Get the callback_id from the request - Map[] rq = TestUtils.getCurrentRQ(); - String callbackId = rq[0].get("callback_id"); - Assert.assertNotNull(callbackId); - Thread.sleep(1000); - Assert.assertEquals(2, contentRequestCount.get()); - Assert.assertEquals(0, TestUtils.getCurrentRQ().length); - server.shutdown(); - } + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + // Wait for server config to be fetched and applied + Thread.sleep(2000); + // verify that enter is called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - this adds request with callback to RQ + Countly.sharedInstance().events().recordEvent("purchase_complete"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + // Get the callback_id from the request + Map[] rq = TestUtils.getCurrentRQ(); + String callbackId = rq[0].get("callback_id"); + Assert.assertNotNull(callbackId); + Thread.sleep(1000); + Assert.assertEquals(2, contentRequestCount.get()); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + } catch (Exception ignored) { + } + }); } /** @@ -2009,66 +1994,45 @@ public void journeyTriggerEvents_noContentRequestOnEventFailure() throws Excepti final AtomicInteger contentRequestCount = new AtomicInteger(0); final AtomicInteger eventRequestCount = new AtomicInteger(0); - CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); - try (MockWebServer server = new MockWebServer()) { - server.setDispatcher(new Dispatcher() { - @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { - // Track content requests - if (recordedRequest.getPath().contains("&method=queue")) { - contentRequestCount.incrementAndGet(); - return new MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("{\"result\": \"Success\"}"); - } - // Server config request - return JTE config without auto content zone - if (recordedRequest.getPath().contains("&method=sc")) { - try { - return new MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(new ServerConfigBuilder().defaults() - .journeyTriggerEvents(triggerEvents) - .build()); - } catch (JSONException ignored) { - } - } - // Event request - return failure (500) - if (recordedRequest.getPath().contains("events=")) { - eventRequestCount.incrementAndGet(); - return new MockResponse().setResponseCode(500) - .setBody("Internal Server Error"); - } - return new MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("{\"result\": \"Success\"}"); - } - }); - - server.start(); - String serverUrl = server.url("/").toString(); - serverUrl = serverUrl.substring(0, serverUrl.length() - 1); - - countlyConfig.metricProviderOverride = new MockedMetricProvider(); - countlyConfig.setServerURL(serverUrl); - Countly.sharedInstance().init(countlyConfig); - Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; - Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + } - Thread.sleep(1000); - int initialContentCount = contentRequestCount.get(); + // Track event requests + if (request.getPath().contains("events=")) { + eventRequestCount.incrementAndGet(); + response.setResponseCode(500) + .setBody("Internal Server Error"); + } - // Record JTE event - this should try to send but fail - Countly.sharedInstance().events().recordEvent("journey_event"); - Thread.sleep(2000); + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + Thread.sleep(1000); + int initialContentCount = contentRequestCount.get(); - // Event request should have been attempted - Assert.assertTrue(eventRequestCount.get() >= 1); + // Record JTE event - this should try to send but fail + Countly.sharedInstance().events().recordEvent("journey_event"); + Thread.sleep(2000); - // Content request should NOT have been made since event failed - // The callback only fires on success - Assert.assertEquals(initialContentCount, contentRequestCount.get()); + // Event request should have been attempted + Assert.assertTrue(eventRequestCount.get() >= 1); - server.shutdown(); - } + // Content request should NOT have been made since event failed + // The callback only fires on success + Assert.assertEquals(initialContentCount, contentRequestCount.get()); + } catch (Exception ignored) { + } + }); } /** @@ -2081,81 +2045,64 @@ public void journeyTriggerEvents_skipsRefreshWhenInContentZone() throws Exceptio triggerEvents.add("journey_event"); triggerEvents.add("journey_event_2"); - CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); - // Provide custom lifecycle observer that always returns false to prevent auto-sessions - // when TransparentActivity is launched - countlyConfig.lifecycleObserver = () -> false; - - try (MockWebServer server = new MockWebServer()) { - AtomicInteger contentRequestCount = new AtomicInteger(0); - AtomicBoolean returnContent = new AtomicBoolean(false); - server.setDispatcher(new Dispatcher() { - @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { - MockResponse response = new MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json"); - if (recordedRequest.getPath().contains("&method=queue")) { - contentRequestCount.incrementAndGet(); - // Return valid content JSON when returnContent is true - // This will set isCurrentlyInContentZone=true in ModuleContent - if (returnContent.get()) { - String contentJson = "{\"html\":\"https://countly.com\",\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100},\"l\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100}}}"; - return response.setBody(contentJson); - } - } else if (recordedRequest.getPath().contains("&method=sc")) { - try { - return response.setBody(new ServerConfigBuilder().defaults() - .journeyTriggerEvents(triggerEvents) - .contentZone(true) - .sessionTracking(false) // Disable to prevent auto sessions - .build()); - } catch (JSONException ignored) { - } - } - return response.setBody("{\"result\": \"Success\"}"); + AtomicInteger contentRequestCount = new AtomicInteger(0); + AtomicBoolean returnContent = new AtomicBoolean(false); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + // Return valid content JSON when returnContent is true + // This will set isCurrentlyInContentZone=true in ModuleContent + if (returnContent.get()) { + String contentJson = "{\"html\":\"https://countly.com\",\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100},\"l\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100}}}"; + response.setBody(contentJson); } - }); - // Start server on localhost - both server and SDK run inside emulator - server.start(); - String serverUrl = server.url("/").toString(); - // Remove trailing slash - serverUrl = serverUrl.substring(0, serverUrl.length() - 1); - - countlyConfig.metricProviderOverride = new MockedMetricProvider(); - countlyConfig.setServerURL(serverUrl); - Countly.sharedInstance().init(countlyConfig); - Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; - Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; - - // Wait for server config to be fetched and applied - Thread.sleep(2000); - // verify that enter is called - Assert.assertEquals(1, contentRequestCount.get()); - - // Record JTE event - this adds request with callback to RQ - returnContent.set(true); - Countly.sharedInstance().events().recordEvent("journey_event"); - Assert.assertEquals(1, TestUtils.getCurrentRQ().length); - - Thread.sleep(2000); // Allow time for content to be fetched and TransparentActivity to launch - Assert.assertEquals(2, contentRequestCount.get()); - - // Note: RQ may contain session requests from activity lifecycle when TransparentActivity launches - // This is expected behavior - the core test is verifying content refresh is skipped - - // Record another JTE - should NOT trigger content refresh since isCurrentlyInContentZone=true - Countly.sharedInstance().events().recordEvent("journey_event_2"); - Thread.sleep(1000); - // Content request count should NOT increase since we're already in content zone, so refresh should skip - Assert.assertEquals(2, contentRequestCount.get()); - server.shutdown(); - - // Finish all TransparentActivity instances before calling exitContentZone - // This ensures the activity lifecycle completes while SDK is still initialized - finishAllTransparentActivities(); - Thread.sleep(2000); // Wait for activity lifecycle to complete + } - Countly.sharedInstance().contents().exitContentZone(); - } + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) + .sessionTracking(false) // Disable to prevent auto sessions + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + // Wait for server config to be fetched and applied + Thread.sleep(2000); + // verify that enter is called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - this adds request with callback to RQ + returnContent.set(true); + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + Thread.sleep(2000); // Allow time for content to be fetched and TransparentActivity to launch + Assert.assertEquals(2, contentRequestCount.get()); + + // Note: RQ may contain session requests from activity lifecycle when TransparentActivity launches + // This is expected behavior - the core test is verifying content refresh is skipped + + // Record another JTE - should NOT trigger content refresh since isCurrentlyInContentZone=true + Countly.sharedInstance().events().recordEvent("journey_event_2"); + Thread.sleep(1000); + // Content request count should NOT increase since we're already in content zone, so refresh should skip + Assert.assertEquals(2, contentRequestCount.get()); + + // Finish all TransparentActivity instances before calling exitContentZone + // This ensures the activity lifecycle completes while SDK is still initialized + finishAllTransparentActivities(); + Thread.sleep(2000); // Wait for activity lifecycle to complete + + Countly.sharedInstance().contents().exitContentZone(); + } catch (InterruptedException ignored) { + } + }); } /** @@ -2240,6 +2187,95 @@ public void journeyTriggerEvents_nonJteStaysQueued() throws JSONException { Assert.assertTrue(events.contains("journey_event")); } + /** + * Tests that JTE events still flush immediately but content refresh doesn't happen + * when refreshContentZone is disabled. The callback_id is still registered but + * the refreshContentZoneInternal early returns when refresh is disabled. + */ + @Test + public void journeyTriggerEvents_noRefreshWhenRefreshContentZoneDisabled() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + final AtomicInteger eventRequestCount = new AtomicInteger(0); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + } + // Track event requests + if (request.getPath().contains("events=")) { + eventRequestCount.incrementAndGet(); + } + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) // Content zone enabled but refresh disabled + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + Thread.sleep(1000); + + // Content zone is enabled, so enter should be called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - should still flush immediately with callback_id + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + // Verify callback_id is present (callback is still registered) + Map[] rq = TestUtils.getCurrentRQ(); + Assert.assertTrue(rq[0].containsKey("callback_id")); + + Thread.sleep(4000); + + // Event should have been sent + Assert.assertTrue(eventRequestCount.get() >= 1); + + // Verify that retry happened 3 times + Assert.assertEquals(4, contentRequestCount.get()); + } catch (InterruptedException ignored) { + } + }); + } + + private void testJTEWithMockedWebServer(BiConsumer customRequestFlow, Runnable runnable) throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(new Dispatcher() { + @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { + MockResponse response = new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json").setBody("{\"result\": \"Success\"}"); + // Track content requests + customRequestFlow.accept(recordedRequest, response); + return response; + } + }); + + server.start(); + String serverUrl = server.url("/").toString(); + serverUrl = serverUrl.substring(0, serverUrl.length() - 1); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.metricProviderOverride = new MockedMetricProvider(); + countlyConfig.setServerURL(serverUrl); + Countly.sharedInstance().init(countlyConfig); + Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; + Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; + + Thread.sleep(1000); + + runnable.run(); + + server.shutdown(); + } + } + // ================ User Property Cache Limit Tests ================ /** diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 733f0d874..08072ba82 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -4,6 +4,8 @@ import android.content.Intent; import android.content.res.Configuration; import android.content.res.Resources; +import android.os.Handler; +import android.os.Looper; import android.util.DisplayMetrics; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -151,22 +153,55 @@ private void enterContentZoneInternal(@Nullable String[] categories, final int i contentInitialDelay += CONTENT_START_DELAY_MS; } - countlyTimer.startTimer(zoneTimerInterval, contentInitialDelay, new Runnable() { - @Override public void run() { - L.d("[ModuleContent] enterContentZoneInternal, waitForDelay: [" + waitForDelay + "], shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(validCategories) + "]"); - if (waitForDelay > 0) { - waitForDelay--; - return; + if (countlyTimer != null) { // for tests, in normal conditions this should never be null here + countlyTimer.startTimer(zoneTimerInterval, contentInitialDelay, new Runnable() { + @Override public void run() { + L.d("[ModuleContent] enterContentZoneInternal, waitForDelay: [" + waitForDelay + "], shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(validCategories) + "]"); + if (waitForDelay > 0) { + waitForDelay--; + return; + } + + if (!shouldFetchContents) { + L.w("[ModuleContent] enterContentZoneInternal, shouldFetchContents is false, skipping"); + return; + } + + fetchContentsInternal(validCategories); } + }, L); + } + } + + private void enterContentZoneWithRetriesInternal() { + Handler handler = new Handler(Looper.getMainLooper()); + int maxRetries = 3; + int delayMillis = 1000; - if (!shouldFetchContents) { - L.w("[ModuleContent] enterContentZoneInternal, shouldFetchContents is false, skipping"); + Runnable retryRunnable = new Runnable() { + int attempt = 0; + + @Override + public void run() { + if (isCurrentlyInContentZone) { return; } - fetchContentsInternal(validCategories); + if (countlyTimer != null) { // for tests + countlyTimer.stopTimer(L); + } + enterContentZoneInternal(null, 0); + + attempt++; + if (attempt < maxRetries) { + handler.postDelayed(this, delayMillis); + } else { + L.w("[ModuleContent] enterContentZoneWithRetriesInternal, " + maxRetries + " attempted"); + } } - }, L); + }; + + handler.post(retryRunnable); } void notifyAfterContentIsClosed() { @@ -335,9 +370,10 @@ void refreshContentZoneInternal(boolean callRQFlush) { if (callRQFlush) { _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); + enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS); + } else { + enterContentZoneWithRetriesInternal(); } - - enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS); } public class Content { From 1560ced12e319a1d2074cc78de2fb8cc6c5347ed Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 09:48:39 +0300 Subject: [PATCH 35/42] feat: retry test cases more --- .../android/sdk/ModuleConfigurationTests.java | 124 ++++++++++++++---- 1 file changed, 98 insertions(+), 26 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index ce5d2aa1d..3b1b46c33 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1497,7 +1497,7 @@ public void userPropertyFilter_emptyFilter_allowsAllProperties() throws JSONExce Countly.sharedInstance().init(countlyConfig); // All properties should be allowed with empty filter - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("prop1", "value1"); properties.put("prop2", "value2"); properties.put("any_prop", "value3"); @@ -1531,7 +1531,7 @@ public void userPropertyFilter_blacklist_blocksFilteredProperties() throws JSONE Countly.sharedInstance().init(countlyConfig); // Set properties - blocked ones should be filtered - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("blocked_prop", "value1"); properties.put("another_blocked", "value2"); properties.put("allowed_prop", "value3"); @@ -1563,7 +1563,7 @@ public void userPropertyFilter_whitelist_onlyAllowsSpecifiedProperties() throws ); Countly.sharedInstance().init(countlyConfig); - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("allowed_prop", "value1"); properties.put("not_allowed", "value2"); @@ -1595,7 +1595,7 @@ public void userPropertyFilter_namedPropertiesBypassFilter() throws JSONExceptio ); Countly.sharedInstance().init(countlyConfig); - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("name", "John Doe"); properties.put("email", "john@example.com"); properties.put("custom_blocked", "blocked_value"); @@ -1664,7 +1664,7 @@ public void segmentationFilter_blacklist_removesFilteredKeys() throws JSONExcept ); Countly.sharedInstance().init(countlyConfig); - Map segmentation = new HashMap<>(); + Map segmentation = new ConcurrentHashMap<>(); segmentation.put("blocked_key", "value1"); segmentation.put("another_blocked", "value2"); segmentation.put("allowed_key", "value3"); @@ -1690,7 +1690,7 @@ public void segmentationFilter_whitelist_onlyKeepsSpecifiedKeys() throws JSONExc ); Countly.sharedInstance().init(countlyConfig); - Map segmentation = new HashMap<>(); + Map segmentation = new ConcurrentHashMap<>(); segmentation.put("allowed_key", "value1"); segmentation.put("not_allowed_1", "value2"); segmentation.put("not_allowed_2", "value3"); @@ -1714,7 +1714,7 @@ public void segmentationFilter_emptyFilter_allowsAllKeys() throws JSONException ); Countly.sharedInstance().init(countlyConfig); - Map segmentation = new HashMap<>(); + Map segmentation = new ConcurrentHashMap<>(); segmentation.put("key1", "value1"); segmentation.put("key2", "value2"); @@ -1743,7 +1743,7 @@ public void eventSegmentationFilter_blacklist_affectsSpecificEvents() throws JSO Countly.sharedInstance().init(countlyConfig); // For event1, blocked_for_event1 should be removed - Map segmentation1 = new HashMap<>(); + Map segmentation1 = new ConcurrentHashMap<>(); segmentation1.put("blocked_for_event1", "value1"); segmentation1.put("allowed_key", "value2"); Countly.sharedInstance().events().recordEvent("event1", segmentation1); @@ -1752,7 +1752,7 @@ public void eventSegmentationFilter_blacklist_affectsSpecificEvents() throws JSO validateEventInRQ("event1", TestUtils.map("allowed_key", "value2"), 0, 1, 0, 1); // For event2, blocked_for_event1 should NOT be removed (filter only applies to event1) - Map segmentation2 = new HashMap<>(); + Map segmentation2 = new ConcurrentHashMap<>(); segmentation2.put("blocked_for_event1", "value1"); segmentation2.put("other_key", "value2"); Countly.sharedInstance().events().recordEvent("event2", segmentation2); @@ -1778,7 +1778,7 @@ public void eventSegmentationFilter_whitelist_onlyKeepsSpecifiedKeysForEvent() t Countly.sharedInstance().init(countlyConfig); // For event1, only allowed_for_event1 should remain - Map segmentation = new HashMap<>(); + Map segmentation = new ConcurrentHashMap<>(); segmentation.put("allowed_for_event1", "value1"); segmentation.put("not_allowed", "value2"); Countly.sharedInstance().events().recordEvent("event1", segmentation); @@ -1804,7 +1804,7 @@ public void eventSegmentationFilter_noRulesForEvent_allowsAllSegmentation() thro Countly.sharedInstance().init(countlyConfig); // event2 has no rules, so all segmentation should pass - Map segmentation = new HashMap<>(); + Map segmentation = new ConcurrentHashMap<>(); segmentation.put("any_key", "value1"); segmentation.put("any_other_key", "value2"); Countly.sharedInstance().events().recordEvent("event2", segmentation); @@ -1838,7 +1838,7 @@ public void segmentationFilters_combined_bothFiltersApplied() throws JSONExcepti ); Countly.sharedInstance().init(countlyConfig); - Map segmentation = new HashMap<>(); + Map segmentation = new ConcurrentHashMap<>(); segmentation.put("general_blocked", "value1"); segmentation.put("event_specific_blocked", "value2"); segmentation.put("allowed_key", "value3"); @@ -2188,12 +2188,15 @@ public void journeyTriggerEvents_nonJteStaysQueued() throws JSONException { } /** - * Tests that JTE events still flush immediately but content refresh doesn't happen - * when refreshContentZone is disabled. The callback_id is still registered but - * the refreshContentZoneInternal early returns when refresh is disabled. + * Tests that JTE triggers content zone refresh with retry mechanism on empty responses. + * When the server returns an empty content response, the SDK should retry up to 3 times. + * Verifies: + * 1. JTE event flushes immediately with callback_id + * 2. Content fetch is triggered after successful event delivery + * 3. Empty responses trigger retry mechanism (total 4 requests: 1 initial + 3 retries) */ @Test - public void journeyTriggerEvents_noRefreshWhenRefreshContentZoneDisabled() throws Exception { + public void journeyTriggerEvents_refreshRetriesCorrectlyAfterProvidingEmptyResponse() throws Exception { Set triggerEvents = new HashSet<>(); triggerEvents.add("journey_event"); @@ -2203,6 +2206,7 @@ public void journeyTriggerEvents_noRefreshWhenRefreshContentZoneDisabled() throw testJTEWithMockedWebServer((request, response) -> { if (request.getPath().contains("&method=queue")) { contentRequestCount.incrementAndGet(); + response.setBody("[]"); // Simulate empty content response } // Track event requests if (request.getPath().contains("events=")) { @@ -2245,6 +2249,74 @@ public void journeyTriggerEvents_noRefreshWhenRefreshContentZoneDisabled() throw }); } + /** + * Tests that content zone refresh retries stop when valid content is received. + * When empty responses are followed by a valid content response, retries should cease. + * Verifies: + * 1. JTE event flushes immediately with callback_id + * 2. Empty responses trigger retry mechanism + * 3. Valid content response stops further retries (content shown, no more requests) + */ + @Test + public void journeyTriggerEvents_refreshRetryStopAfterValidContentResponse() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + final AtomicInteger eventRequestCount = new AtomicInteger(0); + final AtomicBoolean returnContent = new AtomicBoolean(false); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + response.setBody("[]"); // Simulate empty content response + if (returnContent.get()) { + String contentJson = "{\"html\":\"https://countly.com\",\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100},\"l\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100}}}"; + response.setBody(contentJson); + } + } + // Track event requests + if (request.getPath().contains("events=")) { + eventRequestCount.incrementAndGet(); + } + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) // Content zone enabled but refresh disabled + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + Thread.sleep(1000); + + // Content zone is enabled, so enter should be called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - should still flush immediately with callback_id + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + // Verify callback_id is present (callback is still registered) + Map[] rq = TestUtils.getCurrentRQ(); + Assert.assertTrue(rq[0].containsKey("callback_id")); + + Thread.sleep(2000); + returnContent.set(true); + + // Event should have been sent + Assert.assertTrue(eventRequestCount.get() >= 1); + + // Verify that retry happened 2 times and it got the valid content on 3rd try + Assert.assertEquals(3, contentRequestCount.get()); + } catch (InterruptedException ignored) { + } + }); + } + private void testJTEWithMockedWebServer(BiConsumer customRequestFlow, Runnable runnable) throws Exception { try (MockWebServer server = new MockWebServer()) { server.setDispatcher(new Dispatcher() { @@ -2338,7 +2410,7 @@ public void userPropertyCacheLimit_enforcesLimitOnSetProperties() throws JSONExc Assert.assertEquals(3, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); // Set more properties than the limit - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("prop1", "value1"); properties.put("prop2", "value2"); properties.put("prop3", "value3"); @@ -2365,7 +2437,7 @@ public void userPropertyCacheLimit_allowsPropertiesUnderLimit() throws JSONExcep Countly.sharedInstance().init(countlyConfig); // Set fewer properties than the limit - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("prop1", "value1"); properties.put("prop2", "value2"); properties.put("prop3", "value3"); @@ -2394,7 +2466,7 @@ public void userPropertyCacheLimit_namedPropertiesNotAffected() throws JSONExcep Countly.sharedInstance().init(countlyConfig); // Set named properties plus custom properties exceeding limit - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("name", "John Doe"); properties.put("email", "john@example.com"); properties.put("username", "johndoe"); @@ -2426,7 +2498,7 @@ public void userPropertyCacheLimit_enforcesAcrossMultipleCalls() throws JSONExce Countly.sharedInstance().init(countlyConfig); // First call - add 2 properties - Map props1 = new HashMap<>(); + Map props1 = new ConcurrentHashMap<>(); props1.put("prop1", "value1"); props1.put("prop2", "value2"); Countly.sharedInstance().userProfile().setProperties(props1); @@ -2434,7 +2506,7 @@ public void userPropertyCacheLimit_enforcesAcrossMultipleCalls() throws JSONExce Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); // Second call - add 2 more (total 4, but limit is 3) - Map props2 = new HashMap<>(); + Map props2 = new ConcurrentHashMap<>(); props2.put("prop3", "value3"); props2.put("prop4", "value4"); Countly.sharedInstance().userProfile().setProperties(props2); @@ -2454,7 +2526,7 @@ public void userPropertyCacheLimit_limitOfOne() throws JSONException { ); Countly.sharedInstance().init(countlyConfig); - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("prop1", "value1"); properties.put("prop2", "value2"); properties.put("prop3", "value3"); @@ -2477,7 +2549,7 @@ public void userPropertyCacheLimit_limitOfZero_usesDefault() throws JSONExceptio ); Countly.sharedInstance().init(countlyConfig); - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("prop1", "value1"); properties.put("prop2", "value2"); @@ -2617,7 +2689,7 @@ public void userPropertyCacheLimit_separateLimitsForCustomAndMods() throws JSONE Countly.sharedInstance().init(countlyConfig); // Add properties via setProperties - Map properties = new HashMap<>(); + Map properties = new ConcurrentHashMap<>(); properties.put("prop1", "value1"); properties.put("prop2", "value2"); properties.put("prop3", "value3"); @@ -2862,7 +2934,7 @@ public void edgeCase_multipleEventsWithDifferentFilters() throws JSONException { Countly.sharedInstance().init(countlyConfig); // event1: key_a should be blocked, key_b allowed - Map seg1 = new HashMap<>(); + Map seg1 = new ConcurrentHashMap<>(); seg1.put("key_a", "value"); seg1.put("key_b", "value"); Countly.sharedInstance().events().recordEvent("event1", seg1); @@ -2871,7 +2943,7 @@ public void edgeCase_multipleEventsWithDifferentFilters() throws JSONException { validateEventInRQ("event1", TestUtils.map("key_b", "value"), 0, 1, 0, 1); // event2: key_b should be blocked, key_a allowed - Map seg2 = new HashMap<>(); + Map seg2 = new ConcurrentHashMap<>(); seg2.put("key_a", "value"); seg2.put("key_b", "value"); Countly.sharedInstance().events().recordEvent("event2", seg2); From ee1a221247965217aa613353861e1dc814ec3ad2 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 10:42:14 +0300 Subject: [PATCH 36/42] feat: add safe flag for retry --- .../ly/count/android/sdk/ModuleContent.java | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 08072ba82..4ba166eca 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -23,6 +23,7 @@ public class ModuleContent extends ModuleBase { CountlyTimer countlyTimer; private boolean shouldFetchContents = false; private boolean isCurrentlyInContentZone = false; + private boolean isCurrentlyRetrying = false; private int zoneTimerInterval; private final ContentCallback globalContentCallback; private int waitForDelay = 0; @@ -50,14 +51,14 @@ void onSdkConfigurationChanged(@NonNull CountlyConfig config) { exitContentZoneInternal(); } waitForDelay = 0; - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } } @Override void initFinished(@NotNull CountlyConfig config) { if (configProvider.getContentZoneEnabled()) { - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } } @@ -68,7 +69,7 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { } } - void fetchContentsInternal(@NonNull String[] categories) { + void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure) { L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "]"); DisplayMetrics displayMetrics = deviceInfo.mp.getDisplayMetrics(_cly.context_); @@ -109,16 +110,23 @@ void fetchContentsInternal(@NonNull String[] categories) { shouldFetchContents = false; // disable fetching contents until the next time, this will disable the timer fetching isCurrentlyInContentZone = true; + isCurrentlyRetrying = false; } else { L.w("[ModuleContent] fetchContentsInternal, response is not valid, skipping"); + if (callbackOnFailure != null) { + callbackOnFailure.run(); + } } } catch (Exception ex) { L.e("[ModuleContent] fetchContentsInternal, Encountered internal issue while trying to fetch contents, [" + ex + "]"); + if (callbackOnFailure != null) { + callbackOnFailure.run(); + } } }, L); } - private void enterContentZoneInternal(@Nullable String[] categories, final int initialDelayMS) { + private void enterContentZoneInternal(@Nullable String[] categories, final int initialDelayMS, @Nullable Runnable callbackOnFailure) { if (!consentProvider.getConsent(Countly.CountlyFeatureNames.content)) { L.w("[ModuleContent] enterContentZoneInternal, Consent is not granted, skipping"); return; @@ -167,13 +175,18 @@ private void enterContentZoneInternal(@Nullable String[] categories, final int i return; } - fetchContentsInternal(validCategories); + fetchContentsInternal(validCategories, callbackOnFailure); } }, L); } } private void enterContentZoneWithRetriesInternal() { + if (isCurrentlyRetrying) { + L.w("[ModuleContent] enterContentZoneWithRetriesInternal, already retrying, skipping"); + return; + } + isCurrentlyRetrying = true; Handler handler = new Handler(Looper.getMainLooper()); int maxRetries = 3; int delayMillis = 1000; @@ -184,20 +197,31 @@ private void enterContentZoneWithRetriesInternal() { @Override public void run() { if (isCurrentlyInContentZone) { + isCurrentlyRetrying = false; // Reset flag on success return; } if (countlyTimer != null) { // for tests countlyTimer.stopTimer(L); } - enterContentZoneInternal(null, 0); - attempt++; - if (attempt < maxRetries) { - handler.postDelayed(this, delayMillis); - } else { - L.w("[ModuleContent] enterContentZoneWithRetriesInternal, " + maxRetries + " attempted"); - } + final Runnable self = this; // Capture reference to outer Runnable + + enterContentZoneInternal(null, 0, new Runnable() { + @Override public void run() { + if (isCurrentlyInContentZone) { + isCurrentlyRetrying = false; // Reset flag on success + return; + } + attempt++; + if (attempt < maxRetries) { + handler.postDelayed(self, delayMillis); + } else { + L.w("[ModuleContent] enterContentZoneWithRetriesInternal, " + maxRetries + " attempted"); + isCurrentlyRetrying = false; + } + } + }); } }; @@ -370,7 +394,7 @@ void refreshContentZoneInternal(boolean callRQFlush) { if (callRQFlush) { _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); - enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS); + enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS, null); } else { enterContentZoneWithRetriesInternal(); } @@ -389,7 +413,7 @@ public void enterContentZone() { return; } - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } /** From 11b8f87cdd218b96fbbe45701644552cbedf6a2a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 11:16:29 +0300 Subject: [PATCH 37/42] feat: remove unnessary filters --- .../android/sdk/ModuleConfiguration.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index cacb5d5d8..cf3ee3d93 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -62,6 +62,7 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRSegmentationWhitelist = "sw"; final static String keyREventSegmentationWhitelist = "esw"; // json final static String keyRJourneyTriggerEvents = "jte"; + final static String keyRListingFilterPreset = "filter_preset"; // FLAGS boolean currentVTracking = true; @@ -400,6 +401,10 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { isValid = value instanceof Boolean; break; + case keyRListingFilterPreset: + isValid = value instanceof String && (value.equals("Whitelisting") || value.equals("Blacklisting")); + break; + // --- Positive Integer keys (> 0) --- case keyRServerConfigUpdateInterval: case keyRBOMAcceptedTimeout: @@ -490,6 +495,8 @@ void saveAndStoreDownloadedConfig(@NonNull JSONObject config) { L.w("[ModuleConfiguration] saveAndStoreDownloadedConfig, Failed to merge version/timestamp.", e); } + removeListingFilterKeysFromConfig(newInner); + Iterator keys = newInner.keys(); while (keys.hasNext()) { String key = keys.next(); @@ -507,6 +514,22 @@ void saveAndStoreDownloadedConfig(@NonNull JSONObject config) { storageProvider.setServerConfig(latestRetrievedConfigurationFull.toString()); } + private void removeListingFilterKeysFromConfig(JSONObject newConfig) { + String filterPreset = newConfig.optString(keyRListingFilterPreset, "Blacklisting"); + + if (filterPreset.equals("Whitelisting")) { + latestRetrievedConfiguration.remove(keyREventBlacklist); + latestRetrievedConfiguration.remove(keyRUserPropertyBlacklist); + latestRetrievedConfiguration.remove(keyRSegmentationBlacklist); + latestRetrievedConfiguration.remove(keyREventSegmentationBlacklist); + } else { + latestRetrievedConfiguration.remove(keyREventWhitelist); + latestRetrievedConfiguration.remove(keyRUserPropertyWhitelist); + latestRetrievedConfiguration.remove(keyRSegmentationWhitelist); + latestRetrievedConfiguration.remove(keyREventSegmentationWhitelist); + } + } + /** * Perform network request for retrieving latest config * If valid config is downloaded, save it, and update the values From 9f4ae8b30f5dca763744bdb5560d55c918c2d258 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 4 Feb 2026 12:05:36 +0300 Subject: [PATCH 38/42] fix: failing tests for clearance --- .../android/sdk/ModuleConfigurationTests.java | 16 ++++++++++++---- .../ly/count/android/sdk/scSE_SessionsTests.java | 5 ++--- .../count/android/sdk/scUP_UserProfileTests.java | 5 +++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 3b1b46c33..4e2a62ad2 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -712,7 +712,7 @@ public void invalidConfigResponses_AreRejected() { */ @Test public void configurationParameterCount() { - int configParameterCount = 41; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, and journey trigger events + int configParameterCount = 42; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, and journey trigger events and filter_preset int count = 0; for (Field field : ModuleConfiguration.class.getDeclaredFields()) { if (field.getName().startsWith("keyR")) { @@ -805,7 +805,9 @@ public void scenario_trackingDisabled() throws JSONException, InterruptedExcepti ServerConfigBuilder serverConfigBuilder = new ServerConfigBuilder() .defaults(); - Countly countly = new Countly().init(TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build()))); + CountlyConfig config = TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build())); + config.setTrackOrientationChanges(false); // disable orientation to avoid extra event + Countly countly = new Countly().init(config); countly.onStartInternal(null); // Verify initial state Assert.assertTrue(countly.moduleConfiguration.getTrackingEnabled()); @@ -813,11 +815,17 @@ public void scenario_trackingDisabled() throws JSONException, InterruptedExcepti Assert.assertEquals(1, TestUtils.getCurrentRQ().length); // begin session request + // Properly cleanup before reinitializing with different config + countly.halt(); + countlyStore.clear(); + serverConfigBuilder.tracking(false); - countly = new Countly().init(TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build()))); + CountlyConfig config2 = TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build())); + config2.setTrackOrientationChanges(false); + countly = new Countly().init(config2); countly.onStartInternal(null); Thread.sleep(1000); - Assert.assertEquals(1, TestUtils.getCurrentRQ().length); // assert that no new request is added + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); // assert that no request is added when tracking disabled } /** diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java index 604b9f1ef..ef9160976 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java @@ -66,7 +66,7 @@ public void tearDown() { */ @Test public void SE_200_CR_CG_M() throws InterruptedException { - CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(true).setConsentEnabled(new String[] { "sessions" }); + CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(true).setConsentEnabled(new String[] { "sessions" }).setTrackOrientationChanges(false); Countly countly = new Countly().init(config); flowManualSessions(countly); @@ -92,12 +92,11 @@ public void SE_200_CR_CG_M() throws InterruptedException { */ @Test public void SE_201_CNR_M() throws InterruptedException { - CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(false); + CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(false).setTrackOrientationChanges(false); Countly countly = new Countly().init(config); flowManualSessions(countly); - TestUtils.removeRequestContains("orientation"); //TODO fix for now, tweak this Assert.assertEquals(4, TestUtils.getCurrentRQ().length); validateSessionBeginRequest(0, TestUtils.commonDeviceId); validateSessionUpdateRequest(1, 2, TestUtils.commonDeviceId); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java index 4072ab2f9..d60d02bbb 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java @@ -71,6 +71,7 @@ public void eventSaveScenario_manualSessions() throws JSONException { public void eventSaveScenario_onTimer() throws InterruptedException, JSONException { CountlyConfig config = TestUtils.createBaseConfig(); config.sessionUpdateTimerDelay = 2; // trigger update call for property save + config.setTrackOrientationChanges(false); // disable orientation tracking to avoid extra event Countly countly = new Countly().init(config); TestUtils.assertRQSize(0); // no begin session because of no consent @@ -189,7 +190,7 @@ public void eventSaveScenario_sessionCallsTriggersSave_A() throws JSONException, countly.userProfile().setProperty("after_begin_session", true); TestUtils.assertRQSize(2); - Thread.sleep(3000); + Thread.sleep(4000); // Increased to ensure session update timer fires TestUtils.assertRQSize(4); @@ -522,7 +523,7 @@ public void UP_209_CR_CNG_M() { */ @Test public void UP_210_CNR_M_duration() throws InterruptedException, JSONException { - Countly countly = new Countly().init(TestUtils.createBaseConfig().enableManualSessionControl().setUpdateSessionTimerDelay(5)); + Countly countly = new Countly().init(TestUtils.createBaseConfig().enableManualSessionControl().setUpdateSessionTimerDelay(5).setTrackOrientationChanges(false)); sendUserData(countly); Thread.sleep(6000); From 27214a6176e53c2aef880901cf5f5fac5f70171c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 5 Feb 2026 16:47:08 +0300 Subject: [PATCH 39/42] fix: some test related erros --- .../java/ly/count/android/sdk/ModuleViewsTests.java | 2 +- .../ly/count/android/sdk/scUP_UserProfileTests.java | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java index 5eda400cc..e35faea45 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java @@ -1937,7 +1937,7 @@ public void startView_consentRemoval() throws JSONException { try { validateView("test", 0.0, 3, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); validateView("test2", 0.0, 4, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); - } catch (Exception ignored) { + } catch (AssertionError ignored) { validateView("test", 0.0, 4, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); validateView("test2", 0.0, 3, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java index d60d02bbb..e3e87068f 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java @@ -174,7 +174,7 @@ public void eventSaveScenario_sessionCallsTriggersSave_A() throws JSONException, CountlyConfig config = TestUtils.createBaseConfig(TestUtils.getContext()).setTrackOrientationChanges(false); TestLifecycleObserver testLifecycleObserver = new TestLifecycleObserver(); config.lifecycleObserver = testLifecycleObserver; - config.setUpdateSessionTimerDelay(3); + config.setUpdateSessionTimerDelay(5); // Use 5 second timer for more reliable timing Countly countly = new Countly().init(config); TestUtils.assertRQSize(0); @@ -190,24 +190,24 @@ public void eventSaveScenario_sessionCallsTriggersSave_A() throws JSONException, countly.userProfile().setProperty("after_begin_session", true); TestUtils.assertRQSize(2); - Thread.sleep(4000); // Increased to ensure session update timer fires + Thread.sleep(6000); // Wait for session update timer (5s) to fire TestUtils.assertRQSize(4); ModuleUserProfileTests.validateUserProfileRequest(2, 4, TestUtils.map(), TestUtils.map("after_begin_session", true)); - ModuleSessionsTests.validateSessionUpdateRequest(3, 3, TestUtils.commonDeviceId); + ModuleSessionsTests.validateSessionUpdateRequest(3, 5, TestUtils.commonDeviceId); // duration 5 (timer delay) countly.userProfile().setProperty("after_update_session", true); TestUtils.assertRQSize(4); - Thread.sleep(2000); + Thread.sleep(2000); // 6+2=8s total, less than 10s (2 timer intervals) testLifecycleObserver.goToBackground(); countly.onStop(); TestUtils.assertRQSize(6); ModuleUserProfileTests.validateUserProfileRequest(4, 6, TestUtils.map(), TestUtils.map("after_update_session", true)); - ModuleSessionsTests.validateSessionEndRequest(5, 2, TestUtils.commonDeviceId); + ModuleSessionsTests.validateSessionEndRequest(5, 3, TestUtils.commonDeviceId); // duration ~3s since last update } /** From 7c62f63ebf33a32c2134e4b4098842af4ea2262c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 5 Feb 2026 17:36:21 +0300 Subject: [PATCH 40/42] feat: add changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1aaaf62..d19652f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## XX.XX.XX +* Extended server configuration capabilities with server-controlled listing filters: + * Event filters (blacklist/whitelist) to control which events are recorded. + * User property filters (blacklist/whitelist) to control which user properties are recorded. + * Segmentation filters (blacklist/whitelist) to control which segmentation keys are recorded. + * Event-specific segmentation filters (blacklist/whitelist) to control segmentation keys per event. +* Added support for Journey Trigger Events that trigger a content zone refresh when recorded. +* Added a configurable user property cache limit through server configuration. + ## 25.4.9 * Added a new config option `disableViewRestartForManualRecording()` to disable auto close/restart behavior of manual views on app background/foreground actions. From 56a0ade943c5c36a7849f2b9cc5e1072e94e1f34 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 5 Feb 2026 21:51:05 +0300 Subject: [PATCH 41/42] feat: remove filters --- .../android/sdk/ModuleConfigurationTests.java | 2 +- .../android/sdk/ModuleConfiguration.java | 25 ++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 4e2a62ad2..ce0661508 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -712,7 +712,7 @@ public void invalidConfigResponses_AreRejected() { */ @Test public void configurationParameterCount() { - int configParameterCount = 42; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, and journey trigger events and filter_preset + int configParameterCount = 41; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, and journey trigger events int count = 0; for (Field field : ModuleConfiguration.class.getDeclaredFields()) { if (field.getName().startsWith("keyR")) { diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index cf3ee3d93..0ff608396 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -62,7 +62,6 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRSegmentationWhitelist = "sw"; final static String keyREventSegmentationWhitelist = "esw"; // json final static String keyRJourneyTriggerEvents = "jte"; - final static String keyRListingFilterPreset = "filter_preset"; // FLAGS boolean currentVTracking = true; @@ -401,10 +400,6 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { isValid = value instanceof Boolean; break; - case keyRListingFilterPreset: - isValid = value instanceof String && (value.equals("Whitelisting") || value.equals("Blacklisting")); - break; - // --- Positive Integer keys (> 0) --- case keyRServerConfigUpdateInterval: case keyRBOMAcceptedTimeout: @@ -515,19 +510,31 @@ void saveAndStoreDownloadedConfig(@NonNull JSONObject config) { } private void removeListingFilterKeysFromConfig(JSONObject newConfig) { - String filterPreset = newConfig.optString(keyRListingFilterPreset, "Blacklisting"); - - if (filterPreset.equals("Whitelisting")) { + boolean hasAnyWhitelist = newConfig.has(keyREventWhitelist) + || newConfig.has(keyRUserPropertyWhitelist) + || newConfig.has(keyRSegmentationWhitelist) + || newConfig.has(keyREventSegmentationWhitelist); + + boolean hasAnyBlacklist = newConfig.has(keyREventBlacklist) + || newConfig.has(keyRUserPropertyBlacklist) + || newConfig.has(keyRSegmentationBlacklist) + || newConfig.has(keyREventSegmentationBlacklist); + + // Only remove opposite type when we actually have data for current type + if (hasAnyWhitelist) { latestRetrievedConfiguration.remove(keyREventBlacklist); latestRetrievedConfiguration.remove(keyRUserPropertyBlacklist); latestRetrievedConfiguration.remove(keyRSegmentationBlacklist); latestRetrievedConfiguration.remove(keyREventSegmentationBlacklist); - } else { + } + + if (hasAnyBlacklist) { latestRetrievedConfiguration.remove(keyREventWhitelist); latestRetrievedConfiguration.remove(keyRUserPropertyWhitelist); latestRetrievedConfiguration.remove(keyRSegmentationWhitelist); latestRetrievedConfiguration.remove(keyREventSegmentationWhitelist); } + // If neither has data, don't remove anything - preserve existing filters } /** From 1740bed17a74341aa66e0cfb572d10b77e8e9a87 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:51:15 +0900 Subject: [PATCH 42/42] Update CHANGELOG with new features and bug fixes Updated CHANGELOG to include recent bug fixes and new features. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 932c15f2e..083891fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Added a configurable user property cache limit through server configuration. * Mitigated an issue where closing surveys that were presented via journeys was triggering an exception. +* Mitigated an issue where when a content started loading opening a new activity could have hide it. ## 25.4.9 * Added a new config option `disableViewRestartForManualRecording()` to disable auto close/restart behavior of manual views on app background/foreground actions.