From a3dec44c19de4ab6935d6f7b5125717f3ed73b69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:05:14 -0300 Subject: [PATCH 01/10] Updated License Year (#843) Co-authored-by: gthea Co-authored-by: github-actions[bot] --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From defdcd0fb16c67daa2d1bd5a33f6b743d45290f8 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 11:01:05 -0300 Subject: [PATCH 02/10] Metadata source in events manager --- .../events/EventsManagerCoordinator.java | 6 +- .../SplitEventsManagerConfigFactory.java | 9 ++ .../harness/events/EventsManagerConfig.java | 62 ++++++++++++- .../io/harness/events/EventsManagerCore.java | 74 +++++++++++++-- .../events/EventsManagerConfigTest.java | 25 ++++++ .../events/EventsManagerMetadataTest.java | 90 +++++++++++++++++++ 6 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 events/src/test/java/io/harness/events/EventsManagerMetadataTest.java 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/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/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()); + } +} From 6e25095552df2dc7c822f0781a732a1713fde629 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 11:11:09 -0300 Subject: [PATCH 03/10] Update README --- events/README.md | 5 +++++ 1 file changed, 5 insertions(+) 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 From 775cfd32705f40603c85fd86fbb1b36f23c56996 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 11:40:19 -0300 Subject: [PATCH 04/10] New integration tests --- .../events/SdkEventsIntegrationTest.java | 335 +++++++++++++++++- 1 file changed, 334 insertions(+), 1 deletion(-) diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index 24d3283f5..206cf9658 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue; import android.content.Context; +import android.util.Base64; import androidx.annotation.NonNull; import androidx.test.platform.app.InstrumentationRegistry; @@ -15,6 +16,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; @@ -42,6 +44,7 @@ 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; @@ -257,7 +260,7 @@ public void onReady(SplitClient client, SdkReadyMetadata metadata) { }); // When: SDK_READY fires - boolean fired = readyLatch.await(10, TimeUnit.SECONDS); + boolean fired = readyLatch.await(30, TimeUnit.SECONDS); // Then: onReady is invoked exactly once assertTrue("onReady should fire", fired); @@ -280,6 +283,47 @@ public void onReady(SplitClient client, SdkReadyMetadata metadata) { 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 { + long testTimestamp = System.currentTimeMillis(); + populateDatabaseWithCacheData(testTimestamp); + + SplitFactory factory = buildFactory(buildConfig()); + + SplitClient client1 = factory.client(new Key("key_1")); + CountDownLatch readyLatch1 = new CountDownLatch(1); + registerReadyHandler(client1, null, readyLatch1); + assertTrue("Client1 SDK_READY should fire", readyLatch1.await(10, TimeUnit.SECONDS)); + + SplitClient client2 = factory.client(new Key("key_2")); + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch readyLatch2 = new CountDownLatch(1); + client2.addEventListener(new SdkEventListener() { + @Override + public void onReady(SplitClient client, SdkReadyMetadata metadata) { + receivedMetadata.set(metadata); + readyLatch2.countDown(); + } + }); + + assertTrue("Client2 SDK_READY should fire", readyLatch2.await(10, TimeUnit.SECONDS)); + + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertNotNull("initialCacheLoad should not be null", receivedMetadata.get().isInitialCacheLoad()); + assertFalse("initialCacheLoad should be false for cache path", receivedMetadata.get().isInitialCacheLoad()); + assertNotNull("lastUpdateTimestamp should not be null", receivedMetadata.get().getLastUpdateTimestamp()); + + factory.destroy(); + } + /** * Scenario: onReady listener replays to late subscribers *

@@ -1336,6 +1380,267 @@ public void sdkUpdateMetadataContainsTypeForMembershipSegmentsUpdate() throws Ex ); } + /** + * Scenario: sdkUpdateMetadata includes flag names for polling flag updates + *

+ * 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 { + 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) { + return new MockResponse().setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)); + } else { + String responseWithFlagChange = "{\"ff\":{\"s\":2000,\"t\":2000,\"d\":[" + + "{\"trafficTypeName\":\"user\",\"name\":\"polling_flag\",\"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\":[]}}"; + return new MockResponse().setResponseCode(200).setBody(responseWithFlagChange); + } + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(pollingDispatcher); + + 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 SdkEventListener() { + @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")); + + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch readyLatch = new CountDownLatch(1); + + client.addEventListener(new SdkEventListener() { + @Override + public void onReady(SplitClient client, SdkReadyMetadata metadata) { + receivedMetadata.set(metadata); + readyLatch.countDown(); + } + }); + + assertTrue("SDK_READY should fire", readyLatch.await(10, TimeUnit.SECONDS)); + + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertNotNull("initialCacheLoad should not be null", receivedMetadata.get().isInitialCacheLoad()); + assertTrue("initialCacheLoad should be true for fresh install", receivedMetadata.get().isInitialCacheLoad()); + assertEquals("lastUpdateTimestamp should be null for fresh install", + null, receivedMetadata.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); + AtomicInteger key2MembershipHits = new AtomicInteger(0); + + final String initialMemberships = "{\"ms\":{\"k\":[{\"n\":\"segment1\"}],\"cn\":1000},\"ls\":{\"k\":[],\"cn\":1000}}"; + final String updatedMembershipsKey1 = "{\"ms\":{\"k\":[{\"n\":\"segment2\"}],\"cn\":2000},\"ls\":{\"k\":[],\"cn\":1000}}"; + + final Dispatcher pollingDispatcher = 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 : updatedMembershipsKey1); + } + if (path.contains("key_2")) { + key2MembershipHits.incrementAndGet(); + return new MockResponse().setResponseCode(200).setBody(initialMemberships); + } + 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); + } + }; + mWebServer.setDispatcher(pollingDispatcher); + + SplitClientConfig config = new TestableSplitConfigBuilder() + .serviceEndpoints(endpoints()) + .ready(30000) + .featuresRefreshRate(999999) + .segmentsRefreshRate(3) + .impressionsRefreshRate(999999) + .streamingEnabled(false) + .trafficType("account") + .build(); + + SplitFactory factory = buildFactory(config); + SplitClient client1 = factory.client(new Key("key_1")); + SplitClient client2 = factory.client(new Key("key_2")); + + AtomicReference client1Metadata = new AtomicReference<>(); + AtomicInteger client2UpdateCount = new AtomicInteger(0); + CountDownLatch updateLatch = new CountDownLatch(1); + + client1.addEventListener(new SdkEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + client1Metadata.set(metadata); + updateLatch.countDown(); + } + }); + client2.addEventListener(new SdkEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + client2UpdateCount.incrementAndGet(); + } + }); + + CountDownLatch readyLatch1 = new CountDownLatch(1); + CountDownLatch readyLatch2 = new CountDownLatch(1); + client1.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient c) { + readyLatch1.countDown(); + } + }); + client2.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient c) { + readyLatch2.countDown(); + } + }); + assertTrue("Client1 SDK_READY should fire", readyLatch1.await(10, TimeUnit.SECONDS)); + assertTrue("Client2 SDK_READY should fire", readyLatch2.await(10, TimeUnit.SECONDS)); + + assertTrue("Client1 should receive SDK_UPDATE", updateLatch.await(20, TimeUnit.SECONDS)); + assertNotNull("Client1 metadata should not be null", client1Metadata.get()); + assertEquals("Type should be SEGMENTS_UPDATE", + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Metadata.get().getType()); + + Thread.sleep(1000); + assertEquals("Client2 should not receive SDK_UPDATE", 0, client2UpdateCount.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")); + + AtomicReference client1Metadata = new AtomicReference<>(); + AtomicInteger client2UpdateCount = new AtomicInteger(0); + CountDownLatch updateLatch = new CountDownLatch(1); + + fixture.clientA.addEventListener(new SdkEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + client1Metadata.set(metadata); + updateLatch.countDown(); + } + }); + fixture.clientB.addEventListener(new SdkEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + client2UpdateCount.incrementAndGet(); + } + }); + + // Keylist update: only key1 is included + fixture.pushMembershipKeyListUpdate("key1", "streaming_segment"); + + assertTrue("Client1 should receive SDK_UPDATE", updateLatch.await(10, TimeUnit.SECONDS)); + assertNotNull("Client1 metadata should not be null", client1Metadata.get()); + assertEquals("Type should be SEGMENTS_UPDATE", + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Metadata.get().getType()); + + Thread.sleep(500); + assertEquals("Client2 should not receive SDK_UPDATE", 0, client2UpdateCount.get()); + + fixture.destroy(); + } + /** * Scenario: sdkUpdateMetadata contains Type.SEGMENTS_UPDATE for large segments update (polling) *

@@ -2026,11 +2331,39 @@ void pushSplitUpdate() { } } + void pushMembershipKeyListUpdate(String key, String segmentName) { + if (streamingData != null) { + pushMessage(streamingData, membershipKeyListUpdateMessage(key, segmentName)); + } + } + void destroy() { factory.destroy(); } } + private static String membershipKeyListUpdateMessage(String key, String segmentName) { + MySegmentsV2PayloadDecoder decoder = new MySegmentsV2PayloadDecoder(); + BigInteger hashedKey = decoder.hashKey(key); + String keyListJson = "{\"a\":[" + hashedKey.toString() + "],\"r\":[]}"; + String encodedKeyList = Base64.encodeToString( + keyListJson.getBytes(io.split.android.client.utils.StringHelper.defaultCharset()), + Base64.NO_WRAP); + + String notificationJson = "{" + + "\\\"type\\\":\\\"MEMBERSHIPS_MS_UPDATE\\\"," + + "\\\"cn\\\":2000," + + "\\\"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"; + } private static void pushMessage(BlockingQueue queue, String message) { try { queue.put(message + "\n"); From 877ebd96a4a86f5e2bc64c7dcd43699c9af86977 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 12:03:39 -0300 Subject: [PATCH 05/10] Refactor tests --- .../events/SdkEventsIntegrationTest.java | 1137 +++++++---------- 1 file changed, 429 insertions(+), 708 deletions(-) diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index 206cf9658..13dc805ec 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -145,36 +145,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(); } @@ -192,31 +181,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(); } @@ -235,50 +213,25 @@ 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(30, 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(); } @@ -293,33 +246,20 @@ public void onReady(SplitClient client, SdkReadyMetadata metadata) { */ @Test public void sdkReadyMetadataNotNullWhenMembershipsCompletesLast() throws Exception { - long testTimestamp = System.currentTimeMillis(); - populateDatabaseWithCacheData(testTimestamp); - + populateDatabaseWithCacheData(System.currentTimeMillis()); SplitFactory factory = buildFactory(buildConfig()); SplitClient client1 = factory.client(new Key("key_1")); - CountDownLatch readyLatch1 = new CountDownLatch(1); - registerReadyHandler(client1, null, readyLatch1); - assertTrue("Client1 SDK_READY should fire", readyLatch1.await(10, TimeUnit.SECONDS)); + waitForReady(client1); SplitClient client2 = factory.client(new Key("key_2")); - AtomicReference receivedMetadata = new AtomicReference<>(); - CountDownLatch readyLatch2 = new CountDownLatch(1); - client2.addEventListener(new SdkEventListener() { - @Override - public void onReady(SplitClient client, SdkReadyMetadata metadata) { - receivedMetadata.set(metadata); - readyLatch2.countDown(); - } - }); + EventCapture capture = captureReadyEvent(client2); + awaitEvent(capture.latch, "Client2 SDK_READY"); - assertTrue("Client2 SDK_READY should fire", readyLatch2.await(10, TimeUnit.SECONDS)); - - assertNotNull("Metadata should not be null", receivedMetadata.get()); - assertNotNull("initialCacheLoad should not be null", receivedMetadata.get().isInitialCacheLoad()); - assertFalse("initialCacheLoad should be false for cache path", receivedMetadata.get().isInitialCacheLoad()); - assertNotNull("lastUpdateTimestamp should not be null", receivedMetadata.get().getLastUpdateTimestamp()); + 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(); } @@ -337,28 +277,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(); } @@ -374,32 +302,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 + EventCapture capture = new EventCapture<>(); client.addEventListener(new SdkEventListener() { @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(); } @@ -421,59 +339,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(); } @@ -492,19 +371,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(); } @@ -526,49 +401,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(); } @@ -586,27 +439,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(); } @@ -630,7 +471,6 @@ 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); @@ -640,7 +480,6 @@ public void sdkUpdateDoesNotReplayToLateSubscribers() throws Exception { 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(); @@ -649,55 +488,40 @@ 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(); } @@ -716,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(); } @@ -809,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) @@ -822,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(); } @@ -863,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(); } @@ -987,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(); } @@ -1032,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(); } @@ -1076,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.clientA); + EventCapture captureB = captureUpdateEvent(fixture.clientB); // 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(); } @@ -1130,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.clientA); + EventCapture captureB = captureUpdateEvent(fixture.clientB); // 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(); } @@ -1181,28 +890,15 @@ 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(); } @@ -1223,28 +919,15 @@ 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(); } @@ -1473,24 +1156,14 @@ public void sdkReadyMetadataNotNullOnFreshInstall() throws Exception { SplitFactory factory = buildFactory(buildConfig()); SplitClient client = factory.client(new Key("key_1")); - AtomicReference receivedMetadata = new AtomicReference<>(); - CountDownLatch readyLatch = new CountDownLatch(1); - - client.addEventListener(new SdkEventListener() { - @Override - public void onReady(SplitClient client, SdkReadyMetadata metadata) { - receivedMetadata.set(metadata); - readyLatch.countDown(); - } - }); - - assertTrue("SDK_READY should fire", readyLatch.await(10, TimeUnit.SECONDS)); + EventCapture capture = captureReadyEvent(client); - assertNotNull("Metadata should not be null", receivedMetadata.get()); - assertNotNull("initialCacheLoad should not be null", receivedMetadata.get().isInitialCacheLoad()); - assertTrue("initialCacheLoad should be true for fresh install", receivedMetadata.get().isInitialCacheLoad()); + 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, receivedMetadata.get().getLastUpdateTimestamp()); + null, capture.metadata.get().getLastUpdateTimestamp()); factory.destroy(); } @@ -1506,12 +1179,10 @@ public void onReady(SplitClient client, SdkReadyMetadata metadata) { @Test public void sdkUpdateMetadataForSingleClientMembershipPolling() throws Exception { AtomicInteger key1MembershipHits = new AtomicInteger(0); - AtomicInteger key2MembershipHits = new AtomicInteger(0); - final String initialMemberships = "{\"ms\":{\"k\":[{\"n\":\"segment1\"}],\"cn\":1000},\"ls\":{\"k\":[],\"cn\":1000}}"; final String updatedMembershipsKey1 = "{\"ms\":{\"k\":[{\"n\":\"segment2\"}],\"cn\":2000},\"ls\":{\"k\":[],\"cn\":1000}}"; - final Dispatcher pollingDispatcher = new Dispatcher() { + mWebServer.setDispatcher(new Dispatcher() { @Override public MockResponse dispatch(RecordedRequest request) { final String path = request.getPath(); @@ -1521,10 +1192,6 @@ public MockResponse dispatch(RecordedRequest request) { return new MockResponse().setResponseCode(200) .setBody(count <= 1 ? initialMemberships : updatedMembershipsKey1); } - if (path.contains("key_2")) { - key2MembershipHits.incrementAndGet(); - return new MockResponse().setResponseCode(200).setBody(initialMemberships); - } return new MockResponse().setResponseCode(200).setBody(initialMemberships); } else if (path.contains("/splitChanges")) { return new MockResponse().setResponseCode(200) @@ -1534,65 +1201,25 @@ public MockResponse dispatch(RecordedRequest request) { } return new MockResponse().setResponseCode(404); } - }; - mWebServer.setDispatcher(pollingDispatcher); - - SplitClientConfig config = new TestableSplitConfigBuilder() - .serviceEndpoints(endpoints()) - .ready(30000) - .featuresRefreshRate(999999) - .segmentsRefreshRate(3) - .impressionsRefreshRate(999999) - .streamingEnabled(false) - .trafficType("account") - .build(); + }); - SplitFactory factory = buildFactory(config); + SplitFactory factory = buildFactory(createPollingConfig(999999, 3)); SplitClient client1 = factory.client(new Key("key_1")); SplitClient client2 = factory.client(new Key("key_2")); - AtomicReference client1Metadata = new AtomicReference<>(); - AtomicInteger client2UpdateCount = new AtomicInteger(0); - CountDownLatch updateLatch = new CountDownLatch(1); + EventCapture client1Capture = captureUpdateEvent(client1); + EventCapture client2Capture = captureUpdateEvent(client2); - client1.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { - client1Metadata.set(metadata); - updateLatch.countDown(); - } - }); - client2.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { - client2UpdateCount.incrementAndGet(); - } - }); + waitForReady(client1); + waitForReady(client2); - CountDownLatch readyLatch1 = new CountDownLatch(1); - CountDownLatch readyLatch2 = new CountDownLatch(1); - client1.on(SplitEvent.SDK_READY, new SplitEventTask() { - @Override - public void onPostExecution(SplitClient c) { - readyLatch1.countDown(); - } - }); - client2.on(SplitEvent.SDK_READY, new SplitEventTask() { - @Override - public void onPostExecution(SplitClient c) { - readyLatch2.countDown(); - } - }); - assertTrue("Client1 SDK_READY should fire", readyLatch1.await(10, TimeUnit.SECONDS)); - assertTrue("Client2 SDK_READY should fire", readyLatch2.await(10, TimeUnit.SECONDS)); - - assertTrue("Client1 should receive SDK_UPDATE", updateLatch.await(20, TimeUnit.SECONDS)); - assertNotNull("Client1 metadata should not be null", client1Metadata.get()); + 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, client1Metadata.get().getType()); + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Capture.metadata.get().getType()); Thread.sleep(1000); - assertEquals("Client2 should not receive SDK_UPDATE", 0, client2UpdateCount.get()); + assertEquals("Client2 should not receive SDK_UPDATE", 0, client2Capture.count.get()); factory.destroy(); } @@ -1609,34 +1236,19 @@ public void onPostExecution(SplitClient c) { public void sdkUpdateMetadataForSingleClientMembershipStreaming() throws Exception { TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("key1"), new Key("key2")); - AtomicReference client1Metadata = new AtomicReference<>(); - AtomicInteger client2UpdateCount = new AtomicInteger(0); - CountDownLatch updateLatch = new CountDownLatch(1); - - fixture.clientA.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { - client1Metadata.set(metadata); - updateLatch.countDown(); - } - }); - fixture.clientB.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { - client2UpdateCount.incrementAndGet(); - } - }); + EventCapture client1Capture = captureUpdateEvent(fixture.clientA); + EventCapture client2Capture = captureUpdateEvent(fixture.clientB); // Keylist update: only key1 is included fixture.pushMembershipKeyListUpdate("key1", "streaming_segment"); - assertTrue("Client1 should receive SDK_UPDATE", updateLatch.await(10, TimeUnit.SECONDS)); - assertNotNull("Client1 metadata should not be null", client1Metadata.get()); + 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, client1Metadata.get().getType()); + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Capture.metadata.get().getType()); Thread.sleep(500); - assertEquals("Client2 should not receive SDK_UPDATE", 0, client2UpdateCount.get()); + assertEquals("Client2 should not receive SDK_UPDATE", 0, client2Capture.count.get()); fixture.destroy(); } @@ -1823,22 +1435,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(); } @@ -1858,20 +1465,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); - - client.addEventListener(createOnReadyListener(listener1Count, listener1Metadata, readyLatch)); - client.addEventListener(createOnReadyListener(listener2Count, listener2Metadata, readyLatch)); + EventCapture capture1 = captureReadyEvent(client); + EventCapture capture2 = captureReadyEvent(client); - 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(); } @@ -1893,24 +1495,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(); } @@ -1930,30 +1527,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(); } @@ -1971,21 +1568,16 @@ 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(); } @@ -2122,16 +1714,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); + CountDownLatch readyLatchA = captureLegacyReadyEvent(clientA); + CountDownLatch readyLatchB = captureLegacyReadyEvent(clientB); - registerReadyHandler(clientA, null, readyLatchA); - registerReadyHandler(clientB, null, readyLatchB); - - 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); @@ -2140,81 +1727,6 @@ 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. */ @@ -2441,4 +1953,213 @@ private void populateDatabaseWithRbsData() { // Set RBS change number so streaming notifications trigger in-place updates mDatabase.generalInfoDao().update(new GeneralInfoEntity("rbsChangeNumber", 1000L)); } + + // ==================== Event Capture Helpers ==================== + + /** + * Container for capturing event invocations with count, metadata, and latch. + * Reduces boilerplate of creating separate AtomicInteger, AtomicReference, and CountDownLatch. + */ + 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() throws InterruptedException { + return await(10); + } + + boolean await(int seconds) throws InterruptedException { + return latch.await(seconds, TimeUnit.SECONDS); + } + } + + /** + * Awaits a latch and asserts the event fired within timeout. + */ + 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); + } + + /** + * Asserts that an event was fired exactly once. + */ + private void assertFiredOnce(AtomicInteger count, String eventName) { + assertEquals(eventName + " should be invoked exactly once", 1, count.get()); + } + + /** + * Asserts that an event was fired the expected number of times. + */ + private void assertFiredTimes(AtomicInteger count, String eventName, int expectedTimes) { + assertEquals(eventName + " should be invoked " + expectedTimes + " time(s)", expectedTimes, count.get()); + } + + /** + * Registers an onReady listener and returns an EventCapture for the results. + */ + private EventCapture captureReadyEvent(SplitClient client) { + EventCapture capture = new EventCapture<>(); + client.addEventListener(new SdkEventListener() { + @Override + public void onReady(SplitClient c, SdkReadyMetadata metadata) { + capture.capture(metadata); + } + }); + return capture; + } + + /** + * Registers an onReadyFromCache listener and returns an EventCapture for the results. + */ + private EventCapture captureCacheReadyEvent(SplitClient client) { + EventCapture capture = new EventCapture<>(); + client.addEventListener(new SdkEventListener() { + @Override + public void onReadyFromCache(SplitClient c, SdkReadyMetadata metadata) { + capture.capture(metadata); + } + }); + return capture; + } + + /** + * Registers an onUpdate listener and returns an EventCapture for the results. + */ + private EventCapture captureUpdateEvent(SplitClient client) { + return captureUpdateEvent(client, 1); + } + + private EventCapture captureUpdateEvent(SplitClient client, int expectedCount) { + EventCapture capture = new EventCapture<>(expectedCount); + client.addEventListener(new SdkEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + capture.capture(metadata); + } + }); + return capture; + } + + /** + * Registers a legacy SDK_READY handler with latch countdown. + */ + 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. + * Useful for tests that need to verify behavior across multiple polling cycles. + */ + private Dispatcher createPollingDispatcher( + java.util.function.Function splitChangesResponseFn, + java.util.function.Function membershipsResponseFn) { + AtomicInteger splitChangesHits = new AtomicInteger(0); + AtomicInteger membershipsHits = new AtomicInteger(0); + + return new Dispatcher() { + @Override + public MockResponse dispatch(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); + } + }; + } + + /** + * Creates a delayed dispatcher for timeout tests. + */ + 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); + } + }; + } + + /** + * Creates a polling config with specified refresh rates. + */ + 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(); + } + + /** + * Waits for SDK_READY on a client and asserts it fired. + */ + private void waitForReady(SplitClient client) throws InterruptedException { + CountDownLatch latch = captureLegacyReadyEvent(client); + awaitEvent(latch, "SDK_READY"); + } } From badd72f867249dacae846a14402807cd3538f354 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 12:12:13 -0300 Subject: [PATCH 06/10] Reuse helper methods --- .../androidTest/java/helper/IntegrationHelper.java | 11 +++++++---- .../integration/events/SdkEventsIntegrationTest.java | 12 ++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/main/src/androidTest/java/helper/IntegrationHelper.java b/main/src/androidTest/java/helper/IntegrationHelper.java index 40062cd6d..48b718b80 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>() { @@ -303,10 +309,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) { diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index 13dc805ec..791880b93 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -1747,7 +1747,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. @@ -1784,13 +1783,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)); } } @@ -1806,9 +1807,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)); } } From 8271d9531abd98bc8d45a03671e3aced4d34ec71 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 13:04:11 -0300 Subject: [PATCH 07/10] Clean up --- build.gradle | 3 - .../java/helper/IntegrationHelper.java | 104 ++++++++ .../events/SdkEventsIntegrationTest.java | 250 ++++-------------- 3 files changed, 157 insertions(+), 200 deletions(-) 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/main/src/androidTest/java/helper/IntegrationHelper.java b/main/src/androidTest/java/helper/IntegrationHelper.java index 48b718b80..7d99b3fe0 100644 --- a/main/src/androidTest/java/helper/IntegrationHelper.java +++ b/main/src/androidTest/java/helper/IntegrationHelper.java @@ -194,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"; } @@ -509,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 791880b93..30bf560fa 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -6,7 +6,6 @@ import static org.junit.Assert.assertTrue; import android.content.Context; -import android.util.Base64; import androidx.annotation.NonNull; import androidx.test.platform.app.InstrumentationRegistry; @@ -26,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; @@ -47,7 +47,6 @@ 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; @@ -831,8 +830,8 @@ 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")); - EventCapture captureA = captureUpdateEvent(fixture.clientA); - EventCapture captureB = captureUpdateEvent(fixture.clientB); + EventCapture captureA = captureUpdateEvent(fixture.mClientA); + EventCapture captureB = captureUpdateEvent(fixture.mClientB); // When: a SDK-scoped internal "splitsUpdated" event is notified via SSE fixture.pushSplitUpdate(); @@ -861,8 +860,8 @@ public void clientScopedEventsDoNotFanOutToOtherClients() throws Exception { // Given: a factory with two clients (with streaming support) TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("userA"), new Key("userB")); - EventCapture captureA = captureUpdateEvent(fixture.clientA); - EventCapture captureB = captureUpdateEvent(fixture.clientB); + EventCapture captureA = captureUpdateEvent(fixture.mClientA); + EventCapture captureB = captureUpdateEvent(fixture.mClientB); // When: a SDK-scoped split update notification arrives (affects all clients) fixture.pushSplitUpdate(); @@ -944,45 +943,14 @@ public void sdkUpdateMetadataContainsTypeForSegmentsUpdate() throws Exception { */ @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() @@ -1056,10 +1024,8 @@ 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) ); } @@ -1072,35 +1038,12 @@ public void sdkUpdateMetadataContainsTypeForMembershipSegmentsUpdate() throws Ex */ @Test public void sdkUpdateMetadataContainsNamesForPollingFlagsUpdate() throws Exception { - 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) { - return new MockResponse().setResponseCode(200) - .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)); - } else { - String responseWithFlagChange = "{\"ff\":{\"s\":2000,\"t\":2000,\"d\":[" + - "{\"trafficTypeName\":\"user\",\"name\":\"polling_flag\",\"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\":[]}}"; - return new MockResponse().setResponseCode(200).setBody(responseWithFlagChange); - } - } 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.targetingRulesChangesWithFlag("polling_flag", 2000), + count -> IntegrationHelper.dummyAllSegments() + )); SplitClientConfig config = new TestableSplitConfigBuilder() .serviceEndpoints(endpoints()) @@ -1179,8 +1122,8 @@ public void sdkReadyMetadataNotNullOnFreshInstall() throws Exception { @Test public void sdkUpdateMetadataForSingleClientMembershipPolling() throws Exception { AtomicInteger key1MembershipHits = new AtomicInteger(0); - final String initialMemberships = "{\"ms\":{\"k\":[{\"n\":\"segment1\"}],\"cn\":1000},\"ls\":{\"k\":[],\"cn\":1000}}"; - final String updatedMembershipsKey1 = "{\"ms\":{\"k\":[{\"n\":\"segment2\"}],\"cn\":2000},\"ls\":{\"k\":[],\"cn\":1000}}"; + final String initialMemberships = IntegrationHelper.membershipsResponse(new String[]{"segment1"}, 1000); + final String updatedMemberships = IntegrationHelper.membershipsResponse(new String[]{"segment2"}, 2000); mWebServer.setDispatcher(new Dispatcher() { @Override @@ -1190,7 +1133,7 @@ public MockResponse dispatch(RecordedRequest request) { if (path.contains("key_1")) { int count = key1MembershipHits.incrementAndGet(); return new MockResponse().setResponseCode(200) - .setBody(count <= 1 ? initialMemberships : updatedMembershipsKey1); + .setBody(count <= 1 ? initialMemberships : updatedMemberships); } return new MockResponse().setResponseCode(200).setBody(initialMemberships); } else if (path.contains("/splitChanges")) { @@ -1236,8 +1179,8 @@ public MockResponse dispatch(RecordedRequest request) { public void sdkUpdateMetadataForSingleClientMembershipStreaming() throws Exception { TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("key1"), new Key("key2")); - EventCapture client1Capture = captureUpdateEvent(fixture.clientA); - EventCapture client2Capture = captureUpdateEvent(fixture.clientB); + EventCapture client1Capture = captureUpdateEvent(fixture.mClientA); + EventCapture client2Capture = captureUpdateEvent(fixture.mClientB); // Keylist update: only key1 is included fixture.pushMembershipKeyListUpdate("key1", "streaming_segment"); @@ -1266,10 +1209,8 @@ public void sdkUpdateMetadataForSingleClientMembershipStreaming() throws Excepti @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) ); } @@ -1284,10 +1225,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); @@ -1582,10 +1525,6 @@ public void multipleListenersWithOnReadyReplayToLateSubscribers() throws Excepti 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); @@ -1604,17 +1543,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<>(); @@ -1637,10 +1571,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); @@ -1661,9 +1591,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 @@ -1692,9 +1619,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); @@ -1726,10 +1650,6 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key return new TwoClientFixture(factory, clientA, clientB, streamingData); } - - /** - * Creates a SdkEventListener with both onReady and onUpdate handlers. - */ private SdkEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch, AtomicInteger updateCount, CountDownLatch updateLatch) { return new SdkEventListener() { @@ -1821,60 +1741,39 @@ 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 (streamingData != null) { - pushMessage(streamingData, membershipKeyListUpdateMessage(key, 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); - String keyListJson = "{\"a\":[" + hashedKey.toString() + "],\"r\":[]}"; - String encodedKeyList = Base64.encodeToString( - keyListJson.getBytes(io.split.android.client.utils.StringHelper.defaultCharset()), - Base64.NO_WRAP); - - String notificationJson = "{" + - "\\\"type\\\":\\\"MEMBERSHIPS_MS_UPDATE\\\"," + - "\\\"cn\\\":2000," + - "\\\"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"; + return IntegrationHelper.membershipKeyListUpdate(hashedKey, segmentName, 2000); } private static void pushMessage(BlockingQueue queue, String message) { try { @@ -1897,7 +1796,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); @@ -1907,14 +1806,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); } @@ -1946,20 +1845,11 @@ 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)); } - // ==================== Event Capture Helpers ==================== - - /** - * Container for capturing event invocations with count, metadata, and latch. - * Reduces boilerplate of creating separate AtomicInteger, AtomicReference, and CountDownLatch. - */ private static class EventCapture { final AtomicInteger count = new AtomicInteger(0); final AtomicReference metadata = new AtomicReference<>(); @@ -1984,18 +1874,11 @@ void increment() { latch.countDown(); } - boolean await() throws InterruptedException { - return await(10); - } - boolean await(int seconds) throws InterruptedException { return latch.await(seconds, TimeUnit.SECONDS); } } - /** - * Awaits a latch and asserts the event fired within timeout. - */ private void awaitEvent(CountDownLatch latch, String eventName) throws InterruptedException { awaitEvent(latch, eventName, 10); } @@ -2005,23 +1888,14 @@ private void awaitEvent(CountDownLatch latch, String eventName, int timeoutSecon assertTrue(eventName + " should fire", fired); } - /** - * Asserts that an event was fired exactly once. - */ private void assertFiredOnce(AtomicInteger count, String eventName) { assertEquals(eventName + " should be invoked exactly once", 1, count.get()); } - /** - * Asserts that an event was fired the expected number of times. - */ private void assertFiredTimes(AtomicInteger count, String eventName, int expectedTimes) { assertEquals(eventName + " should be invoked " + expectedTimes + " time(s)", expectedTimes, count.get()); } - /** - * Registers an onReady listener and returns an EventCapture for the results. - */ private EventCapture captureReadyEvent(SplitClient client) { EventCapture capture = new EventCapture<>(); client.addEventListener(new SdkEventListener() { @@ -2033,9 +1907,6 @@ public void onReady(SplitClient c, SdkReadyMetadata metadata) { return capture; } - /** - * Registers an onReadyFromCache listener and returns an EventCapture for the results. - */ private EventCapture captureCacheReadyEvent(SplitClient client) { EventCapture capture = new EventCapture<>(); client.addEventListener(new SdkEventListener() { @@ -2047,9 +1918,6 @@ public void onReadyFromCache(SplitClient c, SdkReadyMetadata metadata) { return capture; } - /** - * Registers an onUpdate listener and returns an EventCapture for the results. - */ private EventCapture captureUpdateEvent(SplitClient client) { return captureUpdateEvent(client, 1); } @@ -2065,9 +1933,6 @@ public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { return capture; } - /** - * Registers a legacy SDK_READY handler with latch countdown. - */ private CountDownLatch captureLegacyReadyEvent(SplitClient client) { CountDownLatch latch = new CountDownLatch(1); client.on(SplitEvent.SDK_READY, new SplitEventTask() { @@ -2081,17 +1946,17 @@ public void onPostExecution(SplitClient c) { /** * Creates a polling dispatcher that returns different responses based on hit count. - * Useful for tests that need to verify behavior across multiple polling cycles. */ private Dispatcher createPollingDispatcher( - java.util.function.Function splitChangesResponseFn, - java.util.function.Function membershipsResponseFn) { + Function splitChangesResponseFn, + Function membershipsResponseFn) { AtomicInteger splitChangesHits = new AtomicInteger(0); AtomicInteger membershipsHits = new AtomicInteger(0); return new Dispatcher() { + @NonNull @Override - public MockResponse dispatch(RecordedRequest request) { + public MockResponse dispatch(@NonNull RecordedRequest request) { final String path = request.getPath(); if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { int count = membershipsHits.incrementAndGet(); @@ -2113,9 +1978,6 @@ public MockResponse dispatch(RecordedRequest request) { }; } - /** - * Creates a delayed dispatcher for timeout tests. - */ private Dispatcher createDelayedDispatcher(long delaySeconds) { return new Dispatcher() { @Override @@ -2140,9 +2002,6 @@ public MockResponse dispatch(RecordedRequest request) { }; } - /** - * Creates a polling config with specified refresh rates. - */ private SplitClientConfig createPollingConfig(int featuresRefreshRate, int segmentsRefreshRate) { return new TestableSplitConfigBuilder() .serviceEndpoints(endpoints()) @@ -2155,9 +2014,6 @@ private SplitClientConfig createPollingConfig(int featuresRefreshRate, int segme .build(); } - /** - * Waits for SDK_READY on a client and asserts it fired. - */ private void waitForReady(SplitClient client) throws InterruptedException { CountDownLatch latch = captureLegacyReadyEvent(client); awaitEvent(latch, "SDK_READY"); From e2285c8e91ed78130e5f79274d7a986f2a650438 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 16:28:29 -0300 Subject: [PATCH 08/10] Fix missing flag names --- .../service/splits/SplitsSyncHelper.java | 16 +++++++++++- .../client/service/SplitsSyncHelperTest.java | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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/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(); From 3867550b15a976f897d3189252d0cfe4b648e5b3 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 20 Jan 2026 17:28:44 -0300 Subject: [PATCH 09/10] Update event listener naming --- .../io/split/android/client/SplitClient.java | 4 +- ...tListener.java => SplitEventListener.java} | 2 +- .../android/client/events/SplitEventTask.java | 2 +- .../events/ListenableEventsManager.java | 2 +- .../client/events/SplitEventsManager.java | 14 ++-- .../java/fake/SplitClientStub.java | 4 +- .../events/SdkEventsIntegrationTest.java | 68 +++++++++---------- .../java/tests/service/EventsManagerTest.java | 6 +- .../AlwaysReturnControlSplitClient.java | 4 +- .../split/android/client/SplitClientImpl.java | 4 +- .../localhost/LocalhostSplitClient.java | 4 +- .../SplitClientImplEventRegistrationTest.java | 10 +-- .../client/events/EventsManagerTest.java | 8 +-- .../localhost/LocalhostSplitClientTest.java | 10 +-- .../android/fake/SplitEventsManagerStub.java | 4 +- 15 files changed, 72 insertions(+), 74 deletions(-) rename api/src/main/java/io/split/android/client/events/{SdkEventListener.java => SplitEventListener.java} (99%) 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/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/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/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
index 24d3283f5..51ca6beb8 100644
--- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
+++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
@@ -7,7 +7,6 @@
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
 import androidx.test.platform.app.InstrumentationRegistry;
 
 import org.junit.After;
