diff --git a/LICENSE.txt b/LICENSE.txt index df08de3fb..b6579621e 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright © 2025 Split Software, Inc. +Copyright © 2026 Split Software, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java index 007303091..5a553d281 100644 --- a/api/src/main/java/io/split/android/client/SplitClient.java +++ b/api/src/main/java/io/split/android/client/SplitClient.java @@ -7,7 +7,7 @@ import java.util.Map; import io.split.android.client.attributes.AttributesManager; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; @@ -214,7 +214,7 @@ public interface SplitClient extends AttributesManager { * * @param listener the event listener to register. Must not be null. */ - void addEventListener(@NonNull SdkEventListener listener); + void addEventListener(@NonNull SplitEventListener listener); /** * Enqueue a new event to be sent to Split data collection services. diff --git a/api/src/main/java/io/split/android/client/events/SdkEventListener.java b/api/src/main/java/io/split/android/client/events/SplitEventListener.java similarity index 99% rename from api/src/main/java/io/split/android/client/events/SdkEventListener.java rename to api/src/main/java/io/split/android/client/events/SplitEventListener.java index c6a7a4409..424f5503b 100644 --- a/api/src/main/java/io/split/android/client/events/SdkEventListener.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventListener.java @@ -36,7 +36,7 @@ * }); * } */ -public abstract class SdkEventListener { +public abstract class SplitEventListener { /** * Called when SDK_READY event occurs, executed on a background thread. diff --git a/api/src/main/java/io/split/android/client/events/SplitEventTask.java b/api/src/main/java/io/split/android/client/events/SplitEventTask.java index c2b704cf5..7c053b55f 100644 --- a/api/src/main/java/io/split/android/client/events/SplitEventTask.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventTask.java @@ -14,7 +14,7 @@ * *
* For events with metadata (like SDK_UPDATE or SDK_READY_FROM_CACHE), use - * {@link SdkEventListener} instead for type-safe metadata access. + * {@link SplitEventListener} instead for type-safe metadata access. *
* Example usage: *
{@code
diff --git a/build.gradle b/build.gradle
index b27746e92..001dd7cbd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,3 @@
-import com.vanniktech.maven.publish.AndroidFusedLibrary
-import org.gradle.api.publish.maven.MavenPublication
-
buildscript {
repositories {
google()
diff --git a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java
index 7d8061224..93e0f6c3f 100644
--- a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java
+++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java
@@ -38,6 +38,7 @@ public class EventsManagerCoordinator implements ISplitEventsManager, EventsMana
private final ConcurrentMap mManagers = new ConcurrentHashMap<>();
private final Set mTriggered = Collections.newSetFromMap(new ConcurrentHashMap());
+ private final ConcurrentMap mTriggeredMetadata = new ConcurrentHashMap<>();
private final Object mEventLock = new Object();
/**
@@ -75,6 +76,9 @@ public void notifyInternalEvent(SplitInternalEvent internalEvent, @Nullable Even
synchronized (mEventLock) {
mTriggered.add(internalEvent);
+ if (metadata != null) {
+ mTriggeredMetadata.put(internalEvent, metadata);
+ }
for (ISplitEventsManager manager : mManagers.values()) {
manager.notifyInternalEvent(internalEvent, metadata);
@@ -123,7 +127,7 @@ public void unregisterEventsManager(Key key) {
private void propagateTriggeredEvents(ISplitEventsManager splitEventsManager) {
synchronized (mEventLock) {
for (SplitInternalEvent event : mTriggered) {
- splitEventsManager.notifyInternalEvent(event, null);
+ splitEventsManager.notifyInternalEvent(event, mTriggeredMetadata.get(event));
}
}
}
diff --git a/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
index a8ad9c0f1..43498e379 100644
--- a/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
+++ b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
@@ -8,7 +8,7 @@ public interface ListenableEventsManager {
void register(SplitEvent event, SplitEventTask task);
- void registerEventListener(SdkEventListener listener);
+ void registerEventListener(SplitEventListener listener);
boolean eventAlreadyTriggered(SplitEvent event);
}
diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
index 5977fbbb2..8fc801117 100644
--- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
+++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
@@ -113,7 +113,7 @@ public void register(SplitEvent event, SplitEventTask task) {
}
@Override
- public void registerEventListener(SdkEventListener listener) {
+ public void registerEventListener(SplitEventListener listener) {
requireNonNull(listener);
// Register SDK_READY handlers (bg + main)
@@ -190,7 +190,7 @@ private EventHandler createMainThreadHandler(final Sp
}
// SdkEventListener handlers for SDK_READY
- private EventHandler createReadyBackgroundHandler(final SdkEventListener listener) {
+ private EventHandler createReadyBackgroundHandler(final SplitEventListener listener) {
return (event, metadata) -> {
SplitClient client = mResources.getSplitClient();
SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -198,7 +198,7 @@ private EventHandler createReadyBackgroundHandler(fin
};
}
- private EventHandler createReadyMainThreadHandler(final SdkEventListener listener) {
+ private EventHandler createReadyMainThreadHandler(final SplitEventListener listener) {
return (event, metadata) -> {
SplitClient client = mResources.getSplitClient();
SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -207,7 +207,7 @@ private EventHandler createReadyMainThreadHandler(fin
}
// SdkEventListener handlers for SDK_UPDATE
- private EventHandler createUpdateBackgroundHandler(final SdkEventListener listener) {
+ private EventHandler createUpdateBackgroundHandler(final SplitEventListener listener) {
return (event, metadata) -> {
SplitClient client = mResources.getSplitClient();
SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata);
@@ -215,7 +215,7 @@ private EventHandler createUpdateBackgroundHandler(fi
};
}
- private EventHandler createUpdateMainThreadHandler(final SdkEventListener listener) {
+ private EventHandler createUpdateMainThreadHandler(final SplitEventListener listener) {
return (event, metadata) -> {
SplitClient client = mResources.getSplitClient();
SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata);
@@ -224,7 +224,7 @@ private EventHandler createUpdateMainThreadHandler(fi
}
// SdkEventListener handlers for SDK_READY_FROM_CACHE
- private EventHandler createReadyFromCacheBackgroundHandler(final SdkEventListener listener) {
+ private EventHandler createReadyFromCacheBackgroundHandler(final SplitEventListener listener) {
return (event, metadata) -> {
SplitClient client = mResources.getSplitClient();
SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -232,7 +232,7 @@ private EventHandler createReadyFromCacheBackgroundHa
};
}
- private EventHandler createReadyFromCacheMainThreadHandler(final SdkEventListener listener) {
+ private EventHandler createReadyFromCacheMainThreadHandler(final SplitEventListener listener) {
return (event, metadata) -> {
SplitClient client = mResources.getSplitClient();
SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java
index f6c09ac6f..28e2738be 100644
--- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java
+++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java
@@ -74,6 +74,15 @@ static EventsManagerConfig create() {
.executionLimit(SplitEvent.SDK_READY_TIMED_OUT, 1)
.executionLimit(SplitEvent.SDK_UPDATE, -1) // unlimited
+ // Metadata sources
+ .metadataSource(SplitEvent.SDK_READY, SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)
+ // Cache path: if SDK_READY_FROM_CACHE fired because cache was loaded, use storage load metadata.
+ .metadataSource(SplitEvent.SDK_READY_FROM_CACHE, cacheGroup,
+ SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE)
+ // Sync path: if SDK_READY_FROM_CACHE fired alongside SDK_READY, use sync completion metadata.
+ .metadataSource(SplitEvent.SDK_READY_FROM_CACHE, syncGroup,
+ SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)
+
.build();
}
}
diff --git a/events/README.md b/events/README.md
index 23a505c54..b91d8c63f 100644
--- a/events/README.md
+++ b/events/README.md
@@ -21,6 +21,8 @@ Events are configured using `EventsManagerConfig.Builder`:
- **`prerequisite(external, prerequisiteExternal)`**: External event can only fire after the prerequisite external event has fired
- **`suppressedBy(external, suppressorExternal)`**: External event is permanently suppressed if the suppressor external event has already fired
- **`executionLimit(external, limit)`**: Max times the event can fire (-1 = unlimited, 1 = once only)
+- **`metadataSource(external, internal)`**: For `requireAll`, selects the internal event whose metadata will be delivered
+- **`metadataSource(external, Set, internal)`**: For `requireAny` groups, selects the metadata source per group
## Topological Sort for Evaluation Order
@@ -31,6 +33,9 @@ The events system uses **topological sorting** to determine the order in which e
1. **Internal Event Arrives**: A single internal event can potentially satisfy conditions for multiple external events.
2. **Single-Pass Evaluation**: The system iterates through a pre-computed list of external events (`mEvaluationOrder`).
3. **Order Matters**: This list is topologically sorted so that events with dependencies (prerequisites/suppression) come *after* the events they depend on.
+4. **Metadata Selection**: When an external event fires, metadata is resolved from the configured source event:
+ - `requireAll`: use the configured source internal event
+ - `requireAny`: use the source configured for the specific group that completed
### Why It's Necessary
diff --git a/events/src/main/java/io/harness/events/EventsManagerConfig.java b/events/src/main/java/io/harness/events/EventsManagerConfig.java
index 79d4ccbbb..54886f1a0 100644
--- a/events/src/main/java/io/harness/events/EventsManagerConfig.java
+++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java
@@ -27,6 +27,10 @@ public final class EventsManagerConfig {
private final Map> mSuppressedBy;
// Execution policy: max executions per external event (-1 = unlimited)
private final Map mExecutionLimits;
+ // Metadata source for requireAll events
+ private final Map mRequireAllMetadataSource;
+ // Metadata source for requireAny groups
+ private final Map, I>> mRequireAnyMetadataSource;
// Topologically sorted evaluation order (prerequisites and suppressors come before dependents)
private final List mEvaluationOrder;
@@ -43,7 +47,9 @@ private EventsManagerConfig(Map> requireAll,
Map>> requireAny,
Map> prerequisites,
Map> suppressedBy,
- Map executionLimits) {
+ Map executionLimits,
+ Map requireAllMetadataSource,
+ Map, I>> requireAnyMetadataSource) {
mRequireAll = requireAll == null
? Collections.emptyMap()
: Collections.unmodifiableMap(new HashMap<>(requireAll));
@@ -59,12 +65,20 @@ private EventsManagerConfig(Map> requireAll,
mExecutionLimits = executionLimits == null
? Collections.emptyMap()
: Collections.unmodifiableMap(new HashMap<>(executionLimits));
+ mRequireAllMetadataSource = requireAllMetadataSource == null
+ ? Collections.emptyMap()
+ : Collections.unmodifiableMap(new HashMap<>(requireAllMetadataSource));
+ mRequireAnyMetadataSource = requireAnyMetadataSource == null
+ ? Collections.emptyMap()
+ : Collections.unmodifiableMap(new HashMap<>(requireAnyMetadataSource));
mEvaluationOrder = computeEvaluationOrder();
}
public static EventsManagerConfig empty() {
return new EventsManagerConfig<>(Collections.emptyMap(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
Collections.emptyMap(),
Collections.emptyMap(),
Collections.emptyMap(),
@@ -107,6 +121,16 @@ public Map getExecutionLimits() {
return mExecutionLimits;
}
+ @NotNull
+ public Map getRequireAllMetadataSource() {
+ return mRequireAllMetadataSource;
+ }
+
+ @NotNull
+ public Map, I>> getRequireAnyMetadataSource() {
+ return mRequireAnyMetadataSource;
+ }
+
@NotNull
public List getEvaluationOrder() {
return mEvaluationOrder;
@@ -135,6 +159,8 @@ public static final class Builder {
private final Map> mPrerequisites = new HashMap<>();
private final Map> mSuppressedBy = new HashMap<>();
private final Map mExecutionLimits = new HashMap<>();
+ private final Map mRequireAllMetadataSource = new HashMap<>();
+ private final Map, I>> mRequireAnyMetadataSource = new HashMap<>();
private Builder() {
}
@@ -242,6 +268,36 @@ public Builder executionLimit(E externalEvent, int limit) {
return this;
}
+ /**
+ * Sets the metadata source for a requireAll external event.
+ *
+ * @param externalEvent the external event
+ * @param sourceEvent the internal event whose metadata should be used
+ * @return this builder
+ */
+ public Builder metadataSource(E externalEvent, I sourceEvent) {
+ mRequireAllMetadataSource.put(externalEvent, sourceEvent);
+ return this;
+ }
+
+ /**
+ * Sets the metadata source for a requireAny group.
+ *
+ * @param externalEvent the external event
+ * @param group the internal event group
+ * @param sourceEvent the internal event whose metadata should be used
+ * @return this builder
+ */
+ public Builder metadataSource(E externalEvent, Set group, I sourceEvent) {
+ Map, I> groupSources = mRequireAnyMetadataSource.get(externalEvent);
+ if (groupSources == null) {
+ groupSources = new HashMap<>();
+ mRequireAnyMetadataSource.put(externalEvent, groupSources);
+ }
+ groupSources.put(new HashSet<>(group), sourceEvent);
+ return this;
+ }
+
/**
* Builds the EventsManagerConfig.
*
@@ -253,7 +309,9 @@ public EventsManagerConfig build() {
mRequireAny.isEmpty() ? null : mRequireAny,
mPrerequisites.isEmpty() ? null : mPrerequisites,
mSuppressedBy.isEmpty() ? null : mSuppressedBy,
- mExecutionLimits.isEmpty() ? null : mExecutionLimits
+ mExecutionLimits.isEmpty() ? null : mExecutionLimits,
+ mRequireAllMetadataSource.isEmpty() ? null : mRequireAllMetadataSource,
+ mRequireAnyMetadataSource.isEmpty() ? null : mRequireAnyMetadataSource
);
}
}
diff --git a/events/src/main/java/io/harness/events/EventsManagerCore.java b/events/src/main/java/io/harness/events/EventsManagerCore.java
index 4368417b9..d93baf2cf 100644
--- a/events/src/main/java/io/harness/events/EventsManagerCore.java
+++ b/events/src/main/java/io/harness/events/EventsManagerCore.java
@@ -26,6 +26,7 @@ class EventsManagerCore implements EventsManager {
private final Map>> mSubscriptions = new HashMap<>();
private final Map mTriggerCount = new HashMap<>();
private final Set mSeenInternal = new HashSet<>();
+ private final Map mInternalEventMetadata = new HashMap<>();
@NotNull
private final EventsManagerConfig mConfig;
@@ -146,6 +147,9 @@ private void processInternal(I event, M metadata) {
return;
}
mSeenInternal.add(event);
+ if (metadata != null) {
+ mInternalEventMetadata.put(event, metadata);
+ }
currentSeenInternal = new HashSet<>(mSeenInternal);
}
@@ -153,14 +157,15 @@ private void processInternal(I event, M metadata) {
// before their dependents.
for (E externalEvent : mConfig.getEvaluationOrder()) {
// Check if internal trigger conditions are met (RequireAll or RequireAny)
- boolean internalConditionsMet = checkInternalTriggerConditions(externalEvent, currentSeenInternal, event);
-
- if (!internalConditionsMet) {
+ InternalTriggerMatch match = checkInternalTriggerConditions(externalEvent, currentSeenInternal, event);
+
+ if (!match.mMatched) {
continue;
}
// Check external guards (prerequisites and suppression) and fire if all conditions met
- triggerIfConditionsMet(externalEvent, metadata);
+ M resolvedMetadata = resolveMetadata(externalEvent, match, metadata);
+ triggerIfConditionsMet(externalEvent, resolvedMetadata);
}
}
@@ -249,10 +254,10 @@ private boolean isSuppressed(E external) {
* @param seenInternal all internal events seen so far
* @param currentEvent the internal event that just arrived
*/
- private boolean checkInternalTriggerConditions(E externalEvent, Set seenInternal, I currentEvent) {
+ private InternalTriggerMatch checkInternalTriggerConditions(E externalEvent, Set seenInternal, I currentEvent) {
Set requireAll = mConfig.getRequireAll().get(externalEvent);
if (requireAll != null && !requireAll.isEmpty() && seenInternal.containsAll(requireAll)) {
- return true;
+ return InternalTriggerMatch.requireAll();
}
// Check RequireAny: The CURRENT internal event must be in one of the groups,
@@ -262,12 +267,65 @@ private boolean checkInternalTriggerConditions(E externalEvent, Set seenInter
for (Set group : requireAnyGroups) {
// Only consider groups that contain the current event
if (!group.isEmpty() && group.contains(currentEvent) && seenInternal.containsAll(group)) {
- return true;
+ return InternalTriggerMatch.requireAny(group);
}
}
}
- return false;
+ return InternalTriggerMatch.none();
+ }
+
+ private M resolveMetadata(E externalEvent, InternalTriggerMatch match, M currentMetadata) {
+ if (match.mRequireAllMatched) {
+ I sourceEvent = mConfig.getRequireAllMetadataSource().get(externalEvent);
+ return resolveMetadataFromSource(sourceEvent, currentMetadata);
+ }
+
+ if (match.mRequireAnyGroup != null) {
+ Map, I> groupSources = mConfig.getRequireAnyMetadataSource().get(externalEvent);
+ if (groupSources != null) {
+ I sourceEvent = groupSources.get(match.mRequireAnyGroup);
+ return resolveMetadataFromSource(sourceEvent, currentMetadata);
+ }
+ }
+
+ return resolveMetadataFromSource(null, currentMetadata);
+ }
+
+ private M resolveMetadataFromSource(I sourceEvent, M currentMetadata) {
+ if (sourceEvent != null) {
+ synchronized (mLock) {
+ M stored = mInternalEventMetadata.get(sourceEvent);
+ if (stored != null) {
+ return stored;
+ }
+ }
+ }
+ return currentMetadata;
+ }
+
+ private static class InternalTriggerMatch {
+ private final boolean mMatched;
+ private final boolean mRequireAllMatched;
+ private final Set mRequireAnyGroup;
+
+ private InternalTriggerMatch(boolean matched, boolean requireAllMatched, Set requireAnyGroup) {
+ mMatched = matched;
+ mRequireAllMatched = requireAllMatched;
+ mRequireAnyGroup = requireAnyGroup;
+ }
+
+ private static InternalTriggerMatch requireAll() {
+ return new InternalTriggerMatch<>(true, true, null);
+ }
+
+ private static InternalTriggerMatch requireAny(Set group) {
+ return new InternalTriggerMatch<>(true, false, group);
+ }
+
+ private static InternalTriggerMatch none() {
+ return new InternalTriggerMatch<>(false, false, null);
+ }
}
}
diff --git a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java
index 5fafb6fe6..2662e2aeb 100644
--- a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java
+++ b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java
@@ -24,6 +24,8 @@ public void emptyBuilderCreatesEmptyMaps() {
assertTrue(config.getPrerequisites().isEmpty());
assertTrue(config.getSuppressedBy().isEmpty());
assertTrue(config.getExecutionLimits().isEmpty());
+ assertTrue(config.getRequireAllMetadataSource().isEmpty());
+ assertTrue(config.getRequireAnyMetadataSource().isEmpty());
}
@Test
@@ -34,6 +36,8 @@ public void builderCreatesConfigWithAllFields() {
.prerequisite("E1", "E0")
.suppressedBy("E1", "E2")
.executionLimit("E1", 3)
+ .metadataSource("E1", "I2")
+ .metadataSource("E2", Collections.singleton("I3"), "I3")
.build();
assertEquals(1, config.getRequireAll().size());
@@ -54,6 +58,10 @@ public void builderCreatesConfigWithAllFields() {
assertEquals(1, config.getExecutionLimits().size());
assertEquals(Integer.valueOf(3), config.getExecutionLimits().get("E1"));
+
+ assertEquals("I2", config.getRequireAllMetadataSource().get("E1"));
+ assertEquals("I3", config.getRequireAnyMetadataSource().get("E2")
+ .get(Collections.singleton("I3")));
}
@Test
@@ -90,6 +98,7 @@ public void returnedMapsAreUnmodifiable() {
.prerequisite("E1", "E0")
.suppressedBy("E1", "E2")
.executionLimit("E1", 3)
+ .metadataSource("E1", "I1")
.build();
try {
@@ -126,6 +135,20 @@ public void returnedMapsAreUnmodifiable() {
} catch (UnsupportedOperationException expected) {
// expected
}
+
+ try {
+ config.getRequireAllMetadataSource().put("E2", "I2");
+ Assert.fail("getRequireAllMetadataSource() should return an unmodifiable map");
+ } catch (UnsupportedOperationException expected) {
+ // expected
+ }
+
+ try {
+ config.getRequireAnyMetadataSource().put("E2", Collections.singletonMap(Collections.singleton("I2"), "I2"));
+ Assert.fail("getRequireAnyMetadataSource() should return an unmodifiable map");
+ } catch (UnsupportedOperationException expected) {
+ // expected
+ }
}
@Test
@@ -137,6 +160,8 @@ public void emptyMethodReturnsEmptyUnmodifiableConfig() {
assertTrue(config.getPrerequisites().isEmpty());
assertTrue(config.getSuppressedBy().isEmpty());
assertTrue(config.getExecutionLimits().isEmpty());
+ assertTrue(config.getRequireAllMetadataSource().isEmpty());
+ assertTrue(config.getRequireAnyMetadataSource().isEmpty());
try {
config.getRequireAll().put("E1", Collections.singleton("I1"));
diff --git a/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java b/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java
new file mode 100644
index 000000000..c543d9b21
--- /dev/null
+++ b/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java
@@ -0,0 +1,90 @@
+package io.harness.events;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class EventsManagerMetadataTest {
+
+ private static final long TIMEOUT_MS = 5000;
+
+ enum ExternalEvent {
+ READY_FROM_CACHE
+ }
+
+ enum InternalEvent {
+ CACHE_A, CACHE_B, SYNC_A, SYNC_B
+ }
+
+ @Test
+ public void requireAnyUsesGroupMetadataSource() throws InterruptedException {
+ Set cacheGroup = new HashSet<>();
+ cacheGroup.add(InternalEvent.CACHE_A);
+ cacheGroup.add(InternalEvent.CACHE_B);
+
+ Set syncGroup = new HashSet<>();
+ syncGroup.add(InternalEvent.SYNC_A);
+ syncGroup.add(InternalEvent.SYNC_B);
+
+ EventsManagerConfig config = EventsManagerConfig.builder()
+ .requireAny(ExternalEvent.READY_FROM_CACHE, cacheGroup, syncGroup)
+ .metadataSource(ExternalEvent.READY_FROM_CACHE, cacheGroup, InternalEvent.CACHE_A)
+ .metadataSource(ExternalEvent.READY_FROM_CACHE, syncGroup, InternalEvent.SYNC_A)
+ .executionLimit(ExternalEvent.READY_FROM_CACHE, 1)
+ .build();
+
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference received = new AtomicReference<>();
+
+ EventsManager manager =
+ new EventsManagerCore<>(config, (handler, event, metadata) -> {
+ handler.handle(event, metadata);
+ latch.countDown();
+ });
+
+ manager.register(ExternalEvent.READY_FROM_CACHE, (event, metadata) -> received.set(metadata));
+
+ // Complete sync group: metadata should come from SYNC_A, not from SYNC_B (current event).
+ manager.notifyInternalEvent(InternalEvent.SYNC_A, "sync-meta");
+ manager.notifyInternalEvent(InternalEvent.SYNC_B, "sync-b-meta");
+
+ assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ assertEquals("sync-meta", received.get());
+ }
+
+ @Test
+ public void requireAllUsesConfiguredMetadataSource() throws InterruptedException {
+ EventsManagerConfig config = EventsManagerConfig.builder()
+ .requireAll(ExternalEvent.READY_FROM_CACHE, InternalEvent.CACHE_A, InternalEvent.CACHE_B)
+ .metadataSource(ExternalEvent.READY_FROM_CACHE, InternalEvent.CACHE_A)
+ .executionLimit(ExternalEvent.READY_FROM_CACHE, 1)
+ .build();
+
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference received = new AtomicReference<>();
+
+ EventsManager manager =
+ new EventsManagerCore<>(config, (handler, event, metadata) -> {
+ handler.handle(event, metadata);
+ latch.countDown();
+ });
+
+ manager.register(ExternalEvent.READY_FROM_CACHE, (event, metadata) -> received.set(metadata));
+
+ // Provide metadata on CACHE_A only; CACHE_B completes the requireAll.
+ manager.notifyInternalEvent(InternalEvent.CACHE_A, "cache-meta");
+ manager.notifyInternalEvent(InternalEvent.CACHE_B, null);
+
+ assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ assertNotNull(received.get());
+ assertEquals("cache-meta", received.get());
+ }
+}
diff --git a/main/src/androidTest/java/fake/SplitClientStub.java b/main/src/androidTest/java/fake/SplitClientStub.java
index b9d354bf9..14c0fcde4 100644
--- a/main/src/androidTest/java/fake/SplitClientStub.java
+++ b/main/src/androidTest/java/fake/SplitClientStub.java
@@ -11,7 +11,7 @@
import io.split.android.client.EvaluationOptions;
import io.split.android.client.SplitClient;
import io.split.android.client.SplitResult;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
@@ -122,7 +122,7 @@ public void on(SplitEvent event, SplitEventTask task) {
}
@Override
- public void addEventListener(SdkEventListener listener) {
+ public void addEventListener(SplitEventListener listener) {
// Stub implementation - does nothing
}
diff --git a/main/src/androidTest/java/helper/IntegrationHelper.java b/main/src/androidTest/java/helper/IntegrationHelper.java
index 40062cd6d..7d99b3fe0 100644
--- a/main/src/androidTest/java/helper/IntegrationHelper.java
+++ b/main/src/androidTest/java/helper/IntegrationHelper.java
@@ -56,6 +56,12 @@
public class IntegrationHelper {
public static final int NEVER_REFRESH_RATE = 999999;
+ // Base64-encoded split definition payload for "mauro_java" split
+ public static final String SPLIT_UPDATE_PAYLOAD_TYPE0 = "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0=";
+
+ // Base64-encoded RBS definition payload for "rbs_test" segment
+ public static final String RBS_UPDATE_PAYLOAD_TYPE0 = "eyJuYW1lIjoicmJzX3Rlc3QiLCJzdGF0dXMiOiJBQ1RJVkUiLCJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiZXhjbHVkZWQiOnsia2V5cyI6W10sInNlZ21lbnRzIjpbXX0sImNvbmRpdGlvbnMiOlt7Im1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX19XX0=";
+
private final static Type EVENT_LIST_TYPE = new TypeToken>() {
}.getType();
private final static Type IMPRESSIONS_LIST_TYPE = new TypeToken>() {
@@ -188,6 +194,68 @@ public static String dummySingleSegment(String segment) {
return "{\"ms\":{\"k\":[{\"n\":\"" + segment + "\"}],\"cn\":null},\"ls\":{\"k\":[],\"cn\":1702507130121}}";
}
+ /**
+ * Builds a memberships response with custom segments and change number.
+ * @param segments Array of segment names for my segments
+ * @param msCn Change number for my segments (null if not needed)
+ * @param largeSegments Array of segment names for large segments
+ * @param lsCn Change number for large segments
+ */
+ public static String membershipsResponse(String[] segments, Long msCn, String[] largeSegments, Long lsCn) {
+ StringBuilder msSegments = new StringBuilder();
+ for (int i = 0; i < segments.length; i++) {
+ if (i > 0) msSegments.append(",");
+ msSegments.append("{\"n\":\"").append(segments[i]).append("\"}");
+ }
+
+ StringBuilder lsSegments = new StringBuilder();
+ for (int i = 0; i < largeSegments.length; i++) {
+ if (i > 0) lsSegments.append(",");
+ lsSegments.append("{\"n\":\"").append(largeSegments[i]).append("\"}");
+ }
+
+ return String.format("{\"ms\":{\"k\":[%s],\"cn\":%s},\"ls\":{\"k\":[%s],\"cn\":%d}}",
+ msSegments, msCn, lsSegments, lsCn);
+ }
+
+ /**
+ * Simplified memberships response with only my segments.
+ */
+ public static String membershipsResponse(String[] segments, long cn) {
+ return membershipsResponse(segments, cn, new String[]{}, cn);
+ }
+
+ /**
+ * Builds a targeting rules changes response with a simple flag.
+ */
+ public static String targetingRulesChangesWithFlag(String flagName, long till) {
+ return String.format("{\"ff\":{\"s\":%d,\"t\":%d,\"d\":[" +
+ "{\"trafficTypeName\":\"user\",\"name\":\"%s\",\"status\":\"ACTIVE\"," +
+ "\"killed\":false,\"defaultTreatment\":\"off\",\"changeNumber\":%d," +
+ "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\"," +
+ "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}," +
+ "\"partitions\":[{\"treatment\":\"on\",\"size\":100}]}]}" +
+ "]},\"rbs\":{\"s\":%d,\"t\":%d,\"d\":[]}}", till, till, flagName, till, till, till);
+ }
+
+ /**
+ * Builds a targeting rules changes response with both a flag and an RBS.
+ */
+ public static String targetingRulesChangesWithFlagAndRbs(String flagName, String rbsName, long till) {
+ return String.format("{\"ff\":{\"s\":%d,\"t\":%d,\"d\":[" +
+ "{\"trafficTypeName\":\"user\",\"name\":\"%s\",\"status\":\"ACTIVE\"," +
+ "\"killed\":false,\"defaultTreatment\":\"off\",\"changeNumber\":%d," +
+ "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\"," +
+ "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}," +
+ "\"partitions\":[{\"treatment\":\"on\",\"size\":100}]}]}" +
+ "]},\"rbs\":{\"s\":%d,\"t\":%d,\"d\":[" +
+ "{\"name\":\"%s\",\"status\":\"ACTIVE\",\"trafficTypeName\":\"user\"," +
+ "\"excluded\":{\"keys\":[],\"segments\":[]}," +
+ "\"conditions\":[{\"matcherGroup\":{\"combiner\":\"AND\"," +
+ "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}}]}" +
+ "]}}", till, till, flagName, till, till, till, rbsName);
+ }
+
public static String dummyApiKey() {
return "99049fd8653247c5ea42bc3c1ae2c6a42bc3";
}
@@ -303,10 +371,7 @@ public static String splitChangeV2CompressionType1() {
}
public static String splitChangeV2CompressionType0() {
- return splitChangeV2("9999999999999",
- "1000",
- "0",
- "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0=");
+ return splitChangeV2("9999999999999", "1000", "0", SPLIT_UPDATE_PAYLOAD_TYPE0);
}
public static String splitChangeV2(String changeNumber, String previousChangeNumber, String compressionType, String compressedPayload) {
@@ -506,4 +571,46 @@ public static class ServicePath {
public static final String IMPRESSIONS = "testImpressions/bulk";
public static final String AUTH = "v2/auth";
}
+
+ /**
+ * Creates a simple split entity JSON body for database population.
+ */
+ public static String splitEntityBody(String name, long changeNumber) {
+ return String.format("{\"name\":\"%s\", \"changeNumber\": %d}", name, changeNumber);
+ }
+
+ /**
+ * Creates a segment list JSON for database population (my segments format).
+ * @param segments Array of segment names
+ */
+ public static String segmentListJson(String... segments) {
+ StringBuilder sb = new StringBuilder("{\"k\":[");
+ for (int i = 0; i < segments.length; i++) {
+ if (i > 0) sb.append(",");
+ sb.append("{\"n\":\"").append(segments[i]).append("\"}");
+ }
+ sb.append("],\"cn\":null}");
+ return sb.toString();
+ }
+
+ public static String membershipKeyListUpdate(java.math.BigInteger hashedKey, String segmentName, long changeNumber) {
+ String keyListJson = "{\"a\":[" + hashedKey.toString() + "],\"r\":[]}";
+ String encodedKeyList = Base64.encodeToString(
+ keyListJson.getBytes(java.nio.charset.StandardCharsets.UTF_8),
+ Base64.NO_WRAP);
+
+ String notificationJson = "{" +
+ "\\\"type\\\":\\\"MEMBERSHIPS_MS_UPDATE\\\"," +
+ "\\\"cn\\\":" + changeNumber + "," +
+ "\\\"n\\\":[\\\"" + segmentName + "\\\"]," +
+ "\\\"c\\\":0," +
+ "\\\"u\\\":2," +
+ "\\\"d\\\":\\\"" + encodedKeyList + "\\\"" +
+ "}";
+
+ return "id: 1\n" +
+ "event: message\n" +
+ "data: {\"id\":\"m1\",\"clientId\":\"pri:test\",\"timestamp\":" + System.currentTimeMillis() +
+ ",\"encoding\":\"json\",\"channel\":\"test_channel\",\"data\":\"" + notificationJson + "\"}\n";
+ }
}
diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
index 24d3283f5..62f2dcdad 100644
--- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
+++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
@@ -15,6 +15,7 @@
import org.junit.Test;
import java.io.IOException;
+import java.math.BigInteger;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
@@ -24,6 +25,7 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
import fake.HttpClientMock;
import fake.HttpResponseMock;
@@ -37,14 +39,14 @@
import io.split.android.client.SplitClientConfig;
import io.split.android.client.SplitFactory;
import io.split.android.client.api.Key;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SdkReadyMetadata;
import io.split.android.client.events.SdkUpdateMetadata;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
+import io.split.android.client.service.sseclient.notifications.MySegmentsV2PayloadDecoder;
import io.split.android.client.network.HttpMethod;
import io.split.android.client.storage.db.GeneralInfoEntity;
-import io.split.android.client.storage.db.MyLargeSegmentEntity;
import io.split.android.client.storage.db.MySegmentEntity;
import io.split.android.client.storage.db.SplitEntity;
import io.split.android.client.storage.db.SplitRoomDatabase;
@@ -142,36 +144,25 @@ public void tearDown() throws Exception {
@Test
public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception {
// Given: SDK is starting with populated persistent storage
- long testTimestamp = System.currentTimeMillis();
- populateDatabaseWithCacheData(testTimestamp);
-
- SplitClientConfig config = buildConfig();
- SplitFactory factory = buildFactory(config);
-
- // And: a handler H is registered for sdkReadyFromCache
- AtomicInteger handlerInvocationCount = new AtomicInteger(0);
- AtomicReference receivedMetadata = new AtomicReference<>();
- CountDownLatch cacheReadyLatch = new CountDownLatch(1);
-
+ populateDatabaseWithCacheData(System.currentTimeMillis());
+ SplitFactory factory = buildFactory(buildConfig());
SplitClient client = factory.client(new Key("key_1"));
- registerCacheReadyHandler(client, handlerInvocationCount, receivedMetadata, cacheReadyLatch);
- boolean fired = cacheReadyLatch.await(10, TimeUnit.SECONDS);
+ // And: a handler H is registered for sdkReadyFromCache
+ EventCapture capture = captureCacheReadyEvent(client);
// Then: sdkReadyFromCache is emitted exactly once
- assertTrue("SDK_READY_FROM_CACHE should fire", fired);
- assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get());
+ awaitEvent(capture.latch, "SDK_READY_FROM_CACHE");
+ assertFiredOnce(capture.count, "SDK_READY_FROM_CACHE handler");
// And: the metadata contains "initialCacheLoad" with value false
- assertNotNull("Metadata should not be null", receivedMetadata.get());
- Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad();
- assertNotNull("initialCacheLoad should not be null", initialCacheLoad);
- assertFalse("initialCacheLoad should be false for cache path", initialCacheLoad);
+ assertNotNull("Metadata should not be null", capture.metadata.get());
+ assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+ assertFalse("initialCacheLoad should be false for cache path", capture.metadata.get().isInitialCacheLoad());
// And: the metadata contains "lastUpdateTimestamp" with a valid timestamp
- Long lastUpdateTimestamp = receivedMetadata.get().getLastUpdateTimestamp();
- assertNotNull("lastUpdateTimestamp should not be null", lastUpdateTimestamp);
- assertTrue("lastUpdateTimestamp should be valid", lastUpdateTimestamp > 0);
+ assertNotNull("lastUpdateTimestamp should not be null", capture.metadata.get().getLastUpdateTimestamp());
+ assertTrue("lastUpdateTimestamp should be valid", capture.metadata.get().getLastUpdateTimestamp() > 0);
factory.destroy();
}
@@ -189,31 +180,20 @@ public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception {
@Test
public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exception {
// Given: SDK is starting without persistent storage (fresh install)
- // Database is already empty from setup()
-
- SplitClientConfig config = buildConfig();
- SplitFactory factory = buildFactory(config);
-
- // And: a handler H is registered for sdkReadyFromCache
- AtomicInteger handlerInvocationCount = new AtomicInteger(0);
- AtomicReference receivedMetadata = new AtomicReference<>();
- CountDownLatch cacheReadyLatch = new CountDownLatch(1);
-
+ SplitFactory factory = buildFactory(buildConfig());
SplitClient client = factory.client(new Key("key_1"));
- registerCacheReadyHandler(client, handlerInvocationCount, receivedMetadata, cacheReadyLatch);
- // When: internal events "targetingRulesSyncComplete" and "membershipsSyncComplete" are notified
- boolean fired = cacheReadyLatch.await(10, TimeUnit.SECONDS);
+ // And: a handler H is registered for sdkReadyFromCache
+ EventCapture capture = captureCacheReadyEvent(client);
// Then: sdkReadyFromCache is emitted exactly once
- assertTrue("SDK_READY_FROM_CACHE should fire", fired);
- assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get());
+ awaitEvent(capture.latch, "SDK_READY_FROM_CACHE");
+ assertFiredOnce(capture.count, "SDK_READY_FROM_CACHE handler");
// And: the metadata contains "initialCacheLoad" with value true
- assertNotNull("Metadata should not be null", receivedMetadata.get());
- Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad();
- assertNotNull("initialCacheLoad should not be null", initialCacheLoad);
- assertTrue("initialCacheLoad should be true for sync path (fresh install)", initialCacheLoad);
+ assertNotNull("Metadata should not be null", capture.metadata.get());
+ assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+ assertTrue("initialCacheLoad should be true for sync path (fresh install)", capture.metadata.get().isInitialCacheLoad());
factory.destroy();
}
@@ -232,50 +212,53 @@ public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exc
@Test
public void sdkReadyListenerFiresWithMetadata() throws Exception {
// Given: SDK is starting with populated persistent storage
- long testTimestamp = System.currentTimeMillis();
- populateDatabaseWithCacheData(testTimestamp);
-
- SplitClientConfig config = buildConfig();
- SplitFactory factory = buildFactory(config);
-
- AtomicInteger onReadyCount = new AtomicInteger(0);
- AtomicReference receivedMetadata = new AtomicReference<>();
- AtomicReference receivedClient = new AtomicReference<>();
- CountDownLatch readyLatch = new CountDownLatch(1);
-
+ populateDatabaseWithCacheData(System.currentTimeMillis());
+ SplitFactory factory = buildFactory(buildConfig());
SplitClient client = factory.client(new Key("key_1"));
// And: a handler H is registered using addEventListener with onReady
- client.addEventListener(new SdkEventListener() {
- @Override
- public void onReady(SplitClient client, SdkReadyMetadata metadata) {
- onReadyCount.incrementAndGet();
- receivedMetadata.set(metadata);
- receivedClient.set(client);
- readyLatch.countDown();
- }
- });
-
- // When: SDK_READY fires
- boolean fired = readyLatch.await(10, TimeUnit.SECONDS);
+ EventCapture capture = captureReadyEvent(client);
// Then: onReady is invoked exactly once
- assertTrue("onReady should fire", fired);
- assertEquals("onReady should be invoked exactly once", 1, onReadyCount.get());
-
- // And: the handler receives the SplitClient and SdkReadyMetadata
- assertNotNull("Received client should not be null", receivedClient.get());
- assertNotNull("Received metadata should not be null", receivedMetadata.get());
+ awaitEvent(capture.latch, "onReady", 30);
+ assertFiredOnce(capture.count, "onReady");
// And: the metadata contains "initialCacheLoad" with value false
- Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad();
- assertNotNull("initialCacheLoad should not be null", initialCacheLoad);
- assertFalse("initialCacheLoad should be false for cache path", initialCacheLoad);
+ assertNotNull("Received metadata should not be null", capture.metadata.get());
+ assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+ assertFalse("initialCacheLoad should be false for cache path", capture.metadata.get().isInitialCacheLoad());
// And: the metadata contains "lastUpdateTimestamp" with a valid timestamp
- Long lastUpdateTimestamp = receivedMetadata.get().getLastUpdateTimestamp();
- assertNotNull("lastUpdateTimestamp should not be null", lastUpdateTimestamp);
- assertTrue("lastUpdateTimestamp should be valid", lastUpdateTimestamp > 0);
+ assertNotNull("lastUpdateTimestamp should not be null", capture.metadata.get().getLastUpdateTimestamp());
+ assertTrue("lastUpdateTimestamp should be valid", capture.metadata.get().getLastUpdateTimestamp() > 0);
+
+ factory.destroy();
+ }
+
+ /**
+ * Scenario: sdkReady metadata should be preserved for late-registered clients (warm cache)
+ *
+ * Given the SDK is starting with populated persistent storage
+ * And client1 has already emitted SDK_READY
+ * When client2 is created and receives SDK_READY (replay)
+ * Then the metadata should not be null and should reflect cache path values
+ */
+ @Test
+ public void sdkReadyMetadataNotNullWhenMembershipsCompletesLast() throws Exception {
+ populateDatabaseWithCacheData(System.currentTimeMillis());
+ SplitFactory factory = buildFactory(buildConfig());
+
+ SplitClient client1 = factory.client(new Key("key_1"));
+ waitForReady(client1);
+
+ SplitClient client2 = factory.client(new Key("key_2"));
+ EventCapture capture = captureReadyEvent(client2);
+ awaitEvent(capture.latch, "Client2 SDK_READY");
+
+ assertNotNull("Metadata should not be null", capture.metadata.get());
+ assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+ assertFalse("initialCacheLoad should be false for cache path", capture.metadata.get().isInitialCacheLoad());
+ assertNotNull("lastUpdateTimestamp should not be null", capture.metadata.get().getLastUpdateTimestamp());
factory.destroy();
}
@@ -293,28 +276,16 @@ public void sdkReadyListenerReplaysToLateSubscribers() throws Exception {
TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1"));
// When: a new handler H is registered for onReady after SDK_READY has fired
- AtomicInteger onReadyCount = new AtomicInteger(0);
- AtomicReference receivedMetadata = new AtomicReference<>();
- CountDownLatch lateReadyLatch = new CountDownLatch(1);
-
- fixture.client.addEventListener(new SdkEventListener() {
- @Override
- public void onReady(SplitClient client, SdkReadyMetadata metadata) {
- onReadyCount.incrementAndGet();
- receivedMetadata.set(metadata);
- lateReadyLatch.countDown();
- }
- });
+ EventCapture capture = captureReadyEvent(fixture.client);
// Then: onReady handler H is invoked exactly once immediately (replay)
- boolean replayFired = lateReadyLatch.await(5, TimeUnit.SECONDS);
- assertTrue("Late onReady handler should receive replay", replayFired);
- assertEquals("Late onReady handler should be invoked exactly once", 1, onReadyCount.get());
- assertNotNull("Metadata should not be null on replay", receivedMetadata.get());
+ awaitEvent(capture.latch, "Late onReady handler replay", 5);
+ assertFiredOnce(capture.count, "Late onReady handler");
+ assertNotNull("Metadata should not be null on replay", capture.metadata.get());
// And: onReady is not emitted again (verify no additional invocations)
Thread.sleep(500);
- assertEquals("Late handler should not be invoked again", 1, onReadyCount.get());
+ assertFiredOnce(capture.count, "Late handler");
fixture.destroy();
}
@@ -330,32 +301,22 @@ public void onReady(SplitClient client, SdkReadyMetadata metadata) {
@Test
public void sdkReadyViewListenerFiresOnMainThread() throws Exception {
// Given: SDK is starting with populated persistent storage
- long testTimestamp = System.currentTimeMillis();
- populateDatabaseWithCacheData(testTimestamp);
-
- SplitClientConfig config = buildConfig();
- SplitFactory factory = buildFactory(config);
-
- AtomicInteger onReadyViewCount = new AtomicInteger(0);
- CountDownLatch readyViewLatch = new CountDownLatch(1);
-
+ populateDatabaseWithCacheData(System.currentTimeMillis());
+ SplitFactory factory = buildFactory(buildConfig());
SplitClient client = factory.client(new Key("key_1"));
// And: a handler H is registered using addEventListener with onReadyView
- client.addEventListener(new SdkEventListener() {
+ EventCapture capture = new EventCapture<>();
+ client.addEventListener(new SplitEventListener() {
@Override
- public void onReadyView(SplitClient client, SdkReadyMetadata metadata) {
- onReadyViewCount.incrementAndGet();
- readyViewLatch.countDown();
+ public void onReadyView(SplitClient c, SdkReadyMetadata metadata) {
+ capture.capture(metadata);
}
});
- // When: SDK_READY fires
- boolean fired = readyViewLatch.await(10, TimeUnit.SECONDS);
-
// Then: onReadyView is invoked
- assertTrue("onReadyView should fire", fired);
- assertEquals("onReadyView should be invoked exactly once", 1, onReadyViewCount.get());
+ awaitEvent(capture.latch, "onReadyView");
+ assertFiredOnce(capture.count, "onReadyView");
factory.destroy();
}
@@ -377,59 +338,20 @@ public void onReadyView(SplitClient client, SdkReadyMetadata metadata) {
*/
@Test
public void sdkReadyFiresAfterSdkReadyFromCacheAndRequiresSyncCompletion() throws Exception {
- // Given: SDK has not yet emitted sdkReady
- // Use fresh install (no cache) so SDK_READY_FROM_CACHE fires via sync path,
- // then SDK_READY fires after sync completes
- // Database is already empty from setup()
-
- SplitClientConfig config = buildConfig();
- SplitFactory factory = buildFactory(config);
-
- // And: handlers are registered BEFORE creating client to catch all events
- AtomicInteger cacheHandlerCount = new AtomicInteger(0);
- AtomicInteger readyHandlerCount = new AtomicInteger(0);
- CountDownLatch cacheReadyLatch = new CountDownLatch(1);
- CountDownLatch readyLatch = new CountDownLatch(1);
-
+ // Given: SDK has not yet emitted sdkReady (fresh install)
+ SplitFactory factory = buildFactory(buildConfig());
SplitClient client = factory.client(new Key("key_1"));
- // Register handlers immediately
- client.on(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() {
- @Override
- public void onPostExecution(SplitClient client) {
- cacheHandlerCount.incrementAndGet();
- cacheReadyLatch.countDown();
- }
- });
-
- client.on(SplitEvent.SDK_READY, new SplitEventTask() {
- @Override
- public void onPostExecution(SplitClient client) {
- readyHandlerCount.incrementAndGet();
- readyLatch.countDown();
- }
- });
+ // And: handlers are registered to catch all events
+ EventCapture cacheCapture = captureCacheReadyEvent(client);
+ CountDownLatch readyLatch = captureLegacyReadyEvent(client);
- // When: sync completes (happens automatically during initialization)
- // SDK_READY_FROM_CACHE fires via sync path when TARGETING_RULES_SYNC_COMPLETE and MEMBERSHIPS_SYNC_COMPLETE fire
// Wait for SDK_READY_FROM_CACHE first
- boolean cacheFired = cacheReadyLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_READY_FROM_CACHE should fire", cacheFired);
- assertEquals("Cache handler should be invoked once", 1, cacheHandlerCount.get());
+ awaitEvent(cacheCapture.latch, "SDK_READY_FROM_CACHE");
+ assertFiredOnce(cacheCapture.count, "Cache handler");
- // SDK_READY requires both SDK_READY_FROM_CACHE (prerequisite) and sync completion (requireAll)
// Wait for SDK_READY to fire
- boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS);
-
- // Then: sdkReady is emitted exactly once
- assertTrue("SDK_READY should fire after SDK_READY_FROM_CACHE and sync completion. " +
- "Cache fired: " + cacheHandlerCount.get() + ", Ready fired: " + readyHandlerCount.get(),
- readyFired);
- assertEquals("Ready handler should be invoked exactly once", 1, readyHandlerCount.get());
-
- // Verify both events fired
- assertEquals("SDK_READY_FROM_CACHE should fire", 1, cacheHandlerCount.get());
- assertEquals("SDK_READY should fire after SDK_READY_FROM_CACHE", 1, readyHandlerCount.get());
+ awaitEvent(readyLatch, "SDK_READY");
factory.destroy();
}
@@ -448,19 +370,15 @@ public void sdkReadyReplaysToLateSubscribers() throws Exception {
TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1"));
// When: a new handler H is registered for sdkReady
- AtomicInteger lateHandlerCount = new AtomicInteger(0);
- CountDownLatch lateHandlerLatch = new CountDownLatch(1);
-
- registerReadyHandler(fixture.client, lateHandlerCount, lateHandlerLatch);
+ EventCapture capture = captureReadyEvent(fixture.client);
// Then: handler H is invoked exactly once immediately (replay)
- boolean replayFired = lateHandlerLatch.await(5, TimeUnit.SECONDS);
- assertTrue("Late handler should receive replay", replayFired);
- assertEquals("Late handler should be invoked exactly once", 1, lateHandlerCount.get());
+ awaitEvent(capture.latch, "Late handler replay", 5);
+ assertFiredOnce(capture.count, "Late handler");
// And: sdkReady is not emitted again (verify no additional invocations)
Thread.sleep(500);
- assertEquals("Late handler should not be invoked again", 1, lateHandlerCount.get());
+ assertFiredOnce(capture.count, "Late handler");
fixture.destroy();
}
@@ -482,49 +400,27 @@ public void sdkUpdateEmittedOnlyAfterSdkReady() throws Exception {
// Given: Create streaming client but don't wait for SDK_READY
TestClientFixture fixture = createStreamingClient(new Key("key_1"));
- AtomicInteger updateHandlerCount = new AtomicInteger(0);
- AtomicReference receivedMetadata = new AtomicReference<>();
- CountDownLatch readyLatch = new CountDownLatch(1);
- CountDownLatch updateLatch = new CountDownLatch(1);
-
// Register handlers BEFORE SDK_READY fires
- fixture.client.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- updateHandlerCount.incrementAndGet();
- receivedMetadata.set(metadata);
- updateLatch.countDown();
- }
- });
-
- fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() {
- @Override
- public void onPostExecution(SplitClient client) {
- readyLatch.countDown();
- }
- });
+ EventCapture updateCapture = captureUpdateEvent(fixture.client);
+ CountDownLatch readyLatch = captureLegacyReadyEvent(fixture.client);
// Wait a bit to see if SDK_UPDATE fires prematurely (during initial sync)
Thread.sleep(1000);
// Then: sdkUpdate is not emitted because sdkReady has not fired yet
- assertEquals("SDK_UPDATE should not fire before SDK_READY", 0, updateHandlerCount.get());
+ assertEquals("SDK_UPDATE should not fire before SDK_READY", 0, updateCapture.count.get());
// When: SDK_READY fires
- boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_READY should fire", readyFired);
-
- // Wait for SSE connection
+ awaitEvent(readyLatch, "SDK_READY");
fixture.waitForSseConnection();
// When: a new "splitsUpdated" event is notified via SSE (after SDK_READY has fired)
fixture.pushSplitUpdate("2000", "1000");
// Then: sdkUpdate is emitted and handler H is invoked once
- boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_UPDATE should fire after SDK_READY when splits update arrives", updateFired);
- assertEquals("Handler should be invoked exactly once", 1, updateHandlerCount.get());
- assertNotNull("Metadata should not be null", receivedMetadata.get());
+ awaitEvent(updateCapture.latch, "SDK_UPDATE");
+ assertFiredOnce(updateCapture.count, "SDK_UPDATE handler");
+ assertNotNull("Metadata should not be null", updateCapture.metadata.get());
fixture.destroy();
}
@@ -542,27 +438,15 @@ public void sdkUpdateFiresOnAnyDataChangeEventAfterSdkReady() throws Exception {
// Given: sdkReady has already been emitted (with streaming support)
TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1"));
- AtomicInteger updateHandlerCount = new AtomicInteger(0);
- AtomicReference lastMetadata = new AtomicReference<>();
- CountDownLatch updateLatch = new CountDownLatch(1);
-
- fixture.client.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- updateHandlerCount.incrementAndGet();
- lastMetadata.set(metadata);
- updateLatch.countDown();
- }
- });
+ EventCapture capture = captureUpdateEvent(fixture.client);
// When: a split update notification arrives via SSE
fixture.pushSplitUpdate();
// Then: sdkUpdate is emitted and handler H is invoked
- boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_UPDATE should fire after split update notification", updateFired);
- assertEquals("Handler should be invoked once", 1, updateHandlerCount.get());
- assertNotNull("Metadata should not be null", lastMetadata.get());
+ awaitEvent(capture.latch, "SDK_UPDATE");
+ assertFiredOnce(capture.count, "SDK_UPDATE handler");
+ assertNotNull("Metadata should not be null", capture.metadata.get());
fixture.destroy();
}
@@ -586,17 +470,15 @@ public void sdkUpdateDoesNotReplayToLateSubscribers() throws Exception {
TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1"));
AtomicInteger handler1Count = new AtomicInteger(0);
- AtomicInteger handler2Count = new AtomicInteger(0);
CountDownLatch firstUpdateLatch = new CountDownLatch(1);
AtomicReference secondUpdateLatchRef = new AtomicReference<>(null);
// And: a handler H1 is registered for sdkUpdate
- fixture.client.addEventListener(new SdkEventListener() {
+ fixture.client.addEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
handler1Count.incrementAndGet();
firstUpdateLatch.countDown();
- // Count down second latch if it exists (second update)
CountDownLatch secondLatch = secondUpdateLatchRef.get();
if (secondLatch != null) {
secondLatch.countDown();
@@ -605,55 +487,41 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
});
// When: an internal "splitsUpdated" event is notified via SSE
- // Use large change numbers to avoid any edge cases with change number validation
fixture.pushSplitUpdate("2000", "1000");
// Then: sdkUpdate is emitted and handler H1 is invoked once
- boolean firstUpdateFired = firstUpdateLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_UPDATE should fire for H1", firstUpdateFired);
- assertEquals("H1 should be invoked once", 1, handler1Count.get());
+ awaitEvent(firstUpdateLatch, "SDK_UPDATE for H1");
+ assertFiredOnce(handler1Count, "H1");
- // Wait to ensure first update is fully processed and stored
+ // Wait to ensure first update is fully processed
Thread.sleep(1000);
// When: a second handler H2 is registered for sdkUpdate after one sdkUpdate has already fired
CountDownLatch secondUpdateLatch = new CountDownLatch(2);
secondUpdateLatchRef.set(secondUpdateLatch);
- fixture.client.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- handler2Count.incrementAndGet();
- secondUpdateLatch.countDown();
- }
- });
+ EventCapture handler2Capture = captureUpdateEvent(fixture.client);
// Then: H2 does not receive a replay for past sdkUpdate events
Thread.sleep(500);
- assertEquals("H2 should not receive replay", 0, handler2Count.get());
+ assertEquals("H2 should not receive replay", 0, handler2Capture.count.get());
- // Ensure handlers are registered and first update is fully processed before pushing second update
+ // Ensure handlers are registered before pushing second update
Thread.sleep(500);
-
- // Send keep-alive to ensure SSE connection is still active
if (fixture.streamingData != null) {
TestingHelper.pushKeepAlive(fixture.streamingData);
}
- // When: another internal "splitsUpdated" event is notified (with incrementing change number)
- // Use a higher change number to ensure it's accepted after the first update
+ // When: another internal "splitsUpdated" event is notified
fixture.pushSplitUpdate("2001", "2000");
// Then: both H1 and H2 are invoked for that second sdkUpdate
- boolean secondUpdateFired = secondUpdateLatch.await(15, TimeUnit.SECONDS);
- assertTrue("Second SDK_UPDATE should fire. H1 count: " + handler1Count.get() +
- ", H2 count: " + handler2Count.get() +
- ", secondUpdateLatch count: " + secondUpdateLatch.getCount(), secondUpdateFired);
+ awaitEvent(secondUpdateLatch, "Second SDK_UPDATE", 15);
// H1 should now have 2 total invocations (1 from first + 1 from second)
- assertEquals("H1 should have 2 total invocations", 2, handler1Count.get());
+ assertFiredTimes(handler1Count, "H1", 2);
// H2 should have 1 invocation (only from second update, no replay)
- assertEquals("H2 should have 1 invocation (no replay)", 1, handler2Count.get());
+ assertFiredOnce(handler2Capture.count, "H2");
fixture.destroy();
}
@@ -672,81 +540,46 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
*/
@Test
public void sdkReadyTimedOutEmittedWhenReadinessTimeoutElapses() throws Exception {
- // Given: handlers are registered
- // And: the readiness timeout is configured to a short timeout (2 seconds)
- // Use a mock server that delays responses to prevent sync from completing quickly
+ // Given: the readiness timeout is configured to a short timeout (2 seconds)
SplitClientConfig config = SplitClientConfig.builder()
.serviceEndpoints(endpoints())
- .ready(2000) // 2 second timeout
+ .ready(2000)
.featuresRefreshRate(999999)
.segmentsRefreshRate(999999)
.impressionsRefreshRate(999999)
- .syncEnabled(true) // Keep sync enabled but delay responses
+ .syncEnabled(true)
.trafficType("account")
.build();
// Set up mock server to delay responses so sync doesn't complete before timeout
- final Dispatcher delayedDispatcher = new Dispatcher() {
- @Override
- public MockResponse dispatch(RecordedRequest request) {
- final String path = request.getPath();
- if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) {
- // Delay response to prevent sync from completing
- return new MockResponse()
- .setResponseCode(200)
- .setBody(IntegrationHelper.dummyAllSegments())
- .setBodyDelay(5, TimeUnit.SECONDS); // 5 second delay
- } else if (path.contains("/splitChanges")) {
- // Delay response to prevent sync from completing
- long id = mCurSplitReqId++;
- return new MockResponse()
- .setResponseCode(200)
- .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id))
- .setBodyDelay(5, TimeUnit.SECONDS); // 5 second delay
- } else if (path.contains("/testImpressions/bulk")) {
- return new MockResponse().setResponseCode(200);
- }
- return new MockResponse().setResponseCode(404);
- }
- };
- mWebServer.setDispatcher(delayedDispatcher);
+ mWebServer.setDispatcher(createDelayedDispatcher(5));
SplitFactory factory = buildFactory(config);
+ SplitClient client = factory.client(new Key("key_1"));
- AtomicInteger timeoutHandlerCount = new AtomicInteger(0);
- AtomicInteger readyHandlerCount = new AtomicInteger(0);
- CountDownLatch timeoutLatch = new CountDownLatch(1);
- CountDownLatch readyLatch = new CountDownLatch(1);
+ EventCapture timeoutCapture = new EventCapture<>();
+ AtomicInteger readyCount = new AtomicInteger(0);
- SplitClient client = factory.client(new Key("key_1"));
client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() {
@Override
- public void onPostExecution(SplitClient client) {
- timeoutHandlerCount.incrementAndGet();
- timeoutLatch.countDown();
+ public void onPostExecution(SplitClient c) {
+ timeoutCapture.increment();
}
});
-
client.on(SplitEvent.SDK_READY, new SplitEventTask() {
@Override
- public void onPostExecution(SplitClient client) {
- readyHandlerCount.incrementAndGet();
- readyLatch.countDown();
+ public void onPostExecution(SplitClient c) {
+ readyCount.incrementAndGet();
}
});
- // When: the timeout elapses without sdkReady firing (due to delayed responses)
- boolean timeoutFired = timeoutLatch.await(5, TimeUnit.SECONDS);
-
// Then: sdkReadyTimedOut is emitted exactly once
- assertTrue("SDK_READY_TIMED_OUT should fire after timeout. " +
- "Timeout count: " + timeoutHandlerCount.get() + ", Ready count: " + readyHandlerCount.get(),
- timeoutFired);
- assertEquals("Timeout handler should be invoked once", 1, timeoutHandlerCount.get());
+ awaitEvent(timeoutCapture.latch, "SDK_READY_TIMED_OUT", 5);
+ assertFiredOnce(timeoutCapture.count, "Timeout handler");
// And: sdkReady is not emitted (sync didn't complete in time)
Thread.sleep(500);
- assertEquals("SDK_READY should not fire before timeout", 0, readyHandlerCount.get());
+ assertEquals("SDK_READY should not fire before timeout", 0, readyCount.get());
factory.destroy();
}
@@ -765,11 +598,10 @@ public void onPostExecution(SplitClient client) {
*/
@Test
public void sdkReadyTimedOutSuppressedWhenSdkReadyFiresBeforeTimeout() throws Exception {
- // Given: handlers are registered
- // And: the readiness timeout is configured to a longer timeout (10 seconds)
+ // Given: the readiness timeout is configured to a longer timeout (10 seconds)
SplitClientConfig config = SplitClientConfig.builder()
.serviceEndpoints(endpoints())
- .ready(10000) // 10 second timeout
+ .ready(10000)
.featuresRefreshRate(999999)
.segmentsRefreshRate(999999)
.impressionsRefreshRate(999999)
@@ -778,29 +610,25 @@ public void sdkReadyTimedOutSuppressedWhenSdkReadyFiresBeforeTimeout() throws Ex
.build();
SplitFactory factory = buildFactory(config);
- AtomicInteger timeoutHandlerCount = new AtomicInteger(0);
- AtomicInteger readyHandlerCount = new AtomicInteger(0);
- CountDownLatch readyLatch = new CountDownLatch(1);
-
SplitClient client = factory.client(new Key("key_1"));
+
+ AtomicInteger timeoutCount = new AtomicInteger(0);
client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() {
@Override
- public void onPostExecution(SplitClient client) {
- timeoutHandlerCount.incrementAndGet();
+ public void onPostExecution(SplitClient c) {
+ timeoutCount.incrementAndGet();
}
});
- registerReadyHandler(client, readyHandlerCount, readyLatch);
- // When: internal events for sdkReadyFromCache and sdkReady complete before the timeout elapses
- boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS);
+ EventCapture readyCapture = captureReadyEvent(client);
// Then: sdkReady is emitted
- assertTrue("SDK_READY should fire", readyFired);
- assertEquals("Ready handler should be invoked once", 1, readyHandlerCount.get());
+ awaitEvent(readyCapture.latch, "SDK_READY");
+ assertFiredOnce(readyCapture.count, "Ready handler");
// And: sdkReadyTimedOut is not emitted
- Thread.sleep(2000); // Wait a bit to ensure timeout doesn't fire
- assertEquals("SDK_READY_TIMED_OUT should not fire (suppressed)", 0, timeoutHandlerCount.get());
+ Thread.sleep(2000);
+ assertEquals("SDK_READY_TIMED_OUT should not fire (suppressed)", 0, timeoutCount.get());
factory.destroy();
}
@@ -819,26 +647,18 @@ public void onPostExecution(SplitClient client) {
*/
@Test
public void syncCompletionDoesNotTriggerSdkUpdateDuringInitialSync() throws Exception {
- // Given: handlers are registered
- SplitClientConfig config = buildConfig();
- SplitFactory factory = buildFactory(config);
-
- AtomicInteger updateHandlerCount = new AtomicInteger(0);
- AtomicInteger readyHandlerCount = new AtomicInteger(0);
- CountDownLatch readyLatch = new CountDownLatch(1);
-
+ SplitFactory factory = buildFactory(buildConfig());
SplitClient client = factory.client(new Key("key_1"));
- registerUpdateHandler(client, updateHandlerCount, null);
- registerReadyHandler(client, readyHandlerCount, readyLatch);
+
+ EventCapture updateCapture = captureUpdateEvent(client);
+ CountDownLatch readyLatch = captureLegacyReadyEvent(client);
// When: sync completes (happens automatically during initialization)
- // The *_UPDATED events fire before SDK_READY, so SDK_UPDATE shouldn't fire
- boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_READY should fire", readyFired);
+ awaitEvent(readyLatch, "SDK_READY");
// Then: sdkUpdate is NOT emitted because the *_UPDATED events were notified before sdkReady fired
Thread.sleep(1000);
- assertEquals("SDK_UPDATE should not fire during initial sync", 0, updateHandlerCount.get());
+ assertEquals("SDK_UPDATE should not fire during initial sync", 0, updateCapture.count.get());
factory.destroy();
}
@@ -872,7 +692,7 @@ public void handlersInvokedSequentiallyErrorsIsolated() throws Exception {
// Given: three handlers H1, H2 and H3 are registered for sdkUpdate in that order
// And: H2 throws an exception when invoked
- fixture.client.addEventListener(new SdkEventListener() {
+ fixture.client.addEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
handler1Count.incrementAndGet();
@@ -881,7 +701,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
}
});
- fixture.client.addEventListener(new SdkEventListener() {
+ fixture.client.addEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
handler2Count.incrementAndGet();
@@ -891,7 +711,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
}
});
- fixture.client.addEventListener(new SdkEventListener() {
+ fixture.client.addEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
handler3Count.incrementAndGet();
@@ -943,30 +763,15 @@ public void metadataCorrectlyPropagatedToHandlers() throws Exception {
// Given: sdkReady has already been emitted (with streaming support)
TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1"));
- AtomicInteger updateHandlerCount = new AtomicInteger(0);
- AtomicReference receivedMetadata = new AtomicReference<>();
- CountDownLatch updateLatch = new CountDownLatch(1);
-
- // Given: a handler H is registered for sdkUpdate which inspects the received metadata
- fixture.client.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- updateHandlerCount.incrementAndGet();
- receivedMetadata.set(metadata);
- updateLatch.countDown();
- }
- });
+ EventCapture capture = captureUpdateEvent(fixture.client);
// When: an internal "splitsUpdated" event is notified via SSE
fixture.pushSplitUpdate();
// Then: sdkUpdate is emitted and handler H is invoked once
- boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_UPDATE should fire", updateFired);
- assertEquals("Handler should be invoked exactly once", 1, updateHandlerCount.get());
-
- // And: handler H receives metadata
- assertNotNull("Metadata should not be null", receivedMetadata.get());
+ awaitEvent(capture.latch, "SDK_UPDATE");
+ assertFiredOnce(capture.count, "SDK_UPDATE handler");
+ assertNotNull("Metadata should not be null", capture.metadata.get());
fixture.destroy();
}
@@ -988,29 +793,23 @@ public void destroyingClientStopsEventsAndClearsHandlers() throws Exception {
// Given: sdkReady has already been emitted (with streaming support)
TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1"));
- AtomicInteger handler1Count = new AtomicInteger(0);
- AtomicInteger handler2Count = new AtomicInteger(0);
-
- // Given: a handler H registered for sdkUpdate before destroy
- fixture.client.addEventListener(createOnUpdateListener(handler1Count, null, null));
+ EventCapture handler1 = captureUpdateEvent(fixture.client);
// When: the client is destroyed
fixture.client.destroy();
-
fixture.pushSplitUpdate("3000", "2000");
// Handler H is never invoked (handlers were cleared on destroy)
Thread.sleep(1000);
- assertEquals("Handler H1 should not be invoked after destroy", 0, handler1Count.get());
+ assertEquals("Handler H1 should not be invoked after destroy", 0, handler1.count.get());
// When: registering a new handler H2 for sdkUpdate after destroy
- fixture.client.addEventListener(createOnUpdateListener(handler2Count, null, null));
-
+ EventCapture handler2 = captureUpdateEvent(fixture.client);
fixture.pushSplitUpdate("4000", "3000");
Thread.sleep(1000);
- assertEquals("Handler H1 should still be 0", 0, handler1Count.get());
- assertEquals("Handler H2 should not be invoked after destroy", 0, handler2Count.get());
+ assertEquals("Handler H1 should still be 0", 0, handler1.count.get());
+ assertEquals("Handler H2 should not be invoked after destroy", 0, handler2.count.get());
fixture.destroy();
}
@@ -1032,41 +831,17 @@ public void sdkScopedEventsFanOutToMultipleClients() throws Exception {
// Given: a factory with two clients (with streaming support)
TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("key_A"), new Key("key_B"));
- AtomicInteger handlerACount = new AtomicInteger(0);
- AtomicInteger handlerBCount = new AtomicInteger(0);
- CountDownLatch updateLatchA = new CountDownLatch(1);
- CountDownLatch updateLatchB = new CountDownLatch(1);
-
- // And: handlers HA and HB are registered for sdkUpdate
- fixture.clientA.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- handlerACount.incrementAndGet();
- updateLatchA.countDown();
- }
- });
-
- fixture.clientB.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- handlerBCount.incrementAndGet();
- updateLatchB.countDown();
- }
- });
+ EventCapture captureA = captureUpdateEvent(fixture.mClientA);
+ EventCapture captureB = captureUpdateEvent(fixture.mClientB);
// When: a SDK-scoped internal "splitsUpdated" event is notified via SSE
fixture.pushSplitUpdate();
// Then: sdkUpdate is emitted once per client
- boolean updateAFired = updateLatchA.await(10, TimeUnit.SECONDS);
- boolean updateBFired = updateLatchB.await(10, TimeUnit.SECONDS);
-
- assertTrue("SDK_UPDATE should fire for ClientA", updateAFired);
- assertTrue("SDK_UPDATE should fire for ClientB", updateBFired);
-
- // And: handler HA is invoked once and handler HB is invoked once
- assertEquals("Handler A should be invoked once", 1, handlerACount.get());
- assertEquals("Handler B should be invoked once", 1, handlerBCount.get());
+ awaitEvent(captureA.latch, "SDK_UPDATE for ClientA");
+ awaitEvent(captureB.latch, "SDK_UPDATE for ClientB");
+ assertFiredOnce(captureA.count, "Handler A");
+ assertFiredOnce(captureB.count, "Handler B");
fixture.destroy();
}
@@ -1086,39 +861,17 @@ public void clientScopedEventsDoNotFanOutToOtherClients() throws Exception {
// Given: a factory with two clients (with streaming support)
TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("userA"), new Key("userB"));
- AtomicInteger handlerACount = new AtomicInteger(0);
- AtomicInteger handlerBCount = new AtomicInteger(0);
- CountDownLatch updateLatchA = new CountDownLatch(1);
- CountDownLatch updateLatchB = new CountDownLatch(1);
-
- // And: handlers HA and HB are registered for sdkUpdate
- fixture.clientA.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- handlerACount.incrementAndGet();
- updateLatchA.countDown();
- }
- });
-
- fixture.clientB.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- handlerBCount.incrementAndGet();
- updateLatchB.countDown();
- }
- });
+ EventCapture captureA = captureUpdateEvent(fixture.mClientA);
+ EventCapture captureB = captureUpdateEvent(fixture.mClientB);
// When: a SDK-scoped split update notification arrives (affects all clients)
fixture.pushSplitUpdate();
// Then: both clients receive SDK_UPDATE since splitsUpdated is SDK-scoped
- boolean updateAFired = updateLatchA.await(10, TimeUnit.SECONDS);
- boolean updateBFired = updateLatchB.await(10, TimeUnit.SECONDS);
-
- assertTrue("SDK_UPDATE should fire for ClientA", updateAFired);
- assertTrue("SDK_UPDATE should fire for ClientB", updateBFired);
- assertEquals("Handler A should be invoked once", 1, handlerACount.get());
- assertEquals("Handler B should be invoked once", 1, handlerBCount.get());
+ awaitEvent(captureA.latch, "SDK_UPDATE for ClientA");
+ awaitEvent(captureB.latch, "SDK_UPDATE for ClientB");
+ assertFiredOnce(captureA.count, "Handler A");
+ assertFiredOnce(captureB.count, "Handler B");
fixture.destroy();
}
@@ -1137,28 +890,16 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
public void sdkUpdateMetadataContainsTypeForFlagsUpdate() throws Exception {
TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1"));
- AtomicReference receivedMetadata = new AtomicReference<>();
- CountDownLatch updateLatch = new CountDownLatch(1);
-
- fixture.client.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- receivedMetadata.set(metadata);
- updateLatch.countDown();
- }
- });
+ EventCapture capture = captureUpdateEvent(fixture.client);
fixture.pushSplitUpdate();
- boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_UPDATE should fire", updateFired);
-
- assertNotNull("Metadata should not be null", receivedMetadata.get());
+ awaitEvent(capture.latch, "SDK_UPDATE");
+ assertNotNull("Metadata should not be null", capture.metadata.get());
assertEquals("Type should be FLAGS_UPDATE",
- SdkUpdateMetadata.Type.FLAGS_UPDATE, receivedMetadata.get().getType());
-
- assertNotNull("Names should not be null", receivedMetadata.get().getNames());
- assertFalse("Names should not be empty", receivedMetadata.get().getNames().isEmpty());
+ SdkUpdateMetadata.Type.FLAGS_UPDATE, capture.metadata.get().getType());
+ assertNotNull("Names should not be null", capture.metadata.get().getNames());
+ assertFalse("Names should not be empty", capture.metadata.get().getNames().isEmpty());
fixture.destroy();
}
@@ -1179,28 +920,16 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
public void sdkUpdateMetadataContainsTypeForSegmentsUpdate() throws Exception {
TestClientFixture fixture = createStreamingClientWithRbsAndWaitForReady(new Key("key_1"));
- AtomicReference receivedMetadata = new AtomicReference<>();
- CountDownLatch updateLatch = new CountDownLatch(1);
-
- fixture.client.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
- receivedMetadata.set(metadata);
- updateLatch.countDown();
- }
- });
+ EventCapture capture = captureUpdateEvent(fixture.client);
fixture.pushRbsUpdate();
- boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS);
- assertTrue("SDK_UPDATE should fire for RBS update", updateFired);
-
- assertNotNull("Metadata should not be null", receivedMetadata.get());
+ awaitEvent(capture.latch, "SDK_UPDATE for RBS");
+ assertNotNull("Metadata should not be null", capture.metadata.get());
assertEquals("Type should be SEGMENTS_UPDATE",
- SdkUpdateMetadata.Type.SEGMENTS_UPDATE, receivedMetadata.get().getType());
-
- assertNotNull("Names should not be null", receivedMetadata.get().getNames());
- assertTrue("Names should be empty for SEGMENTS_UPDATE", receivedMetadata.get().getNames().isEmpty());
+ SdkUpdateMetadata.Type.SEGMENTS_UPDATE, capture.metadata.get().getType());
+ assertNotNull("Names should not be null", capture.metadata.get().getNames());
+ assertTrue("Names should be empty for SEGMENTS_UPDATE", capture.metadata.get().getNames().isEmpty());
fixture.destroy();
}
@@ -1217,45 +946,14 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
*/
@Test
public void sdkUpdateFiresOnlyOnceWhenBothFlagsAndRbsChange() throws Exception {
- // Track number of /splitChanges calls
AtomicInteger splitChangesHitCount = new AtomicInteger(0);
- final Dispatcher pollingDispatcher = new Dispatcher() {
- @Override
- public MockResponse dispatch(RecordedRequest request) {
- final String path = request.getPath();
- if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) {
- return new MockResponse().setResponseCode(200).setBody(IntegrationHelper.dummyAllSegments());
- } else if (path.contains("/splitChanges")) {
- int count = splitChangesHitCount.incrementAndGet();
- if (count <= 1) {
- // Initial sync: empty
- return new MockResponse().setResponseCode(200)
- .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000));
- } else {
- // Polling sync: return BOTH flag and RBS changes
- // s and t must be equal to signal end of sync loop
- String responseWithBothChanges = "{\"ff\":{\"s\":2000,\"t\":2000,\"d\":[" +
- "{\"trafficTypeName\":\"user\",\"name\":\"test_split\",\"status\":\"ACTIVE\"," +
- "\"killed\":false,\"defaultTreatment\":\"off\",\"changeNumber\":2000," +
- "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\"," +
- "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}," +
- "\"partitions\":[{\"treatment\":\"on\",\"size\":100}]}]}" +
- "]},\"rbs\":{\"s\":2000,\"t\":2000,\"d\":[" +
- "{\"name\":\"test_rbs\",\"status\":\"ACTIVE\",\"trafficTypeName\":\"user\"," +
- "\"excluded\":{\"keys\":[],\"segments\":[]}," +
- "\"conditions\":[{\"matcherGroup\":{\"combiner\":\"AND\"," +
- "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}}]}" +
- "]}}";
- return new MockResponse().setResponseCode(200).setBody(responseWithBothChanges);
- }
- } else if (path.contains("/testImpressions/bulk")) {
- return new MockResponse().setResponseCode(200);
- }
- return new MockResponse().setResponseCode(404);
- }
- };
- mWebServer.setDispatcher(pollingDispatcher);
+ mWebServer.setDispatcher(createPollingDispatcher(
+ count -> count <= 1
+ ? IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)
+ : IntegrationHelper.targetingRulesChangesWithFlagAndRbs("test_split", "test_rbs", 2000),
+ count -> IntegrationHelper.dummyAllSegments()
+ ));
// Use polling mode with short refresh rate to trigger sync quickly
SplitClientConfig config = new TestableSplitConfigBuilder()
@@ -1285,7 +983,7 @@ public void onPostExecution(SplitClient c) {
List receivedMetadataList = new ArrayList<>();
CountDownLatch updateLatch = new CountDownLatch(1);
- client.addEventListener(new SdkEventListener() {
+ client.addEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) {
synchronized (receivedMetadataList) {
@@ -1329,18 +1027,183 @@ public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) {
@Test
public void sdkUpdateMetadataContainsTypeForMembershipSegmentsUpdate() throws Exception {
verifySdkUpdateForSegmentsPollingWithEmptyNames(
- // Initial sync: segment1, segment2
- "{\"ms\":{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":1000},\"ls\":{\"k\":[],\"cn\":1000}}",
- // Polling: segment1 removed, segment3 added
- "{\"ms\":{\"k\":[{\"n\":\"segment2\"},{\"n\":\"segment3\"}],\"cn\":2000},\"ls\":{\"k\":[],\"cn\":1000}}"
+ IntegrationHelper.membershipsResponse(new String[]{"segment1", "segment2"}, 1000),
+ IntegrationHelper.membershipsResponse(new String[]{"segment2", "segment3"}, 2000)
);
}
/**
- * Scenario: sdkUpdateMetadata contains Type.SEGMENTS_UPDATE for large segments update (polling)
+ * Scenario: sdkUpdateMetadata includes flag names for polling flag updates
*
- * Given sdkReady has already been emitted
- * And a handler H is registered for sdkUpdate
+ * Given sdkReady has already been emitted in polling mode
+ * When polling returns a flag update
+ * Then sdkUpdate metadata contains FLAGS_UPDATE with non-empty names
+ */
+ @Test
+ public void sdkUpdateMetadataContainsNamesForPollingFlagsUpdate() throws Exception {
+ mWebServer.setDispatcher(createPollingDispatcher(
+ count -> count <= 1
+ ? IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)
+ : IntegrationHelper.targetingRulesChangesWithFlag("polling_flag", 2000),
+ count -> IntegrationHelper.dummyAllSegments()
+ ));
+
+ SplitClientConfig config = new TestableSplitConfigBuilder()
+ .serviceEndpoints(endpoints())
+ .ready(30000)
+ .featuresRefreshRate(3)
+ .segmentsRefreshRate(999999)
+ .impressionsRefreshRate(999999)
+ .streamingEnabled(false)
+ .trafficType("account")
+ .build();
+
+ SplitFactory factory = buildFactory(config);
+ SplitClient client = factory.client();
+
+ CountDownLatch readyLatch = new CountDownLatch(1);
+ client.on(SplitEvent.SDK_READY, new SplitEventTask() {
+ @Override
+ public void onPostExecution(SplitClient c) {
+ readyLatch.countDown();
+ }
+ });
+ assertTrue("SDK_READY should fire", readyLatch.await(10, TimeUnit.SECONDS));
+
+ AtomicReference receivedMetadata = new AtomicReference<>();
+ CountDownLatch updateLatch = new CountDownLatch(1);
+ client.addEventListener(new SplitEventListener() {
+ @Override
+ public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) {
+ receivedMetadata.set(metadata);
+ updateLatch.countDown();
+ }
+ });
+
+ assertTrue("SDK_UPDATE should fire", updateLatch.await(15, TimeUnit.SECONDS));
+ assertNotNull("Metadata should not be null", receivedMetadata.get());
+ assertEquals("Type should be FLAGS_UPDATE",
+ SdkUpdateMetadata.Type.FLAGS_UPDATE, receivedMetadata.get().getType());
+ assertNotNull("Names should not be null", receivedMetadata.get().getNames());
+ assertTrue("Names should include polling_flag", receivedMetadata.get().getNames().contains("polling_flag"));
+
+ factory.destroy();
+ }
+
+ /**
+ * Scenario: sdkReady should include non-null metadata on fresh install
+ *
+ * Given the SDK starts with empty storage (fresh install)
+ * When SDK_READY fires
+ * Then metadata should be present (initialCacheLoad=true, lastUpdateTimestamp=null)
+ */
+ @Test
+ public void sdkReadyMetadataNotNullOnFreshInstall() throws Exception {
+ SplitFactory factory = buildFactory(buildConfig());
+ SplitClient client = factory.client(new Key("key_1"));
+
+ EventCapture capture = captureReadyEvent(client);
+
+ awaitEvent(capture.latch, "SDK_READY");
+ assertNotNull("Metadata should not be null", capture.metadata.get());
+ assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+ assertTrue("initialCacheLoad should be true for fresh install", capture.metadata.get().isInitialCacheLoad());
+ assertEquals("lastUpdateTimestamp should be null for fresh install",
+ null, capture.metadata.get().getLastUpdateTimestamp());
+
+ factory.destroy();
+ }
+
+ /**
+ * Scenario: sdkUpdateMetadata should include SEGMENTS_UPDATE when only one client changes (polling)
+ *
+ * Given two clients are created in polling mode
+ * And only client1 receives a membership change on polling
+ * When polling updates occur
+ * Then only client1 receives SDK_UPDATE with SEGMENTS_UPDATE metadata
+ */
+ @Test
+ public void sdkUpdateMetadataForSingleClientMembershipPolling() throws Exception {
+ AtomicInteger key1MembershipHits = new AtomicInteger(0);
+ final String initialMemberships = IntegrationHelper.membershipsResponse(new String[]{"segment1"}, 1000);
+ final String updatedMemberships = IntegrationHelper.membershipsResponse(new String[]{"segment2"}, 2000);
+
+ mWebServer.setDispatcher(new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest request) {
+ final String path = request.getPath();
+ if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) {
+ if (path.contains("key_1")) {
+ int count = key1MembershipHits.incrementAndGet();
+ return new MockResponse().setResponseCode(200)
+ .setBody(count <= 1 ? initialMemberships : updatedMemberships);
+ }
+ return new MockResponse().setResponseCode(200).setBody(initialMemberships);
+ } else if (path.contains("/splitChanges")) {
+ return new MockResponse().setResponseCode(200)
+ .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000));
+ } else if (path.contains("/testImpressions/bulk")) {
+ return new MockResponse().setResponseCode(200);
+ }
+ return new MockResponse().setResponseCode(404);
+ }
+ });
+
+ SplitFactory factory = buildFactory(createPollingConfig(999999, 3));
+ SplitClient client1 = factory.client(new Key("key_1"));
+ SplitClient client2 = factory.client(new Key("key_2"));
+
+ EventCapture client1Capture = captureUpdateEvent(client1);
+ EventCapture client2Capture = captureUpdateEvent(client2);
+
+ waitForReady(client1);
+ waitForReady(client2);
+
+ awaitEvent(client1Capture.latch, "Client1 SDK_UPDATE", 20);
+ assertNotNull("Client1 metadata should not be null", client1Capture.metadata.get());
+ assertEquals("Type should be SEGMENTS_UPDATE",
+ SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Capture.metadata.get().getType());
+
+ Thread.sleep(1000);
+ assertEquals("Client2 should not receive SDK_UPDATE", 0, client2Capture.count.get());
+
+ factory.destroy();
+ }
+
+ /**
+ * Scenario: sdkUpdateMetadata contains SEGMENTS_UPDATE when only one streaming client changes
+ *
+ * Given two clients are created with streaming enabled
+ * And a membership keylist update targets only client1
+ * When the SSE notification is pushed
+ * Then only client1 receives SDK_UPDATE with SEGMENTS_UPDATE metadata
+ */
+ @Test
+ public void sdkUpdateMetadataForSingleClientMembershipStreaming() throws Exception {
+ TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("key1"), new Key("key2"));
+
+ EventCapture client1Capture = captureUpdateEvent(fixture.mClientA);
+ EventCapture client2Capture = captureUpdateEvent(fixture.mClientB);
+
+ // Keylist update: only key1 is included
+ fixture.pushMembershipKeyListUpdate("key1", "streaming_segment");
+
+ awaitEvent(client1Capture.latch, "Client1 SDK_UPDATE");
+ assertNotNull("Client1 metadata should not be null", client1Capture.metadata.get());
+ assertEquals("Type should be SEGMENTS_UPDATE",
+ SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Capture.metadata.get().getType());
+
+ Thread.sleep(500);
+ assertEquals("Client2 should not receive SDK_UPDATE", 0, client2Capture.count.get());
+
+ fixture.destroy();
+ }
+
+ /**
+ * Scenario: sdkUpdateMetadata contains Type.SEGMENTS_UPDATE for large segments update (polling)
+ *
+ * Given sdkReady has already been emitted
+ * And a handler H is registered for sdkUpdate
* When large segments change via polling (server returns different large segments)
* Then sdkUpdate is emitted
* And handler H receives metadata with getType() returning Type.SEGMENTS_UPDATE
@@ -1349,10 +1212,8 @@ public void sdkUpdateMetadataContainsTypeForMembershipSegmentsUpdate() throws Ex
@Test
public void sdkUpdateMetadataContainsTypeForLargeSegmentsUpdate() throws Exception {
verifySdkUpdateForSegmentsPollingWithEmptyNames(
- // Initial sync: large_segment1, large_segment2
- "{\"ms\":{\"k\":[],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment1\"},{\"n\":\"large_segment2\"}],\"cn\":1000}}",
- // Polling: large_segment1 removed, large_segment3 added
- "{\"ms\":{\"k\":[],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment2\"},{\"n\":\"large_segment3\"}],\"cn\":2000}}"
+ IntegrationHelper.membershipsResponse(new String[]{}, 1000L, new String[]{"large_segment1", "large_segment2"}, 1000L),
+ IntegrationHelper.membershipsResponse(new String[]{}, 1000L, new String[]{"large_segment2", "large_segment3"}, 2000L)
);
}
@@ -1367,10 +1228,12 @@ public void sdkUpdateMetadataContainsTypeForLargeSegmentsUpdate() throws Excepti
*/
@Test
public void twoDistinctSdkUpdateEventsWhenBothSegmentsAndLargeSegmentsChange() throws Exception {
- // Initial sync: segment1, segment2 in ms; large_segment1, large_segment2 in ls
- String initialResponse = "{\"ms\":{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment1\"},{\"n\":\"large_segment2\"}],\"cn\":1000}}";
- // Polling: both ms and ls change
- String pollingResponse = "{\"ms\":{\"k\":[{\"n\":\"segment2\"},{\"n\":\"segment3\"}],\"cn\":2000},\"ls\":{\"k\":[{\"n\":\"large_segment2\"},{\"n\":\"large_segment3\"}],\"cn\":2000}}";
+ String initialResponse = IntegrationHelper.membershipsResponse(
+ new String[]{"segment1", "segment2"}, 1000L,
+ new String[]{"large_segment1", "large_segment2"}, 1000L);
+ String pollingResponse = IntegrationHelper.membershipsResponse(
+ new String[]{"segment2", "segment3"}, 2000L,
+ new String[]{"large_segment2", "large_segment3"}, 2000L);
List metadataList = waitForSegmentsPollingUpdates(initialResponse, pollingResponse, 2);
@@ -1470,7 +1333,7 @@ public void onPostExecution(SplitClient c) {
CountDownLatch updateLatch = new CountDownLatch(expectedEventCount * 2);
// Register new API handler (addEventListener)
- client.addEventListener(new SdkEventListener() {
+ client.addEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) {
synchronized (receivedMetadataList) {
@@ -1509,7 +1372,7 @@ public void onPostExecution(SplitClient c) {
* Scenario: Multiple listeners with onUpdate are both invoked
*
* Given sdkReady has already been emitted
- * And two different SdkEventListener instances (L1 and L2) with onUpdate handlers are registered
+ * And two different SplitEventListener instances (L1 and L2) with onUpdate handlers are registered
* When a split update notification arrives via SSE
* Then SDK_UPDATE is emitted once
* And both L1.onUpdate and L2.onUpdate are invoked exactly once each
@@ -1518,22 +1381,17 @@ public void onPostExecution(SplitClient c) {
public void multipleListenersWithOnUpdateBothInvoked() throws Exception {
TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1"));
- AtomicInteger listener1Count = new AtomicInteger(0);
- AtomicInteger listener2Count = new AtomicInteger(0);
- AtomicReference listener1Metadata = new AtomicReference<>();
- AtomicReference listener2Metadata = new AtomicReference<>();
- CountDownLatch updateLatch = new CountDownLatch(2);
-
- fixture.client.addEventListener(createOnUpdateListener(listener1Count, listener1Metadata, updateLatch));
- fixture.client.addEventListener(createOnUpdateListener(listener2Count, listener2Metadata, updateLatch));
+ EventCapture capture1 = captureUpdateEvent(fixture.client);
+ EventCapture capture2 = captureUpdateEvent(fixture.client);
fixture.pushSplitUpdate();
- assertTrue("Both listeners should be invoked", updateLatch.await(10, TimeUnit.SECONDS));
- assertEquals("Listener 1 should be invoked exactly once", 1, listener1Count.get());
- assertEquals("Listener 2 should be invoked exactly once", 1, listener2Count.get());
- assertNotNull("Listener 1 should receive metadata", listener1Metadata.get());
- assertNotNull("Listener 2 should receive metadata", listener2Metadata.get());
+ awaitEvent(capture1.latch, "Listener 1 SDK_UPDATE");
+ awaitEvent(capture2.latch, "Listener 2 SDK_UPDATE");
+ assertFiredOnce(capture1.count, "Listener 1");
+ assertFiredOnce(capture2.count, "Listener 2");
+ assertNotNull("Listener 1 should receive metadata", capture1.metadata.get());
+ assertNotNull("Listener 2 should receive metadata", capture2.metadata.get());
fixture.destroy();
}
@@ -1542,7 +1400,7 @@ public void multipleListenersWithOnUpdateBothInvoked() throws Exception {
* Scenario: Multiple listeners with onReady are both invoked
*
* Given the SDK is starting
- * And two different SdkEventListener instances (L1 and L2) with onReady handlers are registered
+ * And two different SplitEventListener instances (L1 and L2) with onReady handlers are registered
* When SDK_READY fires
* Then both L1.onReady and L2.onReady are invoked exactly once each
* And both receive SdkReadyMetadata
@@ -1553,20 +1411,15 @@ public void multipleListenersWithOnReadyBothInvoked() throws Exception {
SplitFactory factory = buildFactory(buildConfig());
SplitClient client = factory.client(new Key("key_1"));
- AtomicInteger listener1Count = new AtomicInteger(0);
- AtomicInteger listener2Count = new AtomicInteger(0);
- AtomicReference listener1Metadata = new AtomicReference<>();
- AtomicReference listener2Metadata = new AtomicReference<>();
- CountDownLatch readyLatch = new CountDownLatch(2);
+ EventCapture capture1 = captureReadyEvent(client);
+ EventCapture capture2 = captureReadyEvent(client);
- client.addEventListener(createOnReadyListener(listener1Count, listener1Metadata, readyLatch));
- client.addEventListener(createOnReadyListener(listener2Count, listener2Metadata, readyLatch));
-
- assertTrue("Both listeners should be invoked", readyLatch.await(10, TimeUnit.SECONDS));
- assertEquals("Listener 1 should be invoked exactly once", 1, listener1Count.get());
- assertEquals("Listener 2 should be invoked exactly once", 1, listener2Count.get());
- assertNotNull("Listener 1 should receive metadata", listener1Metadata.get());
- assertNotNull("Listener 2 should receive metadata", listener2Metadata.get());
+ awaitEvent(capture1.latch, "Listener 1 SDK_READY");
+ awaitEvent(capture2.latch, "Listener 2 SDK_READY");
+ assertFiredOnce(capture1.count, "Listener 1");
+ assertFiredOnce(capture2.count, "Listener 2");
+ assertNotNull("Listener 1 should receive metadata", capture1.metadata.get());
+ assertNotNull("Listener 2 should receive metadata", capture2.metadata.get());
factory.destroy();
}
@@ -1575,8 +1428,8 @@ public void multipleListenersWithOnReadyBothInvoked() throws Exception {
* Scenario: Listeners with different callbacks (onReady and onUpdate) each invoked on correct event
*
* Given the SDK is starting
- * And a SdkEventListener L1 with onReady handler is registered
- * And a SdkEventListener L2 with onUpdate handler is registered
+ * And a SplitEventListener L1 with onReady handler is registered
+ * And a SplitEventListener L2 with onUpdate handler is registered
* When SDK_READY fires
* Then L1.onReady is invoked
* And L2.onUpdate is NOT invoked (wrong event type)
@@ -1588,24 +1441,19 @@ public void multipleListenersWithOnReadyBothInvoked() throws Exception {
public void listenersWithDifferentCallbacksInvokedOnCorrectEventType() throws Exception {
TestClientFixture fixture = createStreamingClient(new Key("key_1"));
- AtomicInteger onReadyCount = new AtomicInteger(0);
- AtomicInteger onUpdateCount = new AtomicInteger(0);
- CountDownLatch readyLatch = new CountDownLatch(1);
- CountDownLatch updateLatch = new CountDownLatch(1);
+ EventCapture readyCapture = captureReadyEvent(fixture.client);
+ EventCapture updateCapture = captureUpdateEvent(fixture.client);
- fixture.client.addEventListener(createOnReadyListener(onReadyCount, null, readyLatch));
- fixture.client.addEventListener(createOnUpdateListener(onUpdateCount, null, updateLatch));
-
- assertTrue("SDK_READY should fire", readyLatch.await(10, TimeUnit.SECONDS));
- assertEquals("onReady should be invoked exactly once", 1, onReadyCount.get());
- assertEquals("onUpdate should NOT be invoked on SDK_READY", 0, onUpdateCount.get());
+ awaitEvent(readyCapture.latch, "SDK_READY");
+ assertFiredOnce(readyCapture.count, "onReady");
+ assertEquals("onUpdate should NOT be invoked on SDK_READY", 0, updateCapture.count.get());
fixture.waitForSseConnection();
fixture.pushSplitUpdate();
- assertTrue("SDK_UPDATE should fire", updateLatch.await(10, TimeUnit.SECONDS));
- assertEquals("onUpdate should be invoked exactly once", 1, onUpdateCount.get());
- assertEquals("onReady should still be 1 (not invoked again)", 1, onReadyCount.get());
+ awaitEvent(updateCapture.latch, "SDK_UPDATE");
+ assertFiredOnce(updateCapture.count, "onUpdate");
+ assertFiredOnce(readyCapture.count, "onReady (not invoked again)");
fixture.destroy();
}
@@ -1614,7 +1462,7 @@ public void listenersWithDifferentCallbacksInvokedOnCorrectEventType() throws Ex
* Scenario: Multiple listeners with both onReady and onUpdate in same listener
*
* Given the SDK is starting
- * And two SdkEventListener instances (L1 and L2) each with both onReady and onUpdate handlers
+ * And two SplitEventListener instances (L1 and L2) each with both onReady and onUpdate handlers
* When SDK_READY fires
* Then both L1.onReady and L2.onReady are invoked exactly once each
* And neither L1.onUpdate nor L2.onUpdate are invoked
@@ -1625,30 +1473,30 @@ public void listenersWithDifferentCallbacksInvokedOnCorrectEventType() throws Ex
public void multipleListenersWithBothReadyAndUpdateHandlers() throws Exception {
TestClientFixture fixture = createStreamingClient(new Key("key_1"));
- AtomicInteger listener1ReadyCount = new AtomicInteger(0);
- AtomicInteger listener1UpdateCount = new AtomicInteger(0);
- AtomicInteger listener2ReadyCount = new AtomicInteger(0);
- AtomicInteger listener2UpdateCount = new AtomicInteger(0);
+ AtomicInteger l1ReadyCount = new AtomicInteger(0);
+ AtomicInteger l1UpdateCount = new AtomicInteger(0);
+ AtomicInteger l2ReadyCount = new AtomicInteger(0);
+ AtomicInteger l2UpdateCount = new AtomicInteger(0);
CountDownLatch readyLatch = new CountDownLatch(2);
CountDownLatch updateLatch = new CountDownLatch(2);
- fixture.client.addEventListener(createDualListener(listener1ReadyCount, readyLatch, listener1UpdateCount, updateLatch));
- fixture.client.addEventListener(createDualListener(listener2ReadyCount, readyLatch, listener2UpdateCount, updateLatch));
+ fixture.client.addEventListener(createDualListener(l1ReadyCount, readyLatch, l1UpdateCount, updateLatch));
+ fixture.client.addEventListener(createDualListener(l2ReadyCount, readyLatch, l2UpdateCount, updateLatch));
- assertTrue("Both onReady handlers should be invoked", readyLatch.await(10, TimeUnit.SECONDS));
- assertEquals("Listener 1 onReady should be invoked once", 1, listener1ReadyCount.get());
- assertEquals("Listener 2 onReady should be invoked once", 1, listener2ReadyCount.get());
- assertEquals("Listener 1 onUpdate should NOT be invoked on SDK_READY", 0, listener1UpdateCount.get());
- assertEquals("Listener 2 onUpdate should NOT be invoked on SDK_READY", 0, listener2UpdateCount.get());
+ awaitEvent(readyLatch, "Both onReady handlers");
+ assertFiredOnce(l1ReadyCount, "Listener 1 onReady");
+ assertFiredOnce(l2ReadyCount, "Listener 2 onReady");
+ assertEquals("Listener 1 onUpdate should NOT be invoked on SDK_READY", 0, l1UpdateCount.get());
+ assertEquals("Listener 2 onUpdate should NOT be invoked on SDK_READY", 0, l2UpdateCount.get());
fixture.waitForSseConnection();
fixture.pushSplitUpdate();
- assertTrue("Both onUpdate handlers should be invoked", updateLatch.await(10, TimeUnit.SECONDS));
- assertEquals("Listener 1 onUpdate should be invoked once", 1, listener1UpdateCount.get());
- assertEquals("Listener 2 onUpdate should be invoked once", 1, listener2UpdateCount.get());
- assertEquals("Listener 1 onReady should still be 1", 1, listener1ReadyCount.get());
- assertEquals("Listener 2 onReady should still be 1", 1, listener2ReadyCount.get());
+ awaitEvent(updateLatch, "Both onUpdate handlers");
+ assertFiredOnce(l1UpdateCount, "Listener 1 onUpdate");
+ assertFiredOnce(l2UpdateCount, "Listener 2 onUpdate");
+ assertFiredOnce(l1ReadyCount, "Listener 1 onReady (not invoked again)");
+ assertFiredOnce(l2ReadyCount, "Listener 2 onReady (not invoked again)");
fixture.destroy();
}
@@ -1657,8 +1505,8 @@ public void multipleListenersWithBothReadyAndUpdateHandlers() throws Exception {
* Scenario: Multiple listeners with onReady replay to late subscribers
*
* Given SDK_READY has already been emitted
- * And a SdkEventListener L1 with onReady was registered before SDK_READY and was invoked
- * When a new SdkEventListener L2 with onReady is registered after SDK_READY has fired
+ * And a SplitEventListener L1 with onReady was registered before SDK_READY and was invoked
+ * When a new SplitEventListener L2 with onReady is registered after SDK_READY has fired
* Then L2.onReady is invoked (replay)
* And L1.onReady is NOT invoked again
*/
@@ -1666,29 +1514,20 @@ public void multipleListenersWithBothReadyAndUpdateHandlers() throws Exception {
public void multipleListenersWithOnReadyReplayToLateSubscribers() throws Exception {
TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1"));
- AtomicInteger listener1Count = new AtomicInteger(0);
- AtomicInteger listener2Count = new AtomicInteger(0);
- CountDownLatch listener1Latch = new CountDownLatch(1);
- CountDownLatch listener2Latch = new CountDownLatch(1);
-
- fixture.client.addEventListener(createOnReadyListener(listener1Count, null, listener1Latch));
- assertTrue("Listener 1 should receive replay", listener1Latch.await(5, TimeUnit.SECONDS));
- assertEquals("Listener 1 should be invoked once (replay)", 1, listener1Count.get());
+ EventCapture capture1 = captureReadyEvent(fixture.client);
+ awaitEvent(capture1.latch, "Listener 1 replay", 5);
+ assertFiredOnce(capture1.count, "Listener 1 (replay)");
- fixture.client.addEventListener(createOnReadyListener(listener2Count, null, listener2Latch));
- assertTrue("Listener 2 should receive replay", listener2Latch.await(5, TimeUnit.SECONDS));
- assertEquals("Listener 2 should be invoked once (replay)", 1, listener2Count.get());
+ EventCapture capture2 = captureReadyEvent(fixture.client);
+ awaitEvent(capture2.latch, "Listener 2 replay", 5);
+ assertFiredOnce(capture2.count, "Listener 2 (replay)");
Thread.sleep(500);
- assertEquals("Listener 1 should still be 1 (not invoked again)", 1, listener1Count.get());
+ assertFiredOnce(capture1.count, "Listener 1 (not invoked again)");
fixture.destroy();
}
- /**
- * Creates a client and waits for SDK_READY to fire.
- * Returns a TestClientFixture containing the factory, client, and ready latch.
- */
private TestClientFixture createClientAndWaitForReady(SplitClientConfig config, Key key) throws InterruptedException {
SplitFactory factory = buildFactory(config);
SplitClient client = factory.client(key);
@@ -1707,17 +1546,12 @@ public void onPostExecution(SplitClient client) {
return new TestClientFixture(factory, client, readyLatch);
}
- /**
- * Creates a client with default config and waits for SDK_READY.
- */
private TestClientFixture createClientAndWaitForReady(Key key) throws InterruptedException {
return createClientAndWaitForReady(buildConfig(), key);
}
/**
* Creates a client with streaming enabled but does NOT wait for SDK_READY.
- * Useful for tests that need to register handlers before SDK_READY fires.
- * Returns a fixture that can push SSE messages to trigger SDK_UPDATE.
*/
private TestClientFixture createStreamingClient(Key key) throws IOException {
BlockingQueue streamingData = new LinkedBlockingDeque<>();
@@ -1740,10 +1574,6 @@ private TestClientFixture createStreamingClient(Key key) throws IOException {
return new TestClientFixture(factory, client, null, streamingData, sseLatch);
}
- /**
- * Creates a client with streaming enabled and waits for SDK_READY.
- * Returns a fixture that can push SSE messages to trigger SDK_UPDATE.
- */
private TestClientFixture createStreamingClientAndWaitForReady(Key key) throws InterruptedException, IOException {
TestClientFixture fixture = createStreamingClient(key);
@@ -1764,9 +1594,6 @@ public void onPostExecution(SplitClient client) {
return new TestClientFixture(fixture.factory, fixture.client, readyLatch, fixture.streamingData, fixture.sseLatch);
}
- /**
- * Creates a standard streaming dispatcher for mock HTTP responses.
- */
private HttpResponseMockDispatcher createStreamingDispatcher(BlockingQueue streamingData, CountDownLatch sseLatch) {
return new HttpResponseMockDispatcher() {
@Override
@@ -1795,9 +1622,6 @@ public HttpStreamResponseMock getStreamResponse(URI uri) {
};
}
- /**
- * Creates two clients with streaming enabled and waits for both to be ready.
- */
private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key keyB) throws InterruptedException, IOException {
BlockingQueue streamingData = new LinkedBlockingDeque<>();
CountDownLatch sseLatch = new CountDownLatch(1);
@@ -1817,16 +1641,11 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key
SplitClient clientA = factory.client(keyA);
SplitClient clientB = factory.client(keyB);
- CountDownLatch readyLatchA = new CountDownLatch(1);
- CountDownLatch readyLatchB = new CountDownLatch(1);
-
- registerReadyHandler(clientA, null, readyLatchA);
- registerReadyHandler(clientB, null, readyLatchB);
+ CountDownLatch readyLatchA = captureLegacyReadyEvent(clientA);
+ CountDownLatch readyLatchB = captureLegacyReadyEvent(clientB);
- boolean readyA = readyLatchA.await(30, TimeUnit.SECONDS);
- boolean readyB = readyLatchB.await(30, TimeUnit.SECONDS);
- assertTrue("ClientA SDK_READY should fire", readyA);
- assertTrue("ClientB SDK_READY should fire", readyB);
+ awaitEvent(readyLatchA, "ClientA SDK_READY", 30);
+ awaitEvent(readyLatchB, "ClientB SDK_READY", 30);
// Wait for SSE connection and send keep-alive
sseLatch.await(10, TimeUnit.SECONDS);
@@ -1835,87 +1654,10 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key
return new TwoClientFixture(factory, clientA, clientB, streamingData);
}
- /**
- * Registers a handler for SDK_READY_FROM_CACHE that captures metadata and counts invocations.
- */
- private void registerCacheReadyHandler(SplitClient client, AtomicInteger count,
- AtomicReference metadata,
- CountDownLatch latch) {
- client.addEventListener(new SdkEventListener() {
- @Override
- public void onReadyFromCache(SplitClient client, SdkReadyMetadata eventMetadata) {
- count.incrementAndGet();
- if (metadata != null) metadata.set(eventMetadata);
- if (latch != null) latch.countDown();
- }
- });
- }
-
- /**
- * Registers a handler for SDK_UPDATE that counts invocations and optionally captures metadata.
- */
- private void registerUpdateHandler(SplitClient client, AtomicInteger count,
- AtomicReference metadata) {
- client.addEventListener(new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) {
- count.incrementAndGet();
- if (metadata != null) metadata.set(eventMetadata);
- }
- });
- }
-
- /**
- * Registers a handler for SDK_READY that counts invocations and optionally counts down a latch.
- */
- private void registerReadyHandler(SplitClient client, AtomicInteger count, CountDownLatch latch) {
- client.on(SplitEvent.SDK_READY, new SplitEventTask() {
- @Override
- public void onPostExecution(SplitClient client) {
- if (count != null) count.incrementAndGet();
- if (latch != null) latch.countDown();
- }
- });
- }
-
- /**
- * Creates a SdkEventListener that counts onReady invocations and captures metadata.
- */
- private SdkEventListener createOnReadyListener(AtomicInteger count,
- AtomicReference metadata,
- CountDownLatch latch) {
- return new SdkEventListener() {
- @Override
- public void onReady(SplitClient client, SdkReadyMetadata eventMetadata) {
- if (count != null) count.incrementAndGet();
- if (metadata != null) metadata.set(eventMetadata);
- if (latch != null) latch.countDown();
- }
- };
- }
-
- /**
- * Creates a SdkEventListener that counts onUpdate invocations and captures metadata.
- */
- private SdkEventListener createOnUpdateListener(AtomicInteger count,
- AtomicReference metadata,
- CountDownLatch latch) {
- return new SdkEventListener() {
- @Override
- public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) {
- if (count != null) count.incrementAndGet();
- if (metadata != null) metadata.set(eventMetadata);
- if (latch != null) latch.countDown();
- }
- };
- }
-
- /**
- * Creates a SdkEventListener with both onReady and onUpdate handlers.
- */
- private SdkEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch,
+ private SplitEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch,
AtomicInteger updateCount, CountDownLatch updateLatch) {
- return new SdkEventListener() {
+ return new SplitEventListener() {
+
@Override
public void onReady(SplitClient client, SdkReadyMetadata metadata) {
if (readyCount != null) readyCount.incrementAndGet();
@@ -1930,7 +1672,6 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
};
}
- private static final String SPLIT_UPDATE_PAYLOAD = "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0=";
/**
* Helper class to hold factory and client together for cleanup.
@@ -1967,13 +1708,15 @@ void waitForSseConnection() throws InterruptedException {
}
void pushSplitUpdate() {
- pushSplitUpdate("9999999999999", "1000");
+ if (streamingData != null) {
+ pushMessage(streamingData, IntegrationHelper.splitChangeV2CompressionType0());
+ }
}
void pushSplitUpdate(String changeNumber, String previousChangeNumber) {
if (streamingData != null) {
pushMessage(streamingData, IntegrationHelper.splitChangeV2(
- changeNumber, previousChangeNumber, "0", SPLIT_UPDATE_PAYLOAD));
+ changeNumber, previousChangeNumber, "0", IntegrationHelper.SPLIT_UPDATE_PAYLOAD_TYPE0));
}
}
@@ -1989,9 +1732,8 @@ void pushRbsUpdate() {
void pushRbsUpdate(String changeNumber, String previousChangeNumber) {
if (streamingData != null) {
- // RBS payload: {"name":"rbs_test","status":"ACTIVE","trafficTypeName":"user","excluded":{"keys":[],"segments":[]},"conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"user"},"matcherType":"ALL_KEYS","negate":false}]}}]}
- String RBS_UPDATE_PAYLOAD = "eyJuYW1lIjoicmJzX3Rlc3QiLCJzdGF0dXMiOiJBQ1RJVkUiLCJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiZXhjbHVkZWQiOnsia2V5cyI6W10sInNlZ21lbnRzIjpbXX0sImNvbmRpdGlvbnMiOlt7Im1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX19XX0=";
- pushMessage(streamingData, IntegrationHelper.rbsChange(changeNumber, previousChangeNumber, RBS_UPDATE_PAYLOAD));
+ pushMessage(streamingData, IntegrationHelper.rbsChange(
+ changeNumber, previousChangeNumber, IntegrationHelper.RBS_UPDATE_PAYLOAD_TYPE0));
}
}
@@ -2004,33 +1746,40 @@ void destroy() {
* Helper class to hold factory and two clients together for cleanup.
*/
private static class TwoClientFixture {
- final SplitFactory factory;
- final SplitClient clientA;
- final SplitClient clientB;
- final BlockingQueue streamingData;
-
- TwoClientFixture(SplitFactory factory, SplitClient clientA, SplitClient clientB) {
- this(factory, clientA, clientB, null);
- }
+ final SplitFactory mFactory;
+ final SplitClient mClientA;
+ final SplitClient mClientB;
+ final BlockingQueue mStreamingData;
TwoClientFixture(SplitFactory factory, SplitClient clientA, SplitClient clientB, BlockingQueue streamingData) {
- this.factory = factory;
- this.clientA = clientA;
- this.clientB = clientB;
- this.streamingData = streamingData;
+ mFactory = factory;
+ mClientA = clientA;
+ mClientB = clientB;
+ mStreamingData = streamingData;
}
void pushSplitUpdate() {
- if (streamingData != null) {
- pushMessage(streamingData, IntegrationHelper.splitChangeV2CompressionType0());
+ if (mStreamingData != null) {
+ pushMessage(mStreamingData, IntegrationHelper.splitChangeV2CompressionType0());
+ }
+ }
+
+ void pushMembershipKeyListUpdate(String key, String segmentName) {
+ if (mStreamingData != null) {
+ pushMessage(mStreamingData, membershipKeyListUpdateMessage(key, segmentName));
}
}
void destroy() {
- factory.destroy();
+ mFactory.destroy();
}
}
+ private static String membershipKeyListUpdateMessage(String key, String segmentName) {
+ MySegmentsV2PayloadDecoder decoder = new MySegmentsV2PayloadDecoder();
+ BigInteger hashedKey = decoder.hashKey(key);
+ return IntegrationHelper.membershipKeyListUpdate(hashedKey, segmentName, 2000);
+ }
private static void pushMessage(BlockingQueue queue, String message) {
try {
queue.put(message + "\n");
@@ -2052,7 +1801,7 @@ private void populateDatabaseWithCacheData(long timestamp) {
entity.setName("split_" + i);
long cn = 1000L + i;
finalChangeNumber = cn;
- entity.setBody(String.format("{\"name\":\"split_%d\", \"changeNumber\": %d}", i, cn));
+ entity.setBody(IntegrationHelper.splitEntityBody("split_" + i, cn));
splitEntities.add(entity);
}
mDatabase.splitDao().insert(splitEntities);
@@ -2062,14 +1811,14 @@ private void populateDatabaseWithCacheData(long timestamp) {
// Populate segments for default key
MySegmentEntity segmentEntity = new MySegmentEntity();
segmentEntity.setUserKey("DEFAULT_KEY");
- segmentEntity.setSegmentList("{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":null}");
+ segmentEntity.setSegmentList(IntegrationHelper.segmentListJson("segment1", "segment2"));
segmentEntity.setUpdatedAt(System.currentTimeMillis() / 1000);
mDatabase.mySegmentDao().update(segmentEntity);
// Populate segments for key_1
MySegmentEntity segmentEntity2 = new MySegmentEntity();
segmentEntity2.setUserKey("key_1");
- segmentEntity2.setSegmentList("{\"k\":[{\"n\":\"segment1\"}],\"cn\":null}");
+ segmentEntity2.setSegmentList(IntegrationHelper.segmentListJson("segment1"));
segmentEntity2.setUpdatedAt(System.currentTimeMillis() / 1000);
mDatabase.mySegmentDao().update(segmentEntity2);
}
@@ -2101,11 +1850,177 @@ public void onPostExecution(SplitClient client) {
return new TestClientFixture(fixture.factory, fixture.client, readyLatch, fixture.streamingData, fixture.sseLatch);
}
- /**
- * Populates the database with RBS change number for instant update testing.
- */
private void populateDatabaseWithRbsData() {
// Set RBS change number so streaming notifications trigger in-place updates
mDatabase.generalInfoDao().update(new GeneralInfoEntity("rbsChangeNumber", 1000L));
}
+
+ private static class EventCapture {
+ final AtomicInteger count = new AtomicInteger(0);
+ final AtomicReference metadata = new AtomicReference<>();
+ final CountDownLatch latch;
+
+ EventCapture() {
+ this(1);
+ }
+
+ EventCapture(int expectedCount) {
+ this.latch = new CountDownLatch(expectedCount);
+ }
+
+ void capture(M meta) {
+ count.incrementAndGet();
+ metadata.set(meta);
+ latch.countDown();
+ }
+
+ void increment() {
+ count.incrementAndGet();
+ latch.countDown();
+ }
+
+ boolean await(int seconds) throws InterruptedException {
+ return latch.await(seconds, TimeUnit.SECONDS);
+ }
+ }
+
+ private void awaitEvent(CountDownLatch latch, String eventName) throws InterruptedException {
+ awaitEvent(latch, eventName, 10);
+ }
+
+ private void awaitEvent(CountDownLatch latch, String eventName, int timeoutSeconds) throws InterruptedException {
+ boolean fired = latch.await(timeoutSeconds, TimeUnit.SECONDS);
+ assertTrue(eventName + " should fire", fired);
+ }
+
+ private void assertFiredOnce(AtomicInteger count, String eventName) {
+ assertEquals(eventName + " should be invoked exactly once", 1, count.get());
+ }
+
+ private void assertFiredTimes(AtomicInteger count, String eventName, int expectedTimes) {
+ assertEquals(eventName + " should be invoked " + expectedTimes + " time(s)", expectedTimes, count.get());
+ }
+
+ private EventCapture captureReadyEvent(SplitClient client) {
+ EventCapture capture = new EventCapture<>();
+ client.addEventListener(new SplitEventListener() {
+ @Override
+ public void onReady(SplitClient c, SdkReadyMetadata metadata) {
+ capture.capture(metadata);
+ }
+ });
+ return capture;
+ }
+
+ private EventCapture captureCacheReadyEvent(SplitClient client) {
+ EventCapture capture = new EventCapture<>();
+ client.addEventListener(new SplitEventListener() {
+ @Override
+ public void onReadyFromCache(SplitClient c, SdkReadyMetadata metadata) {
+ capture.capture(metadata);
+ }
+ });
+ return capture;
+ }
+
+ private EventCapture captureUpdateEvent(SplitClient client) {
+ return captureUpdateEvent(client, 1);
+ }
+
+ private EventCapture captureUpdateEvent(SplitClient client, int expectedCount) {
+ EventCapture capture = new EventCapture<>(expectedCount);
+ client.addEventListener(new SplitEventListener() {
+ @Override
+ public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) {
+ capture.capture(metadata);
+ }
+ });
+ return capture;
+ }
+
+ private CountDownLatch captureLegacyReadyEvent(SplitClient client) {
+ CountDownLatch latch = new CountDownLatch(1);
+ client.on(SplitEvent.SDK_READY, new SplitEventTask() {
+ @Override
+ public void onPostExecution(SplitClient c) {
+ latch.countDown();
+ }
+ });
+ return latch;
+ }
+
+ /**
+ * Creates a polling dispatcher that returns different responses based on hit count.
+ */
+ private Dispatcher createPollingDispatcher(
+ Function splitChangesResponseFn,
+ Function membershipsResponseFn) {
+ AtomicInteger splitChangesHits = new AtomicInteger(0);
+ AtomicInteger membershipsHits = new AtomicInteger(0);
+
+ return new Dispatcher() {
+ @NonNull
+ @Override
+ public MockResponse dispatch(@NonNull RecordedRequest request) {
+ final String path = request.getPath();
+ if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) {
+ int count = membershipsHits.incrementAndGet();
+ String body = membershipsResponseFn != null
+ ? membershipsResponseFn.apply(count)
+ : IntegrationHelper.dummyAllSegments();
+ return new MockResponse().setResponseCode(200).setBody(body);
+ } else if (path.contains("/splitChanges")) {
+ int count = splitChangesHits.incrementAndGet();
+ String body = splitChangesResponseFn != null
+ ? splitChangesResponseFn.apply(count)
+ : IntegrationHelper.emptyTargetingRulesChanges(1000, 1000);
+ return new MockResponse().setResponseCode(200).setBody(body);
+ } else if (path.contains("/testImpressions/bulk")) {
+ return new MockResponse().setResponseCode(200);
+ }
+ return new MockResponse().setResponseCode(404);
+ }
+ };
+ }
+
+ private Dispatcher createDelayedDispatcher(long delaySeconds) {
+ return new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest request) {
+ final String path = request.getPath();
+ if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) {
+ return new MockResponse()
+ .setResponseCode(200)
+ .setBody(IntegrationHelper.dummyAllSegments())
+ .setBodyDelay(delaySeconds, TimeUnit.SECONDS);
+ } else if (path.contains("/splitChanges")) {
+ long id = mCurSplitReqId++;
+ return new MockResponse()
+ .setResponseCode(200)
+ .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id))
+ .setBodyDelay(delaySeconds, TimeUnit.SECONDS);
+ } else if (path.contains("/testImpressions/bulk")) {
+ return new MockResponse().setResponseCode(200);
+ }
+ return new MockResponse().setResponseCode(404);
+ }
+ };
+ }
+
+ private SplitClientConfig createPollingConfig(int featuresRefreshRate, int segmentsRefreshRate) {
+ return new TestableSplitConfigBuilder()
+ .serviceEndpoints(endpoints())
+ .ready(30000)
+ .featuresRefreshRate(featuresRefreshRate)
+ .segmentsRefreshRate(segmentsRefreshRate)
+ .impressionsRefreshRate(999999)
+ .streamingEnabled(false)
+ .trafficType("account")
+ .build();
+ }
+
+ private void waitForReady(SplitClient client) throws InterruptedException {
+ CountDownLatch latch = captureLegacyReadyEvent(client);
+ awaitEvent(latch, "SDK_READY");
+ }
}
diff --git a/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java
index 27d871a02..82ec302e3 100644
--- a/main/src/androidTest/java/tests/service/EventsManagerTest.java
+++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java
@@ -17,7 +17,7 @@
import helper.TestingHelper;
import io.split.android.client.SplitClient;
import io.split.android.client.SplitClientConfig;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SdkUpdateMetadata;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
@@ -198,7 +198,7 @@ public void onPostExecutionView(SplitClient client) {
});
// Register for SDK_UPDATE with metadata callback using SdkEventListener
- eventManager.registerEventListener(new SdkEventListener() {
+ eventManager.registerEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
receivedMetadata.set(metadata);
@@ -315,7 +315,7 @@ public void onPostExecutionView(SplitClient client) {
});
// Register SdkEventListener to receive typed metadata
- eventManager.registerEventListener(new SdkEventListener() {
+ eventManager.registerEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
backgroundCalled.set(true);
diff --git a/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java b/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java
index 30bccc1b3..fc3e7a02c 100644
--- a/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java
+++ b/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java
@@ -3,7 +3,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
import io.split.android.grammar.Treatments;
@@ -173,7 +173,7 @@ public void on(SplitEvent event, SplitEventTask task) {
}
@Override
- public void addEventListener(SdkEventListener listener) {
+ public void addEventListener(SplitEventListener listener) {
// no-op
}
diff --git a/main/src/main/java/io/split/android/client/SplitClientImpl.java b/main/src/main/java/io/split/android/client/SplitClientImpl.java
index 307618238..571efa169 100644
--- a/main/src/main/java/io/split/android/client/SplitClientImpl.java
+++ b/main/src/main/java/io/split/android/client/SplitClientImpl.java
@@ -12,7 +12,7 @@
import io.split.android.client.api.Key;
import io.split.android.client.attributes.AttributesManager;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
import io.split.android.client.events.SplitEventsManager;
@@ -204,7 +204,7 @@ public void on(SplitEvent event, SplitEventTask task) {
}
@Override
- public void addEventListener(@NonNull SdkEventListener listener) {
+ public void addEventListener(@NonNull SplitEventListener listener) {
if (mIsClientDestroyed) {
Logger.w("Client has already been destroyed. Cannot add event listener");
return;
diff --git a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java
index 0b5b6a0c3..1b5e58499 100644
--- a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java
+++ b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java
@@ -25,7 +25,7 @@
import io.split.android.client.api.Key;
import io.split.android.client.attributes.AttributesManager;
import io.split.android.client.attributes.AttributesMerger;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
import io.split.android.client.events.SplitEventsManager;
@@ -272,7 +272,7 @@ public void on(SplitEvent event, SplitEventTask task) {
}
@Override
- public void addEventListener(@NonNull SdkEventListener listener) {
+ public void addEventListener(@NonNull SplitEventListener listener) {
if (mIsClientDestroyed) {
Logger.w("Client has already been destroyed. Cannot add event listener");
return;
diff --git a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java
index 3b2a4be33..f41271796 100644
--- a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java
+++ b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java
@@ -159,6 +159,12 @@ public void close() {
}
+ @VisibleForTesting
+ @Nullable
+ SSLSocketFactory getSslSocketFactory() {
+ return mSslSocketFactory;
+ }
+
private Proxy initializeProxy(HttpProxy proxy) {
if (proxy != null) {
return new Proxy(
@@ -279,7 +285,7 @@ public HttpClient build() {
if (mProxy != null) {
mSslSocketFactory = createSslSocketFactoryFromProxy(mProxy);
- } else {
+ } else if (LegacyTlsUpdater.couldBeOld()) {
try {
mSslSocketFactory = new Tls12OnlySocketFactory();
} catch (NoSuchAlgorithmException | KeyManagementException e) {
@@ -287,6 +293,9 @@ public HttpClient build() {
} catch (Exception e) {
Logger.e("Unknown TLS v12 error: " + e.getLocalizedMessage());
}
+ } else {
+ // Use platform default
+ mSslSocketFactory = null;
}
}
diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java
index 0ea6127c8..705331080 100644
--- a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java
+++ b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java
@@ -327,11 +327,25 @@ private void updateStorage(boolean clearBeforeUpdate, SplitChange splitChange, R
mRuleBasedSegmentStorage.clear();
}
ProcessedSplitChange processedSplitChange = mSplitChangeProcessor.process(splitChange);
- mLastProcessedSplitChange.set(processedSplitChange);
+ if (hasFlagUpdates(processedSplitChange)) {
+ mLastProcessedSplitChange.set(processedSplitChange);
+ }
mSplitsStorage.update(processedSplitChange, mExecutor);
updateRbsStorage(ruleBasedSegmentChange);
}
+ private boolean hasFlagUpdates(@Nullable ProcessedSplitChange processedSplitChange) {
+ if (processedSplitChange == null) {
+ return false;
+ }
+ List activeSplits = processedSplitChange.getActiveSplits();
+ if (activeSplits != null && !activeSplits.isEmpty()) {
+ return true;
+ }
+ List archivedSplits = processedSplitChange.getArchivedSplits();
+ return archivedSplits != null && !archivedSplits.isEmpty();
+ }
+
/**
* Gets the list of updated flag names from the last sync operation.
* This includes both active (added/modified) and archived (removed) splits.
diff --git a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java
index c76b3c9a0..16d40a060 100644
--- a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java
+++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java
@@ -16,7 +16,7 @@
import io.split.android.client.api.Key;
import io.split.android.client.attributes.AttributesManager;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
import io.split.android.client.events.SplitEventsManager;
@@ -136,14 +136,14 @@ public void addEventListenerWithNullListenerDoesNotRegisterAndLogsWarning() {
try (MockedStatic logger = mockStatic(Logger.class)) {
splitClient.addEventListener(null);
- verify(eventsManager, never()).registerEventListener(any(SdkEventListener.class));
+ verify(eventsManager, never()).registerEventListener(any(SplitEventListener.class));
logger.verify(() -> Logger.w("SDK Event Listener cannot be null"));
}
}
@Test
public void addEventListenerWithValidListenerRegistersListener() {
- SdkEventListener listener = mock(SdkEventListener.class);
+ SplitEventListener listener = mock(SplitEventListener.class);
splitClient.addEventListener(listener);
@@ -155,10 +155,10 @@ public void addEventListenerDoesNotRegisterWhenClientIsDestroyedAndLogsWarning()
try (MockedStatic logger = mockStatic(Logger.class)) {
splitClient.destroy();
- SdkEventListener listener = mock(SdkEventListener.class);
+ SplitEventListener listener = mock(SplitEventListener.class);
splitClient.addEventListener(listener);
- verify(eventsManager, never()).registerEventListener(any(SdkEventListener.class));
+ verify(eventsManager, never()).registerEventListener(any(SplitEventListener.class));
logger.verify(() -> Logger.w("Client has already been destroyed. Cannot add event listener"));
}
}
diff --git a/main/src/test/java/io/split/android/client/events/EventsManagerTest.java b/main/src/test/java/io/split/android/client/events/EventsManagerTest.java
index 6861d84c4..4df176da1 100644
--- a/main/src/test/java/io/split/android/client/events/EventsManagerTest.java
+++ b/main/src/test/java/io/split/android/client/events/EventsManagerTest.java
@@ -282,7 +282,7 @@ public void sdkUpdateWithTypedTaskReceivesMetadata() throws InterruptedException
waitForSdkReady(eventManager, readyLatch);
- eventManager.registerEventListener(new SdkEventListener() {
+ eventManager.registerEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
receivedMetadata.set(metadata);
@@ -309,7 +309,7 @@ public void sdkUpdateWithTypedTaskReceivesMetadataOnMainThread() throws Interrup
waitForSdkReady(eventManager, readyLatch);
- eventManager.registerEventListener(new SdkEventListener() {
+ eventManager.registerEventListener(new SplitEventListener() {
@Override
public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) {
receivedMetadata.set(metadata);
@@ -362,7 +362,7 @@ public void sdkEventListenerCallsBothBackgroundAndMainThreadMethods() throws Int
waitForSdkReady(eventManager, readyLatch);
- eventManager.registerEventListener(new SdkEventListener() {
+ eventManager.registerEventListener(new SplitEventListener() {
@Override
public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
backgroundMethodCalled[0] = true;
@@ -396,7 +396,7 @@ public void sdkReadyFromCacheTypedTaskReceivesMetadata() throws InterruptedExcep
AtomicReference receivedMetadata = new AtomicReference<>();
// Register an event listener
- eventManager.registerEventListener(new SdkEventListener() {
+ eventManager.registerEventListener(new SplitEventListener() {
@Override
public void onReadyFromCache(SplitClient client, SdkReadyMetadata metadata) {
receivedMetadata.set(metadata);
diff --git a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java
index 1b8b7a38c..9dd91d706 100644
--- a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java
+++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java
@@ -37,7 +37,7 @@
import io.split.android.client.api.Key;
import io.split.android.client.attributes.AttributesManager;
import io.split.android.client.attributes.AttributesMerger;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
import io.split.android.client.events.SplitEventsManager;
@@ -450,7 +450,7 @@ public void addEventListenerWithNullListenerDoesNotRegister() {
try (MockedStatic logger = mockStatic(Logger.class)) {
client.addEventListener(null);
- verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class));
+ verify(mockEventsManager, never()).registerEventListener(any(SplitEventListener.class));
logger.verify(() -> Logger.w("SDK Event Listener cannot be null"));
}
}
@@ -459,18 +459,18 @@ public void addEventListenerWithNullListenerDoesNotRegister() {
public void addEventListenerDoesNotRegisterWhenClientIsDestroyed() {
try (MockedStatic logger = mockStatic(Logger.class)) {
client.destroy();
- SdkEventListener listener = mock(SdkEventListener.class);
+ SplitEventListener listener = mock(SplitEventListener.class);
client.addEventListener(listener);
- verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class));
+ verify(mockEventsManager, never()).registerEventListener(any(SplitEventListener.class));
logger.verify(() -> Logger.w("Client has already been destroyed. Cannot add event listener"));
}
}
@Test
public void addEventListenerWithValidListenerRegistersListener() {
- SdkEventListener listener = mock(SdkEventListener.class);
+ SplitEventListener listener = mock(SplitEventListener.class);
client.addEventListener(listener);
diff --git a/main/src/test/java/io/split/android/client/network/HttpClientTest.java b/main/src/test/java/io/split/android/client/network/HttpClientTest.java
index 2daa5063b..3ecc24ee2 100644
--- a/main/src/test/java/io/split/android/client/network/HttpClientTest.java
+++ b/main/src/test/java/io/split/android/client/network/HttpClientTest.java
@@ -20,6 +20,8 @@
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
@@ -403,6 +405,40 @@ public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest
mProxyServer.shutdown();
}
+ @Test
+ public void buildUsesTls12FactoryWhenLegacyAndNoProxy() throws Exception {
+ Context context = mock(Context.class);
+
+ try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) {
+ legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(true);
+
+ HttpClient legacyClient = new HttpClientImpl.Builder()
+ .setContext(context)
+ .setUrlSanitizer(mUrlSanitizerMock)
+ .build();
+
+ legacyMock.verify(() -> LegacyTlsUpdater.update(context));
+ assertTrue(((HttpClientImpl) legacyClient).getSslSocketFactory() instanceof Tls12OnlySocketFactory);
+ }
+ }
+
+ @Test
+ public void buildUsesDefaultSslWhenNotLegacyAndNoProxy() throws Exception {
+ Context context = mock(Context.class);
+
+ try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) {
+ legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(false);
+
+ HttpClient modernClient = new HttpClientImpl.Builder()
+ .setContext(context)
+ .setUrlSanitizer(mUrlSanitizerMock)
+ .build();
+
+ legacyMock.verify(() -> LegacyTlsUpdater.update(context), Mockito.never());
+ assertNull(((HttpClientImpl) modernClient).getSslSocketFactory());
+ }
+ }
+
@Test
public void copyStreamToByteArrayWithSimpleString() {
diff --git a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java
index e60147600..ec8c7db04 100644
--- a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java
+++ b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java
@@ -42,6 +42,7 @@
import io.split.android.client.dtos.RuleBasedSegmentChange;
import io.split.android.client.dtos.SplitChange;
import io.split.android.client.dtos.TargetingRulesChange;
+import io.split.android.client.dtos.Status;
import io.split.android.client.network.SplitHttpHeadersBuilder;
import io.split.android.client.service.executor.SplitTaskExecutionInfo;
import io.split.android.client.service.executor.SplitTaskExecutionStatus;
@@ -756,6 +757,31 @@ public void getLastUpdatedSplitNamesReturnsFlagNamesAfterSync() throws HttpFetch
// The exact number depends on the splits in the test data, but it should not be null
}
+ @Test
+ public void getLastUpdatedFlagNamesPreservesLastNonEmptyChange() throws HttpFetcherException {
+ Split split = new Split();
+ split.name = "split_1";
+ split.status = Status.ACTIVE;
+
+ SplitChange firstSplitChange = SplitChange.create(-1, 100L, Collections.singletonList(split));
+ SplitChange secondSplitChange = SplitChange.create(100L, 100L, Collections.emptyList());
+
+ RuleBasedSegmentChange firstRbsChange = RuleBasedSegmentChange.create(-1, 10L, Collections.emptyList());
+ RuleBasedSegmentChange secondRbsChange = RuleBasedSegmentChange.create(10L, 10L, Collections.emptyList());
+
+ when(mSplitsFetcher.execute(any(), any()))
+ .thenReturn(TargetingRulesChange.create(firstSplitChange, firstRbsChange))
+ .thenReturn(TargetingRulesChange.create(secondSplitChange, secondRbsChange));
+ when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L);
+ when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(10L);
+
+ mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES);
+
+ List result = mSplitsSyncHelper.getLastUpdatedFlagNames();
+ assertEquals(1, result.size());
+ assertTrue(result.contains("split_1"));
+ }
+
@Test
public void getLastUpdatedFlagNamesIncludesArchivedSplits() throws HttpFetcherException {
Split archivedSplit = new Split();
diff --git a/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java
index bc276e320..b8eb66b9f 100644
--- a/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java
+++ b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java
@@ -5,7 +5,7 @@
import io.split.android.client.events.metadata.EventMetadata;
import io.split.android.client.events.ISplitEventsManager;
import io.split.android.client.events.ListenableEventsManager;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
import io.split.android.client.events.SplitEvent;
import io.split.android.client.events.SplitEventTask;
import io.split.android.client.events.SplitInternalEvent;
@@ -44,7 +44,7 @@ public boolean eventAlreadyTriggered(SplitEvent event) {
}
@Override
- public void registerEventListener(SdkEventListener listener) {
+ public void registerEventListener(SplitEventListener listener) {
// Stub implementation - does nothing
}
}