@@ -37,14 +36,13 @@
 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.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;
@@ -246,7 +244,7 @@ public void sdkReadyListenerFiresWithMetadata() throws Exception {
         SplitClient client = factory.client(new Key("key_1"));
 
         // And: a handler H is registered using addEventListener with onReady
-        client.addEventListener(new SdkEventListener() {
+        client.addEventListener(new SplitEventListener() {
             @Override
             public void onReady(SplitClient client, SdkReadyMetadata metadata) {
                 onReadyCount.incrementAndGet();
@@ -297,7 +295,7 @@ public void sdkReadyListenerReplaysToLateSubscribers() throws Exception {
         AtomicReference receivedMetadata = new AtomicReference<>();
         CountDownLatch lateReadyLatch = new CountDownLatch(1);
 
-        fixture.client.addEventListener(new SdkEventListener() {
+        fixture.client.addEventListener(new SplitEventListener() {
             @Override
             public void onReady(SplitClient client, SdkReadyMetadata metadata) {
                 onReadyCount.incrementAndGet();
@@ -342,7 +340,7 @@ public void sdkReadyViewListenerFiresOnMainThread() throws Exception {
         SplitClient client = factory.client(new Key("key_1"));
 
         // And: a handler H is registered using addEventListener with onReadyView
-        client.addEventListener(new SdkEventListener() {
+        client.addEventListener(new SplitEventListener() {
             @Override
             public void onReadyView(SplitClient client, SdkReadyMetadata metadata) {
                 onReadyViewCount.incrementAndGet();
@@ -488,7 +486,7 @@ public void sdkUpdateEmittedOnlyAfterSdkReady() throws Exception {
         CountDownLatch updateLatch = new CountDownLatch(1);
 
         // Register handlers BEFORE SDK_READY fires
-        fixture.client.addEventListener(new SdkEventListener() {
+        fixture.client.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 updateHandlerCount.incrementAndGet();
@@ -546,7 +544,7 @@ public void sdkUpdateFiresOnAnyDataChangeEventAfterSdkReady() throws Exception {
         AtomicReference lastMetadata = new AtomicReference<>();
         CountDownLatch updateLatch = new CountDownLatch(1);
 
-        fixture.client.addEventListener(new SdkEventListener() {
+        fixture.client.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 updateHandlerCount.incrementAndGet();
@@ -591,7 +589,7 @@ public void sdkUpdateDoesNotReplayToLateSubscribers() throws Exception {
         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();
@@ -620,7 +618,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
         CountDownLatch secondUpdateLatch = new CountDownLatch(2);
         secondUpdateLatchRef.set(secondUpdateLatch);
 
-        fixture.client.addEventListener(new SdkEventListener() {
+        fixture.client.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 handler2Count.incrementAndGet();
@@ -872,7 +870,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 +879,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 +889,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();
@@ -948,7 +946,7 @@ public void metadataCorrectlyPropagatedToHandlers() throws Exception {
         CountDownLatch updateLatch = new CountDownLatch(1);
 
         // Given: a handler H is registered for sdkUpdate which inspects the received metadata
-        fixture.client.addEventListener(new SdkEventListener() {
+        fixture.client.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 updateHandlerCount.incrementAndGet();
@@ -1038,7 +1036,7 @@ public void sdkScopedEventsFanOutToMultipleClients() throws Exception {
         CountDownLatch updateLatchB = new CountDownLatch(1);
 
         // And: handlers HA and HB are registered for sdkUpdate
-        fixture.clientA.addEventListener(new SdkEventListener() {
+        fixture.clientA.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 handlerACount.incrementAndGet();
@@ -1046,7 +1044,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
             }
         });
 
-        fixture.clientB.addEventListener(new SdkEventListener() {
+        fixture.clientB.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 handlerBCount.incrementAndGet();
@@ -1092,7 +1090,7 @@ public void clientScopedEventsDoNotFanOutToOtherClients() throws Exception {
         CountDownLatch updateLatchB = new CountDownLatch(1);
 
         // And: handlers HA and HB are registered for sdkUpdate
-        fixture.clientA.addEventListener(new SdkEventListener() {
+        fixture.clientA.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 handlerACount.incrementAndGet();
@@ -1100,7 +1098,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
             }
         });
 
-        fixture.clientB.addEventListener(new SdkEventListener() {
+        fixture.clientB.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 handlerBCount.incrementAndGet();
@@ -1140,7 +1138,7 @@ public void sdkUpdateMetadataContainsTypeForFlagsUpdate() throws Exception {
         AtomicReference receivedMetadata = new AtomicReference<>();
         CountDownLatch updateLatch = new CountDownLatch(1);
 
-        fixture.client.addEventListener(new SdkEventListener() {
+        fixture.client.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 receivedMetadata.set(metadata);
@@ -1182,7 +1180,7 @@ public void sdkUpdateMetadataContainsTypeForSegmentsUpdate() throws Exception {
         AtomicReference receivedMetadata = new AtomicReference<>();
         CountDownLatch updateLatch = new CountDownLatch(1);
 
-        fixture.client.addEventListener(new SdkEventListener() {
+        fixture.client.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
                 receivedMetadata.set(metadata);
@@ -1285,7 +1283,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) {
@@ -1470,7 +1468,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) {
@@ -1841,7 +1839,7 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key
     private void registerCacheReadyHandler(SplitClient client, AtomicInteger count,
                                            AtomicReference metadata,
                                            CountDownLatch latch) {
-        client.addEventListener(new SdkEventListener() {
+        client.addEventListener(new SplitEventListener() {
             @Override
             public void onReadyFromCache(SplitClient client, SdkReadyMetadata eventMetadata) {
                 count.incrementAndGet();
@@ -1856,7 +1854,7 @@ public void onReadyFromCache(SplitClient client, SdkReadyMetadata eventMetadata)
      */
     private void registerUpdateHandler(SplitClient client, AtomicInteger count,
                                        AtomicReference metadata) {
-        client.addEventListener(new SdkEventListener() {
+        client.addEventListener(new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) {
                 count.incrementAndGet();
@@ -1881,10 +1879,10 @@ public void onPostExecution(SplitClient client) {
     /**
      * Creates a SdkEventListener that counts onReady invocations and captures metadata.
      */
-    private SdkEventListener createOnReadyListener(AtomicInteger count,
-                                                   AtomicReference metadata,
-                                                   CountDownLatch latch) {
-        return new SdkEventListener() {
+    private SplitEventListener createOnReadyListener(AtomicInteger count,
+                                                     AtomicReference metadata,
+                                                     CountDownLatch latch) {
+        return new SplitEventListener() {
             @Override
             public void onReady(SplitClient client, SdkReadyMetadata eventMetadata) {
                 if (count != null) count.incrementAndGet();
@@ -1897,10 +1895,10 @@ public void onReady(SplitClient client, SdkReadyMetadata eventMetadata) {
     /**
      * Creates a SdkEventListener that counts onUpdate invocations and captures metadata.
      */
-    private SdkEventListener createOnUpdateListener(AtomicInteger count,
-                                                    AtomicReference metadata,
-                                                    CountDownLatch latch) {
-        return new SdkEventListener() {
+    private SplitEventListener createOnUpdateListener(AtomicInteger count,
+                                                      AtomicReference metadata,
+                                                      CountDownLatch latch) {
+        return new SplitEventListener() {
             @Override
             public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) {
                 if (count != null) count.incrementAndGet();
@@ -1913,9 +1911,9 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) {
     /**
      * Creates a SdkEventListener with both onReady and onUpdate handlers.
      */
-    private SdkEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch,
-                                                AtomicInteger updateCount, CountDownLatch updateLatch) {
-        return new SdkEventListener() {
+    private SplitEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch,
+                                                  AtomicInteger updateCount, CountDownLatch updateLatch) {
+        return new SplitEventListener() {
             @Override
             public void onReady(SplitClient client, SdkReadyMetadata metadata) {
                 if (readyCount != null) readyCount.incrementAndGet();
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/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/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
     }
 }

From 6366a40754330c0359deba95a0ed0ce75eea7a4b Mon Sep 17 00:00:00 2001
From: Gaston Thea 
Date: Wed, 21 Jan 2026 11:05:45 -0300
Subject: [PATCH 10/10] Default socket factory when not specified

---
 .../client/network/HttpClientImpl.java        | 11 +++++-
 .../client/network/HttpClientTest.java        | 36 +++++++++++++++++++
 2 files changed, 46 insertions(+), 1 deletion(-)

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/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() {