diff --git a/LICENSE.txt b/LICENSE.txt index df08de3fb..b6579621e 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright © 2025 Split Software, Inc. +Copyright © 2026 Split Software, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java index 007303091..5a553d281 100644 --- a/api/src/main/java/io/split/android/client/SplitClient.java +++ b/api/src/main/java/io/split/android/client/SplitClient.java @@ -7,7 +7,7 @@ import java.util.Map; import io.split.android.client.attributes.AttributesManager; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; @@ -214,7 +214,7 @@ public interface SplitClient extends AttributesManager { * * @param listener the event listener to register. Must not be null. */ - void addEventListener(@NonNull SdkEventListener listener); + void addEventListener(@NonNull SplitEventListener listener); /** * Enqueue a new event to be sent to Split data collection services. diff --git a/api/src/main/java/io/split/android/client/events/SdkEventListener.java b/api/src/main/java/io/split/android/client/events/SplitEventListener.java similarity index 99% rename from api/src/main/java/io/split/android/client/events/SdkEventListener.java rename to api/src/main/java/io/split/android/client/events/SplitEventListener.java index c6a7a4409..424f5503b 100644 --- a/api/src/main/java/io/split/android/client/events/SdkEventListener.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventListener.java @@ -36,7 +36,7 @@ * }); * } */ -public abstract class SdkEventListener { +public abstract class SplitEventListener { /** * Called when SDK_READY event occurs, executed on a background thread. diff --git a/api/src/main/java/io/split/android/client/events/SplitEventTask.java b/api/src/main/java/io/split/android/client/events/SplitEventTask.java index c2b704cf5..7c053b55f 100644 --- a/api/src/main/java/io/split/android/client/events/SplitEventTask.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventTask.java @@ -14,7 +14,7 @@ * *

* For events with metadata (like SDK_UPDATE or SDK_READY_FROM_CACHE), use - * {@link SdkEventListener} instead for type-safe metadata access. + * {@link SplitEventListener} instead for type-safe metadata access. *

* Example usage: *

{@code
diff --git a/build.gradle b/build.gradle
index b27746e92..001dd7cbd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,3 @@
-import com.vanniktech.maven.publish.AndroidFusedLibrary
-import org.gradle.api.publish.maven.MavenPublication
-
 buildscript {
     repositories {
         google()
diff --git a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java
index 7d8061224..93e0f6c3f 100644
--- a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java
+++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java
@@ -38,6 +38,7 @@ public class EventsManagerCoordinator implements ISplitEventsManager, EventsMana
 
     private final ConcurrentMap mManagers = new ConcurrentHashMap<>();
     private final Set mTriggered = Collections.newSetFromMap(new ConcurrentHashMap());
+    private final ConcurrentMap mTriggeredMetadata = new ConcurrentHashMap<>();
     private final Object mEventLock = new Object();
 
     /**
@@ -75,6 +76,9 @@ public void notifyInternalEvent(SplitInternalEvent internalEvent, @Nullable Even
 
         synchronized (mEventLock) {
             mTriggered.add(internalEvent);
+            if (metadata != null) {
+                mTriggeredMetadata.put(internalEvent, metadata);
+            }
 
             for (ISplitEventsManager manager : mManagers.values()) {
                 manager.notifyInternalEvent(internalEvent, metadata);
@@ -123,7 +127,7 @@ public void unregisterEventsManager(Key key) {
     private void propagateTriggeredEvents(ISplitEventsManager splitEventsManager) {
         synchronized (mEventLock) {
             for (SplitInternalEvent event : mTriggered) {
-                splitEventsManager.notifyInternalEvent(event, null);
+                splitEventsManager.notifyInternalEvent(event, mTriggeredMetadata.get(event));
             }
         }
     }
diff --git a/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
index a8ad9c0f1..43498e379 100644
--- a/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
+++ b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
@@ -8,7 +8,7 @@ public interface ListenableEventsManager {
 
     void register(SplitEvent event, SplitEventTask task);
 
-    void registerEventListener(SdkEventListener listener);
+    void registerEventListener(SplitEventListener listener);
 
     boolean eventAlreadyTriggered(SplitEvent event);
 }
diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
index 5977fbbb2..8fc801117 100644
--- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
+++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
@@ -113,7 +113,7 @@ public void register(SplitEvent event, SplitEventTask task) {
     }
 
     @Override
-    public void registerEventListener(SdkEventListener listener) {
+    public void registerEventListener(SplitEventListener listener) {
         requireNonNull(listener);
 
         // Register SDK_READY handlers (bg + main)
@@ -190,7 +190,7 @@ private EventHandler createMainThreadHandler(final Sp
     }
 
     // SdkEventListener handlers for SDK_READY
-    private EventHandler createReadyBackgroundHandler(final SdkEventListener listener) {
+    private EventHandler createReadyBackgroundHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -198,7 +198,7 @@ private EventHandler createReadyBackgroundHandler(fin
         };
     }
 
-    private EventHandler createReadyMainThreadHandler(final SdkEventListener listener) {
+    private EventHandler createReadyMainThreadHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -207,7 +207,7 @@ private EventHandler createReadyMainThreadHandler(fin
     }
 
     // SdkEventListener handlers for SDK_UPDATE
-    private EventHandler createUpdateBackgroundHandler(final SdkEventListener listener) {
+    private EventHandler createUpdateBackgroundHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata);
@@ -215,7 +215,7 @@ private EventHandler createUpdateBackgroundHandler(fi
         };
     }
 
-    private EventHandler createUpdateMainThreadHandler(final SdkEventListener listener) {
+    private EventHandler createUpdateMainThreadHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata);
@@ -224,7 +224,7 @@ private EventHandler createUpdateMainThreadHandler(fi
     }
 
     // SdkEventListener handlers for SDK_READY_FROM_CACHE
-    private EventHandler createReadyFromCacheBackgroundHandler(final SdkEventListener listener) {
+    private EventHandler createReadyFromCacheBackgroundHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -232,7 +232,7 @@ private EventHandler createReadyFromCacheBackgroundHa
         };
     }
 
-    private EventHandler createReadyFromCacheMainThreadHandler(final SdkEventListener listener) {
+    private EventHandler createReadyFromCacheMainThreadHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java
index f6c09ac6f..28e2738be 100644
--- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java
+++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java
@@ -74,6 +74,15 @@ static EventsManagerConfig create() {
                 .executionLimit(SplitEvent.SDK_READY_TIMED_OUT, 1)
                 .executionLimit(SplitEvent.SDK_UPDATE, -1) // unlimited
 
+                // Metadata sources
+                .metadataSource(SplitEvent.SDK_READY, SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)
+                // Cache path: if SDK_READY_FROM_CACHE fired because cache was loaded, use storage load metadata.
+                .metadataSource(SplitEvent.SDK_READY_FROM_CACHE, cacheGroup,
+                        SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE)
+                // Sync path: if SDK_READY_FROM_CACHE fired alongside SDK_READY, use sync completion metadata.
+                .metadataSource(SplitEvent.SDK_READY_FROM_CACHE, syncGroup,
+                        SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)
+
                 .build();
     }
 }
diff --git a/events/README.md b/events/README.md
index 23a505c54..b91d8c63f 100644
--- a/events/README.md
+++ b/events/README.md
@@ -21,6 +21,8 @@ Events are configured using `EventsManagerConfig.Builder`:
 - **`prerequisite(external, prerequisiteExternal)`**: External event can only fire after the prerequisite external event has fired
 - **`suppressedBy(external, suppressorExternal)`**: External event is permanently suppressed if the suppressor external event has already fired
 - **`executionLimit(external, limit)`**: Max times the event can fire (-1 = unlimited, 1 = once only)
+- **`metadataSource(external, internal)`**: For `requireAll`, selects the internal event whose metadata will be delivered
+- **`metadataSource(external, Set, internal)`**: For `requireAny` groups, selects the metadata source per group
 
 ## Topological Sort for Evaluation Order
 
@@ -31,6 +33,9 @@ The events system uses **topological sorting** to determine the order in which e
 1. **Internal Event Arrives**: A single internal event can potentially satisfy conditions for multiple external events.
 2. **Single-Pass Evaluation**: The system iterates through a pre-computed list of external events (`mEvaluationOrder`).
 3. **Order Matters**: This list is topologically sorted so that events with dependencies (prerequisites/suppression) come *after* the events they depend on.
+4. **Metadata Selection**: When an external event fires, metadata is resolved from the configured source event:
+   - `requireAll`: use the configured source internal event
+   - `requireAny`: use the source configured for the specific group that completed
 
 ### Why It's Necessary
 
diff --git a/events/src/main/java/io/harness/events/EventsManagerConfig.java b/events/src/main/java/io/harness/events/EventsManagerConfig.java
index 79d4ccbbb..54886f1a0 100644
--- a/events/src/main/java/io/harness/events/EventsManagerConfig.java
+++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java
@@ -27,6 +27,10 @@ public final class EventsManagerConfig {
     private final Map> mSuppressedBy;
     // Execution policy: max executions per external event (-1 = unlimited)
     private final Map mExecutionLimits;
+    // Metadata source for requireAll events
+    private final Map mRequireAllMetadataSource;
+    // Metadata source for requireAny groups
+    private final Map, I>> mRequireAnyMetadataSource;
     // Topologically sorted evaluation order (prerequisites and suppressors come before dependents)
     private final List mEvaluationOrder;
 
@@ -43,7 +47,9 @@ private EventsManagerConfig(Map> requireAll,
                                 Map>> requireAny,
                                 Map> prerequisites,
                                 Map> suppressedBy,
-                                Map executionLimits) {
+                                Map executionLimits,
+                                Map requireAllMetadataSource,
+                                Map, I>> requireAnyMetadataSource) {
         mRequireAll = requireAll == null
                 ? Collections.emptyMap()
                 : Collections.unmodifiableMap(new HashMap<>(requireAll));
@@ -59,12 +65,20 @@ private EventsManagerConfig(Map> requireAll,
         mExecutionLimits = executionLimits == null
                 ? Collections.emptyMap()
                 : Collections.unmodifiableMap(new HashMap<>(executionLimits));
+        mRequireAllMetadataSource = requireAllMetadataSource == null
+                ? Collections.emptyMap()
+                : Collections.unmodifiableMap(new HashMap<>(requireAllMetadataSource));
+        mRequireAnyMetadataSource = requireAnyMetadataSource == null
+                ? Collections.emptyMap()
+                : Collections.unmodifiableMap(new HashMap<>(requireAnyMetadataSource));
         
         mEvaluationOrder = computeEvaluationOrder();
     }
 
     public static  EventsManagerConfig empty() {
         return new EventsManagerConfig<>(Collections.emptyMap(),
+                Collections.emptyMap(),
+                Collections.emptyMap(),
                 Collections.emptyMap(),
                 Collections.emptyMap(),
                 Collections.emptyMap(),
@@ -107,6 +121,16 @@ public Map getExecutionLimits() {
         return mExecutionLimits;
     }
 
+    @NotNull
+    public Map getRequireAllMetadataSource() {
+        return mRequireAllMetadataSource;
+    }
+
+    @NotNull
+    public Map, I>> getRequireAnyMetadataSource() {
+        return mRequireAnyMetadataSource;
+    }
+
     @NotNull
     public List getEvaluationOrder() {
         return mEvaluationOrder;
@@ -135,6 +159,8 @@ public static final class Builder {
         private final Map> mPrerequisites = new HashMap<>();
         private final Map> mSuppressedBy = new HashMap<>();
         private final Map mExecutionLimits = new HashMap<>();
+        private final Map mRequireAllMetadataSource = new HashMap<>();
+        private final Map, I>> mRequireAnyMetadataSource = new HashMap<>();
 
         private Builder() {
         }
@@ -242,6 +268,36 @@ public Builder executionLimit(E externalEvent, int limit) {
             return this;
         }
 
+        /**
+         * Sets the metadata source for a requireAll external event.
+         *
+         * @param externalEvent the external event
+         * @param sourceEvent   the internal event whose metadata should be used
+         * @return this builder
+         */
+        public Builder metadataSource(E externalEvent, I sourceEvent) {
+            mRequireAllMetadataSource.put(externalEvent, sourceEvent);
+            return this;
+        }
+
+        /**
+         * Sets the metadata source for a requireAny group.
+         *
+         * @param externalEvent the external event
+         * @param group         the internal event group
+         * @param sourceEvent   the internal event whose metadata should be used
+         * @return this builder
+         */
+        public Builder metadataSource(E externalEvent, Set group, I sourceEvent) {
+            Map, I> groupSources = mRequireAnyMetadataSource.get(externalEvent);
+            if (groupSources == null) {
+                groupSources = new HashMap<>();
+                mRequireAnyMetadataSource.put(externalEvent, groupSources);
+            }
+            groupSources.put(new HashSet<>(group), sourceEvent);
+            return this;
+        }
+
         /**
          * Builds the EventsManagerConfig.
          *
@@ -253,7 +309,9 @@ public EventsManagerConfig build() {
                     mRequireAny.isEmpty() ? null : mRequireAny,
                     mPrerequisites.isEmpty() ? null : mPrerequisites,
                     mSuppressedBy.isEmpty() ? null : mSuppressedBy,
-                    mExecutionLimits.isEmpty() ? null : mExecutionLimits
+                    mExecutionLimits.isEmpty() ? null : mExecutionLimits,
+                    mRequireAllMetadataSource.isEmpty() ? null : mRequireAllMetadataSource,
+                    mRequireAnyMetadataSource.isEmpty() ? null : mRequireAnyMetadataSource
             );
         }
     }
diff --git a/events/src/main/java/io/harness/events/EventsManagerCore.java b/events/src/main/java/io/harness/events/EventsManagerCore.java
index 4368417b9..d93baf2cf 100644
--- a/events/src/main/java/io/harness/events/EventsManagerCore.java
+++ b/events/src/main/java/io/harness/events/EventsManagerCore.java
@@ -26,6 +26,7 @@ class EventsManagerCore implements EventsManager {
     private final Map>> mSubscriptions = new HashMap<>();
     private final Map mTriggerCount = new HashMap<>();
     private final Set mSeenInternal = new HashSet<>();
+    private final Map mInternalEventMetadata = new HashMap<>();
 
     @NotNull
     private final EventsManagerConfig mConfig;
@@ -146,6 +147,9 @@ private void processInternal(I event, M metadata) {
                 return;
             }
             mSeenInternal.add(event);
+            if (metadata != null) {
+                mInternalEventMetadata.put(event, metadata);
+            }
             currentSeenInternal = new HashSet<>(mSeenInternal);
         }
 
@@ -153,14 +157,15 @@ private void processInternal(I event, M metadata) {
         // before their dependents.
         for (E externalEvent : mConfig.getEvaluationOrder()) {
             // Check if internal trigger conditions are met (RequireAll or RequireAny)
-            boolean internalConditionsMet = checkInternalTriggerConditions(externalEvent, currentSeenInternal, event);
-            
-            if (!internalConditionsMet) {
+            InternalTriggerMatch match = checkInternalTriggerConditions(externalEvent, currentSeenInternal, event);
+
+            if (!match.mMatched) {
                 continue;
             }
 
             // Check external guards (prerequisites and suppression) and fire if all conditions met
-            triggerIfConditionsMet(externalEvent, metadata);
+            M resolvedMetadata = resolveMetadata(externalEvent, match, metadata);
+            triggerIfConditionsMet(externalEvent, resolvedMetadata);
         }
     }
 
@@ -249,10 +254,10 @@ private boolean isSuppressed(E external) {
      * @param seenInternal all internal events seen so far
      * @param currentEvent the internal event that just arrived
      */
-    private boolean checkInternalTriggerConditions(E externalEvent, Set seenInternal, I currentEvent) {
+    private InternalTriggerMatch checkInternalTriggerConditions(E externalEvent, Set seenInternal, I currentEvent) {
         Set requireAll = mConfig.getRequireAll().get(externalEvent);
         if (requireAll != null && !requireAll.isEmpty() && seenInternal.containsAll(requireAll)) {
-            return true;
+            return InternalTriggerMatch.requireAll();
         }
 
         // Check RequireAny: The CURRENT internal event must be in one of the groups,
@@ -262,12 +267,65 @@ private boolean checkInternalTriggerConditions(E externalEvent, Set seenInter
             for (Set group : requireAnyGroups) {
                 // Only consider groups that contain the current event
                 if (!group.isEmpty() && group.contains(currentEvent) && seenInternal.containsAll(group)) {
-                    return true;
+                    return InternalTriggerMatch.requireAny(group);
                 }
             }
         }
 
-        return false;
+        return InternalTriggerMatch.none();
+    }
+
+    private M resolveMetadata(E externalEvent, InternalTriggerMatch match, M currentMetadata) {
+        if (match.mRequireAllMatched) {
+            I sourceEvent = mConfig.getRequireAllMetadataSource().get(externalEvent);
+            return resolveMetadataFromSource(sourceEvent, currentMetadata);
+        }
+
+        if (match.mRequireAnyGroup != null) {
+            Map, I> groupSources = mConfig.getRequireAnyMetadataSource().get(externalEvent);
+            if (groupSources != null) {
+                I sourceEvent = groupSources.get(match.mRequireAnyGroup);
+                return resolveMetadataFromSource(sourceEvent, currentMetadata);
+            }
+        }
+
+        return resolveMetadataFromSource(null, currentMetadata);
+    }
+
+    private M resolveMetadataFromSource(I sourceEvent, M currentMetadata) {
+        if (sourceEvent != null) {
+            synchronized (mLock) {
+                M stored = mInternalEventMetadata.get(sourceEvent);
+                if (stored != null) {
+                    return stored;
+                }
+            }
+        }
+        return currentMetadata;
+    }
+
+    private static class InternalTriggerMatch {
+        private final boolean mMatched;
+        private final boolean mRequireAllMatched;
+        private final Set mRequireAnyGroup;
+
+        private InternalTriggerMatch(boolean matched, boolean requireAllMatched, Set requireAnyGroup) {
+            mMatched = matched;
+            mRequireAllMatched = requireAllMatched;
+            mRequireAnyGroup = requireAnyGroup;
+        }
+
+        private static  InternalTriggerMatch requireAll() {
+            return new InternalTriggerMatch<>(true, true, null);
+        }
+
+        private static  InternalTriggerMatch requireAny(Set group) {
+            return new InternalTriggerMatch<>(true, false, group);
+        }
+
+        private static  InternalTriggerMatch none() {
+            return new InternalTriggerMatch<>(false, false, null);
+        }
     }
 
 }
diff --git a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java
index 5fafb6fe6..2662e2aeb 100644
--- a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java
+++ b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java
@@ -24,6 +24,8 @@ public void emptyBuilderCreatesEmptyMaps() {
         assertTrue(config.getPrerequisites().isEmpty());
         assertTrue(config.getSuppressedBy().isEmpty());
         assertTrue(config.getExecutionLimits().isEmpty());
+        assertTrue(config.getRequireAllMetadataSource().isEmpty());
+        assertTrue(config.getRequireAnyMetadataSource().isEmpty());
     }
 
     @Test
@@ -34,6 +36,8 @@ public void builderCreatesConfigWithAllFields() {
                 .prerequisite("E1", "E0")
                 .suppressedBy("E1", "E2")
                 .executionLimit("E1", 3)
+                .metadataSource("E1", "I2")
+                .metadataSource("E2", Collections.singleton("I3"), "I3")
                 .build();
 
         assertEquals(1, config.getRequireAll().size());
@@ -54,6 +58,10 @@ public void builderCreatesConfigWithAllFields() {
 
         assertEquals(1, config.getExecutionLimits().size());
         assertEquals(Integer.valueOf(3), config.getExecutionLimits().get("E1"));
+
+        assertEquals("I2", config.getRequireAllMetadataSource().get("E1"));
+        assertEquals("I3", config.getRequireAnyMetadataSource().get("E2")
+                .get(Collections.singleton("I3")));
     }
 
     @Test
@@ -90,6 +98,7 @@ public void returnedMapsAreUnmodifiable() {
                 .prerequisite("E1", "E0")
                 .suppressedBy("E1", "E2")
                 .executionLimit("E1", 3)
+                .metadataSource("E1", "I1")
                 .build();
 
         try {
@@ -126,6 +135,20 @@ public void returnedMapsAreUnmodifiable() {
         } catch (UnsupportedOperationException expected) {
             // expected
         }
+
+        try {
+            config.getRequireAllMetadataSource().put("E2", "I2");
+            Assert.fail("getRequireAllMetadataSource() should return an unmodifiable map");
+        } catch (UnsupportedOperationException expected) {
+            // expected
+        }
+
+        try {
+            config.getRequireAnyMetadataSource().put("E2", Collections.singletonMap(Collections.singleton("I2"), "I2"));
+            Assert.fail("getRequireAnyMetadataSource() should return an unmodifiable map");
+        } catch (UnsupportedOperationException expected) {
+            // expected
+        }
     }
 
     @Test
@@ -137,6 +160,8 @@ public void emptyMethodReturnsEmptyUnmodifiableConfig() {
         assertTrue(config.getPrerequisites().isEmpty());
         assertTrue(config.getSuppressedBy().isEmpty());
         assertTrue(config.getExecutionLimits().isEmpty());
+        assertTrue(config.getRequireAllMetadataSource().isEmpty());
+        assertTrue(config.getRequireAnyMetadataSource().isEmpty());
 
         try {
             config.getRequireAll().put("E1", Collections.singleton("I1"));
diff --git a/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java b/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java
new file mode 100644
index 000000000..c543d9b21
--- /dev/null
+++ b/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java
@@ -0,0 +1,90 @@
+package io.harness.events;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class EventsManagerMetadataTest {
+
+    private static final long TIMEOUT_MS = 5000;
+
+    enum ExternalEvent {
+        READY_FROM_CACHE
+    }
+
+    enum InternalEvent {
+        CACHE_A, CACHE_B, SYNC_A, SYNC_B
+    }
+
+    @Test
+    public void requireAnyUsesGroupMetadataSource() throws InterruptedException {
+        Set cacheGroup = new HashSet<>();
+        cacheGroup.add(InternalEvent.CACHE_A);
+        cacheGroup.add(InternalEvent.CACHE_B);
+
+        Set syncGroup = new HashSet<>();
+        syncGroup.add(InternalEvent.SYNC_A);
+        syncGroup.add(InternalEvent.SYNC_B);
+
+        EventsManagerConfig config = EventsManagerConfig.builder()
+                .requireAny(ExternalEvent.READY_FROM_CACHE, cacheGroup, syncGroup)
+                .metadataSource(ExternalEvent.READY_FROM_CACHE, cacheGroup, InternalEvent.CACHE_A)
+                .metadataSource(ExternalEvent.READY_FROM_CACHE, syncGroup, InternalEvent.SYNC_A)
+                .executionLimit(ExternalEvent.READY_FROM_CACHE, 1)
+                .build();
+
+        CountDownLatch latch = new CountDownLatch(1);
+        AtomicReference received = new AtomicReference<>();
+
+        EventsManager manager =
+                new EventsManagerCore<>(config, (handler, event, metadata) -> {
+                    handler.handle(event, metadata);
+                    latch.countDown();
+                });
+
+        manager.register(ExternalEvent.READY_FROM_CACHE, (event, metadata) -> received.set(metadata));
+
+        // Complete sync group: metadata should come from SYNC_A, not from SYNC_B (current event).
+        manager.notifyInternalEvent(InternalEvent.SYNC_A, "sync-meta");
+        manager.notifyInternalEvent(InternalEvent.SYNC_B, "sync-b-meta");
+
+        assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+        assertEquals("sync-meta", received.get());
+    }
+
+    @Test
+    public void requireAllUsesConfiguredMetadataSource() throws InterruptedException {
+        EventsManagerConfig config = EventsManagerConfig.builder()
+                .requireAll(ExternalEvent.READY_FROM_CACHE, InternalEvent.CACHE_A, InternalEvent.CACHE_B)
+                .metadataSource(ExternalEvent.READY_FROM_CACHE, InternalEvent.CACHE_A)
+                .executionLimit(ExternalEvent.READY_FROM_CACHE, 1)
+                .build();
+
+        CountDownLatch latch = new CountDownLatch(1);
+        AtomicReference received = new AtomicReference<>();
+
+        EventsManager manager =
+                new EventsManagerCore<>(config, (handler, event, metadata) -> {
+                    handler.handle(event, metadata);
+                    latch.countDown();
+                });
+
+        manager.register(ExternalEvent.READY_FROM_CACHE, (event, metadata) -> received.set(metadata));
+
+        // Provide metadata on CACHE_A only; CACHE_B completes the requireAll.
+        manager.notifyInternalEvent(InternalEvent.CACHE_A, "cache-meta");
+        manager.notifyInternalEvent(InternalEvent.CACHE_B, null);
+
+        assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+        assertNotNull(received.get());
+        assertEquals("cache-meta", received.get());
+    }
+}
diff --git a/main/src/androidTest/java/fake/SplitClientStub.java b/main/src/androidTest/java/fake/SplitClientStub.java
index b9d354bf9..14c0fcde4 100644
--- a/main/src/androidTest/java/fake/SplitClientStub.java
+++ b/main/src/androidTest/java/fake/SplitClientStub.java
@@ -11,7 +11,7 @@
 import io.split.android.client.EvaluationOptions;
 import io.split.android.client.SplitClient;
 import io.split.android.client.SplitResult;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
 import io.split.android.client.events.SplitEvent;
 import io.split.android.client.events.SplitEventTask;
 
@@ -122,7 +122,7 @@ public void on(SplitEvent event, SplitEventTask task) {
     }
 
     @Override
-    public void addEventListener(SdkEventListener listener) {
+    public void addEventListener(SplitEventListener listener) {
         // Stub implementation - does nothing
     }
 
diff --git a/main/src/androidTest/java/helper/IntegrationHelper.java b/main/src/androidTest/java/helper/IntegrationHelper.java
index 40062cd6d..7d99b3fe0 100644
--- a/main/src/androidTest/java/helper/IntegrationHelper.java
+++ b/main/src/androidTest/java/helper/IntegrationHelper.java
@@ -56,6 +56,12 @@
 public class IntegrationHelper {
     public static final int NEVER_REFRESH_RATE = 999999;
 
+    // Base64-encoded split definition payload for "mauro_java" split
+    public static final String SPLIT_UPDATE_PAYLOAD_TYPE0 = "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0=";
+
+    // Base64-encoded RBS definition payload for "rbs_test" segment
+    public static final String RBS_UPDATE_PAYLOAD_TYPE0 = "eyJuYW1lIjoicmJzX3Rlc3QiLCJzdGF0dXMiOiJBQ1RJVkUiLCJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiZXhjbHVkZWQiOnsia2V5cyI6W10sInNlZ21lbnRzIjpbXX0sImNvbmRpdGlvbnMiOlt7Im1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX19XX0=";
+
     private final static Type EVENT_LIST_TYPE = new TypeToken>() {
     }.getType();
     private final static Type IMPRESSIONS_LIST_TYPE = new TypeToken>() {
@@ -188,6 +194,68 @@ public static String dummySingleSegment(String segment) {
         return "{\"ms\":{\"k\":[{\"n\":\"" + segment + "\"}],\"cn\":null},\"ls\":{\"k\":[],\"cn\":1702507130121}}";
     }
 
+    /**
+     * Builds a memberships response with custom segments and change number.
+     * @param segments Array of segment names for my segments
+     * @param msCn Change number for my segments (null if not needed)
+     * @param largeSegments Array of segment names for large segments
+     * @param lsCn Change number for large segments
+     */
+    public static String membershipsResponse(String[] segments, Long msCn, String[] largeSegments, Long lsCn) {
+        StringBuilder msSegments = new StringBuilder();
+        for (int i = 0; i < segments.length; i++) {
+            if (i > 0) msSegments.append(",");
+            msSegments.append("{\"n\":\"").append(segments[i]).append("\"}");
+        }
+
+        StringBuilder lsSegments = new StringBuilder();
+        for (int i = 0; i < largeSegments.length; i++) {
+            if (i > 0) lsSegments.append(",");
+            lsSegments.append("{\"n\":\"").append(largeSegments[i]).append("\"}");
+        }
+
+        return String.format("{\"ms\":{\"k\":[%s],\"cn\":%s},\"ls\":{\"k\":[%s],\"cn\":%d}}",
+                msSegments, msCn, lsSegments, lsCn);
+    }
+
+    /**
+     * Simplified memberships response with only my segments.
+     */
+    public static String membershipsResponse(String[] segments, long cn) {
+        return membershipsResponse(segments, cn, new String[]{}, cn);
+    }
+
+    /**
+     * Builds a targeting rules changes response with a simple flag.
+     */
+    public static String targetingRulesChangesWithFlag(String flagName, long till) {
+        return String.format("{\"ff\":{\"s\":%d,\"t\":%d,\"d\":[" +
+                "{\"trafficTypeName\":\"user\",\"name\":\"%s\",\"status\":\"ACTIVE\"," +
+                "\"killed\":false,\"defaultTreatment\":\"off\",\"changeNumber\":%d," +
+                "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\"," +
+                "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}," +
+                "\"partitions\":[{\"treatment\":\"on\",\"size\":100}]}]}" +
+                "]},\"rbs\":{\"s\":%d,\"t\":%d,\"d\":[]}}", till, till, flagName, till, till, till);
+    }
+
+    /**
+     * Builds a targeting rules changes response with both a flag and an RBS.
+     */
+    public static String targetingRulesChangesWithFlagAndRbs(String flagName, String rbsName, long till) {
+        return String.format("{\"ff\":{\"s\":%d,\"t\":%d,\"d\":[" +
+                "{\"trafficTypeName\":\"user\",\"name\":\"%s\",\"status\":\"ACTIVE\"," +
+                "\"killed\":false,\"defaultTreatment\":\"off\",\"changeNumber\":%d," +
+                "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\"," +
+                "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}," +
+                "\"partitions\":[{\"treatment\":\"on\",\"size\":100}]}]}" +
+                "]},\"rbs\":{\"s\":%d,\"t\":%d,\"d\":[" +
+                "{\"name\":\"%s\",\"status\":\"ACTIVE\",\"trafficTypeName\":\"user\"," +
+                "\"excluded\":{\"keys\":[],\"segments\":[]}," +
+                "\"conditions\":[{\"matcherGroup\":{\"combiner\":\"AND\"," +
+                "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}}]}" +
+                "]}}", till, till, flagName, till, till, till, rbsName);
+    }
+
     public static String dummyApiKey() {
         return "99049fd8653247c5ea42bc3c1ae2c6a42bc3";
     }
@@ -303,10 +371,7 @@ public static String splitChangeV2CompressionType1() {
     }
 
     public static String splitChangeV2CompressionType0() {
-        return splitChangeV2("9999999999999",
-                "1000",
-                "0",
-                "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0=");
+        return splitChangeV2("9999999999999", "1000", "0", SPLIT_UPDATE_PAYLOAD_TYPE0);
     }
 
     public static String splitChangeV2(String changeNumber, String previousChangeNumber, String compressionType, String compressedPayload) {
@@ -506,4 +571,46 @@ public static class ServicePath {
         public static final String IMPRESSIONS = "testImpressions/bulk";
         public static final String AUTH = "v2/auth";
     }
+
+    /**
+     * Creates a simple split entity JSON body for database population.
+     */
+    public static String splitEntityBody(String name, long changeNumber) {
+        return String.format("{\"name\":\"%s\", \"changeNumber\": %d}", name, changeNumber);
+    }
+
+    /**
+     * Creates a segment list JSON for database population (my segments format).
+     * @param segments Array of segment names
+     */
+    public static String segmentListJson(String... segments) {
+        StringBuilder sb = new StringBuilder("{\"k\":[");
+        for (int i = 0; i < segments.length; i++) {
+            if (i > 0) sb.append(",");
+            sb.append("{\"n\":\"").append(segments[i]).append("\"}");
+        }
+        sb.append("],\"cn\":null}");
+        return sb.toString();
+    }
+
+    public static String membershipKeyListUpdate(java.math.BigInteger hashedKey, String segmentName, long changeNumber) {
+        String keyListJson = "{\"a\":[" + hashedKey.toString() + "],\"r\":[]}";
+        String encodedKeyList = Base64.encodeToString(
+                keyListJson.getBytes(java.nio.charset.StandardCharsets.UTF_8),
+                Base64.NO_WRAP);
+
+        String notificationJson = "{" +
+                "\\\"type\\\":\\\"MEMBERSHIPS_MS_UPDATE\\\"," +
+                "\\\"cn\\\":" + changeNumber + "," +
+                "\\\"n\\\":[\\\"" + segmentName + "\\\"]," +
+                "\\\"c\\\":0," +
+                "\\\"u\\\":2," +
+                "\\\"d\\\":\\\"" + encodedKeyList + "\\\"" +
+                "}";
+
+        return "id: 1\n" +
+                "event: message\n" +
+                "data: {\"id\":\"m1\",\"clientId\":\"pri:test\",\"timestamp\":" + System.currentTimeMillis() +
+                ",\"encoding\":\"json\",\"channel\":\"test_channel\",\"data\":\"" + notificationJson + "\"}\n";
+    }
 }
diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
index 24d3283f5..62f2dcdad 100644
--- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
+++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java
@@ -15,6 +15,7 @@
 import org.junit.Test;
 
 import java.io.IOException;
+import java.math.BigInteger;
 import java.net.URI;
 import java.util.ArrayList;
 import java.util.List;
@@ -24,6 +25,7 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
 
 import fake.HttpClientMock;
 import fake.HttpResponseMock;
@@ -37,14 +39,14 @@
 import io.split.android.client.SplitClientConfig;
 import io.split.android.client.SplitFactory;
 import io.split.android.client.api.Key;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
 import io.split.android.client.events.SdkReadyMetadata;
 import io.split.android.client.events.SdkUpdateMetadata;
 import io.split.android.client.events.SplitEvent;
 import io.split.android.client.events.SplitEventTask;
+import io.split.android.client.service.sseclient.notifications.MySegmentsV2PayloadDecoder;
 import io.split.android.client.network.HttpMethod;
 import io.split.android.client.storage.db.GeneralInfoEntity;
-import io.split.android.client.storage.db.MyLargeSegmentEntity;
 import io.split.android.client.storage.db.MySegmentEntity;
 import io.split.android.client.storage.db.SplitEntity;
 import io.split.android.client.storage.db.SplitRoomDatabase;
@@ -142,36 +144,25 @@ public void tearDown() throws Exception {
     @Test
     public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception {
         // Given: SDK is starting with populated persistent storage
-        long testTimestamp = System.currentTimeMillis();
-        populateDatabaseWithCacheData(testTimestamp);
-
-        SplitClientConfig config = buildConfig();
-        SplitFactory factory = buildFactory(config);
-
-        // And: a handler H is registered for sdkReadyFromCache
-        AtomicInteger handlerInvocationCount = new AtomicInteger(0);
-        AtomicReference receivedMetadata = new AtomicReference<>();
-        CountDownLatch cacheReadyLatch = new CountDownLatch(1);
-
+        populateDatabaseWithCacheData(System.currentTimeMillis());
+        SplitFactory factory = buildFactory(buildConfig());
         SplitClient client = factory.client(new Key("key_1"));
-        registerCacheReadyHandler(client, handlerInvocationCount, receivedMetadata, cacheReadyLatch);
 
-        boolean fired = cacheReadyLatch.await(10, TimeUnit.SECONDS);
+        // And: a handler H is registered for sdkReadyFromCache
+        EventCapture capture = captureCacheReadyEvent(client);
 
         // Then: sdkReadyFromCache is emitted exactly once
-        assertTrue("SDK_READY_FROM_CACHE should fire", fired);
-        assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get());
+        awaitEvent(capture.latch, "SDK_READY_FROM_CACHE");
+        assertFiredOnce(capture.count, "SDK_READY_FROM_CACHE handler");
 
         // And: the metadata contains "initialCacheLoad" with value false
-        assertNotNull("Metadata should not be null", receivedMetadata.get());
-        Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad();
-        assertNotNull("initialCacheLoad should not be null", initialCacheLoad);
-        assertFalse("initialCacheLoad should be false for cache path", initialCacheLoad);
+        assertNotNull("Metadata should not be null", capture.metadata.get());
+        assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+        assertFalse("initialCacheLoad should be false for cache path", capture.metadata.get().isInitialCacheLoad());
 
         // And: the metadata contains "lastUpdateTimestamp" with a valid timestamp
-        Long lastUpdateTimestamp = receivedMetadata.get().getLastUpdateTimestamp();
-        assertNotNull("lastUpdateTimestamp should not be null", lastUpdateTimestamp);
-        assertTrue("lastUpdateTimestamp should be valid", lastUpdateTimestamp > 0);
+        assertNotNull("lastUpdateTimestamp should not be null", capture.metadata.get().getLastUpdateTimestamp());
+        assertTrue("lastUpdateTimestamp should be valid", capture.metadata.get().getLastUpdateTimestamp() > 0);
 
         factory.destroy();
     }
@@ -189,31 +180,20 @@ public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception {
     @Test
     public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exception {
         // Given: SDK is starting without persistent storage (fresh install)
-        // Database is already empty from setup()
-
-        SplitClientConfig config = buildConfig();
-        SplitFactory factory = buildFactory(config);
-
-        // And: a handler H is registered for sdkReadyFromCache
-        AtomicInteger handlerInvocationCount = new AtomicInteger(0);
-        AtomicReference receivedMetadata = new AtomicReference<>();
-        CountDownLatch cacheReadyLatch = new CountDownLatch(1);
-
+        SplitFactory factory = buildFactory(buildConfig());
         SplitClient client = factory.client(new Key("key_1"));
-        registerCacheReadyHandler(client, handlerInvocationCount, receivedMetadata, cacheReadyLatch);
 
-        // When: internal events "targetingRulesSyncComplete" and "membershipsSyncComplete" are notified
-        boolean fired = cacheReadyLatch.await(10, TimeUnit.SECONDS);
+        // And: a handler H is registered for sdkReadyFromCache
+        EventCapture capture = captureCacheReadyEvent(client);
 
         // Then: sdkReadyFromCache is emitted exactly once
-        assertTrue("SDK_READY_FROM_CACHE should fire", fired);
-        assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get());
+        awaitEvent(capture.latch, "SDK_READY_FROM_CACHE");
+        assertFiredOnce(capture.count, "SDK_READY_FROM_CACHE handler");
 
         // And: the metadata contains "initialCacheLoad" with value true
-        assertNotNull("Metadata should not be null", receivedMetadata.get());
-        Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad();
-        assertNotNull("initialCacheLoad should not be null", initialCacheLoad);
-        assertTrue("initialCacheLoad should be true for sync path (fresh install)", initialCacheLoad);
+        assertNotNull("Metadata should not be null", capture.metadata.get());
+        assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+        assertTrue("initialCacheLoad should be true for sync path (fresh install)", capture.metadata.get().isInitialCacheLoad());
 
         factory.destroy();
     }
@@ -232,50 +212,53 @@ public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exc
     @Test
     public void sdkReadyListenerFiresWithMetadata() throws Exception {
         // Given: SDK is starting with populated persistent storage
-        long testTimestamp = System.currentTimeMillis();
-        populateDatabaseWithCacheData(testTimestamp);
-
-        SplitClientConfig config = buildConfig();
-        SplitFactory factory = buildFactory(config);
-
-        AtomicInteger onReadyCount = new AtomicInteger(0);
-        AtomicReference receivedMetadata = new AtomicReference<>();
-        AtomicReference receivedClient = new AtomicReference<>();
-        CountDownLatch readyLatch = new CountDownLatch(1);
-
+        populateDatabaseWithCacheData(System.currentTimeMillis());
+        SplitFactory factory = buildFactory(buildConfig());
         SplitClient client = factory.client(new Key("key_1"));
 
         // And: a handler H is registered using addEventListener with onReady
-        client.addEventListener(new SdkEventListener() {
-            @Override
-            public void onReady(SplitClient client, SdkReadyMetadata metadata) {
-                onReadyCount.incrementAndGet();
-                receivedMetadata.set(metadata);
-                receivedClient.set(client);
-                readyLatch.countDown();
-            }
-        });
-
-        // When: SDK_READY fires
-        boolean fired = readyLatch.await(10, TimeUnit.SECONDS);
+        EventCapture capture = captureReadyEvent(client);
 
         // Then: onReady is invoked exactly once
-        assertTrue("onReady should fire", fired);
-        assertEquals("onReady should be invoked exactly once", 1, onReadyCount.get());
-
-        // And: the handler receives the SplitClient and SdkReadyMetadata
-        assertNotNull("Received client should not be null", receivedClient.get());
-        assertNotNull("Received metadata should not be null", receivedMetadata.get());
+        awaitEvent(capture.latch, "onReady", 30);
+        assertFiredOnce(capture.count, "onReady");
 
         // And: the metadata contains "initialCacheLoad" with value false
-        Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad();
-        assertNotNull("initialCacheLoad should not be null", initialCacheLoad);
-        assertFalse("initialCacheLoad should be false for cache path", initialCacheLoad);
+        assertNotNull("Received metadata should not be null", capture.metadata.get());
+        assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad());
+        assertFalse("initialCacheLoad should be false for cache path", capture.metadata.get().isInitialCacheLoad());
 
         // And: the metadata contains "lastUpdateTimestamp" with a valid timestamp
-        Long lastUpdateTimestamp = receivedMetadata.get().getLastUpdateTimestamp();
-        assertNotNull("lastUpdateTimestamp should not be null", lastUpdateTimestamp);
-        assertTrue("lastUpdateTimestamp should be valid", lastUpdateTimestamp > 0);
+        assertNotNull("lastUpdateTimestamp should not be null", capture.metadata.get().getLastUpdateTimestamp());
+        assertTrue("lastUpdateTimestamp should be valid", capture.metadata.get().getLastUpdateTimestamp() > 0);
+
+        factory.destroy();
+    }
+
+    /**
+     * Scenario: sdkReady metadata should be preserved for late-registered clients (warm cache)
+     * 

+ * Given the SDK is starting with populated persistent storage + * And client1 has already emitted SDK_READY + * When client2 is created and receives SDK_READY (replay) + * Then the metadata should not be null and should reflect cache path values + */ + @Test + public void sdkReadyMetadataNotNullWhenMembershipsCompletesLast() throws Exception { + populateDatabaseWithCacheData(System.currentTimeMillis()); + SplitFactory factory = buildFactory(buildConfig()); + + SplitClient client1 = factory.client(new Key("key_1")); + waitForReady(client1); + + SplitClient client2 = factory.client(new Key("key_2")); + EventCapture capture = captureReadyEvent(client2); + awaitEvent(capture.latch, "Client2 SDK_READY"); + + assertNotNull("Metadata should not be null", capture.metadata.get()); + assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad()); + assertFalse("initialCacheLoad should be false for cache path", capture.metadata.get().isInitialCacheLoad()); + assertNotNull("lastUpdateTimestamp should not be null", capture.metadata.get().getLastUpdateTimestamp()); factory.destroy(); } @@ -293,28 +276,16 @@ public void sdkReadyListenerReplaysToLateSubscribers() throws Exception { TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); // When: a new handler H is registered for onReady after SDK_READY has fired - AtomicInteger onReadyCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); - CountDownLatch lateReadyLatch = new CountDownLatch(1); - - fixture.client.addEventListener(new SdkEventListener() { - @Override - public void onReady(SplitClient client, SdkReadyMetadata metadata) { - onReadyCount.incrementAndGet(); - receivedMetadata.set(metadata); - lateReadyLatch.countDown(); - } - }); + EventCapture capture = captureReadyEvent(fixture.client); // Then: onReady handler H is invoked exactly once immediately (replay) - boolean replayFired = lateReadyLatch.await(5, TimeUnit.SECONDS); - assertTrue("Late onReady handler should receive replay", replayFired); - assertEquals("Late onReady handler should be invoked exactly once", 1, onReadyCount.get()); - assertNotNull("Metadata should not be null on replay", receivedMetadata.get()); + awaitEvent(capture.latch, "Late onReady handler replay", 5); + assertFiredOnce(capture.count, "Late onReady handler"); + assertNotNull("Metadata should not be null on replay", capture.metadata.get()); // And: onReady is not emitted again (verify no additional invocations) Thread.sleep(500); - assertEquals("Late handler should not be invoked again", 1, onReadyCount.get()); + assertFiredOnce(capture.count, "Late handler"); fixture.destroy(); } @@ -330,32 +301,22 @@ public void onReady(SplitClient client, SdkReadyMetadata metadata) { @Test public void sdkReadyViewListenerFiresOnMainThread() throws Exception { // Given: SDK is starting with populated persistent storage - long testTimestamp = System.currentTimeMillis(); - populateDatabaseWithCacheData(testTimestamp); - - SplitClientConfig config = buildConfig(); - SplitFactory factory = buildFactory(config); - - AtomicInteger onReadyViewCount = new AtomicInteger(0); - CountDownLatch readyViewLatch = new CountDownLatch(1); - + populateDatabaseWithCacheData(System.currentTimeMillis()); + SplitFactory factory = buildFactory(buildConfig()); SplitClient client = factory.client(new Key("key_1")); // And: a handler H is registered using addEventListener with onReadyView - client.addEventListener(new SdkEventListener() { + EventCapture capture = new EventCapture<>(); + client.addEventListener(new SplitEventListener() { @Override - public void onReadyView(SplitClient client, SdkReadyMetadata metadata) { - onReadyViewCount.incrementAndGet(); - readyViewLatch.countDown(); + public void onReadyView(SplitClient c, SdkReadyMetadata metadata) { + capture.capture(metadata); } }); - // When: SDK_READY fires - boolean fired = readyViewLatch.await(10, TimeUnit.SECONDS); - // Then: onReadyView is invoked - assertTrue("onReadyView should fire", fired); - assertEquals("onReadyView should be invoked exactly once", 1, onReadyViewCount.get()); + awaitEvent(capture.latch, "onReadyView"); + assertFiredOnce(capture.count, "onReadyView"); factory.destroy(); } @@ -377,59 +338,20 @@ public void onReadyView(SplitClient client, SdkReadyMetadata metadata) { */ @Test public void sdkReadyFiresAfterSdkReadyFromCacheAndRequiresSyncCompletion() throws Exception { - // Given: SDK has not yet emitted sdkReady - // Use fresh install (no cache) so SDK_READY_FROM_CACHE fires via sync path, - // then SDK_READY fires after sync completes - // Database is already empty from setup() - - SplitClientConfig config = buildConfig(); - SplitFactory factory = buildFactory(config); - - // And: handlers are registered BEFORE creating client to catch all events - AtomicInteger cacheHandlerCount = new AtomicInteger(0); - AtomicInteger readyHandlerCount = new AtomicInteger(0); - CountDownLatch cacheReadyLatch = new CountDownLatch(1); - CountDownLatch readyLatch = new CountDownLatch(1); - + // Given: SDK has not yet emitted sdkReady (fresh install) + SplitFactory factory = buildFactory(buildConfig()); SplitClient client = factory.client(new Key("key_1")); - // Register handlers immediately - client.on(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { - @Override - public void onPostExecution(SplitClient client) { - cacheHandlerCount.incrementAndGet(); - cacheReadyLatch.countDown(); - } - }); - - client.on(SplitEvent.SDK_READY, new SplitEventTask() { - @Override - public void onPostExecution(SplitClient client) { - readyHandlerCount.incrementAndGet(); - readyLatch.countDown(); - } - }); + // And: handlers are registered to catch all events + EventCapture cacheCapture = captureCacheReadyEvent(client); + CountDownLatch readyLatch = captureLegacyReadyEvent(client); - // When: sync completes (happens automatically during initialization) - // SDK_READY_FROM_CACHE fires via sync path when TARGETING_RULES_SYNC_COMPLETE and MEMBERSHIPS_SYNC_COMPLETE fire // Wait for SDK_READY_FROM_CACHE first - boolean cacheFired = cacheReadyLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_READY_FROM_CACHE should fire", cacheFired); - assertEquals("Cache handler should be invoked once", 1, cacheHandlerCount.get()); + awaitEvent(cacheCapture.latch, "SDK_READY_FROM_CACHE"); + assertFiredOnce(cacheCapture.count, "Cache handler"); - // SDK_READY requires both SDK_READY_FROM_CACHE (prerequisite) and sync completion (requireAll) // Wait for SDK_READY to fire - boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); - - // Then: sdkReady is emitted exactly once - assertTrue("SDK_READY should fire after SDK_READY_FROM_CACHE and sync completion. " + - "Cache fired: " + cacheHandlerCount.get() + ", Ready fired: " + readyHandlerCount.get(), - readyFired); - assertEquals("Ready handler should be invoked exactly once", 1, readyHandlerCount.get()); - - // Verify both events fired - assertEquals("SDK_READY_FROM_CACHE should fire", 1, cacheHandlerCount.get()); - assertEquals("SDK_READY should fire after SDK_READY_FROM_CACHE", 1, readyHandlerCount.get()); + awaitEvent(readyLatch, "SDK_READY"); factory.destroy(); } @@ -448,19 +370,15 @@ public void sdkReadyReplaysToLateSubscribers() throws Exception { TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); // When: a new handler H is registered for sdkReady - AtomicInteger lateHandlerCount = new AtomicInteger(0); - CountDownLatch lateHandlerLatch = new CountDownLatch(1); - - registerReadyHandler(fixture.client, lateHandlerCount, lateHandlerLatch); + EventCapture capture = captureReadyEvent(fixture.client); // Then: handler H is invoked exactly once immediately (replay) - boolean replayFired = lateHandlerLatch.await(5, TimeUnit.SECONDS); - assertTrue("Late handler should receive replay", replayFired); - assertEquals("Late handler should be invoked exactly once", 1, lateHandlerCount.get()); + awaitEvent(capture.latch, "Late handler replay", 5); + assertFiredOnce(capture.count, "Late handler"); // And: sdkReady is not emitted again (verify no additional invocations) Thread.sleep(500); - assertEquals("Late handler should not be invoked again", 1, lateHandlerCount.get()); + assertFiredOnce(capture.count, "Late handler"); fixture.destroy(); } @@ -482,49 +400,27 @@ public void sdkUpdateEmittedOnlyAfterSdkReady() throws Exception { // Given: Create streaming client but don't wait for SDK_READY TestClientFixture fixture = createStreamingClient(new Key("key_1")); - AtomicInteger updateHandlerCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); - CountDownLatch readyLatch = new CountDownLatch(1); - CountDownLatch updateLatch = new CountDownLatch(1); - // Register handlers BEFORE SDK_READY fires - fixture.client.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - updateHandlerCount.incrementAndGet(); - receivedMetadata.set(metadata); - updateLatch.countDown(); - } - }); - - fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() { - @Override - public void onPostExecution(SplitClient client) { - readyLatch.countDown(); - } - }); + EventCapture updateCapture = captureUpdateEvent(fixture.client); + CountDownLatch readyLatch = captureLegacyReadyEvent(fixture.client); // Wait a bit to see if SDK_UPDATE fires prematurely (during initial sync) Thread.sleep(1000); // Then: sdkUpdate is not emitted because sdkReady has not fired yet - assertEquals("SDK_UPDATE should not fire before SDK_READY", 0, updateHandlerCount.get()); + assertEquals("SDK_UPDATE should not fire before SDK_READY", 0, updateCapture.count.get()); // When: SDK_READY fires - boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_READY should fire", readyFired); - - // Wait for SSE connection + awaitEvent(readyLatch, "SDK_READY"); fixture.waitForSseConnection(); // When: a new "splitsUpdated" event is notified via SSE (after SDK_READY has fired) fixture.pushSplitUpdate("2000", "1000"); // Then: sdkUpdate is emitted and handler H is invoked once - boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_UPDATE should fire after SDK_READY when splits update arrives", updateFired); - assertEquals("Handler should be invoked exactly once", 1, updateHandlerCount.get()); - assertNotNull("Metadata should not be null", receivedMetadata.get()); + awaitEvent(updateCapture.latch, "SDK_UPDATE"); + assertFiredOnce(updateCapture.count, "SDK_UPDATE handler"); + assertNotNull("Metadata should not be null", updateCapture.metadata.get()); fixture.destroy(); } @@ -542,27 +438,15 @@ public void sdkUpdateFiresOnAnyDataChangeEventAfterSdkReady() throws Exception { // Given: sdkReady has already been emitted (with streaming support) TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); - AtomicInteger updateHandlerCount = new AtomicInteger(0); - AtomicReference lastMetadata = new AtomicReference<>(); - CountDownLatch updateLatch = new CountDownLatch(1); - - fixture.client.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - updateHandlerCount.incrementAndGet(); - lastMetadata.set(metadata); - updateLatch.countDown(); - } - }); + EventCapture capture = captureUpdateEvent(fixture.client); // When: a split update notification arrives via SSE fixture.pushSplitUpdate(); // Then: sdkUpdate is emitted and handler H is invoked - boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_UPDATE should fire after split update notification", updateFired); - assertEquals("Handler should be invoked once", 1, updateHandlerCount.get()); - assertNotNull("Metadata should not be null", lastMetadata.get()); + awaitEvent(capture.latch, "SDK_UPDATE"); + assertFiredOnce(capture.count, "SDK_UPDATE handler"); + assertNotNull("Metadata should not be null", capture.metadata.get()); fixture.destroy(); } @@ -586,17 +470,15 @@ public void sdkUpdateDoesNotReplayToLateSubscribers() throws Exception { TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); AtomicInteger handler1Count = new AtomicInteger(0); - AtomicInteger handler2Count = new AtomicInteger(0); CountDownLatch firstUpdateLatch = new CountDownLatch(1); AtomicReference secondUpdateLatchRef = new AtomicReference<>(null); // And: a handler H1 is registered for sdkUpdate - fixture.client.addEventListener(new SdkEventListener() { + fixture.client.addEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler1Count.incrementAndGet(); firstUpdateLatch.countDown(); - // Count down second latch if it exists (second update) CountDownLatch secondLatch = secondUpdateLatchRef.get(); if (secondLatch != null) { secondLatch.countDown(); @@ -605,55 +487,41 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { }); // When: an internal "splitsUpdated" event is notified via SSE - // Use large change numbers to avoid any edge cases with change number validation fixture.pushSplitUpdate("2000", "1000"); // Then: sdkUpdate is emitted and handler H1 is invoked once - boolean firstUpdateFired = firstUpdateLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_UPDATE should fire for H1", firstUpdateFired); - assertEquals("H1 should be invoked once", 1, handler1Count.get()); + awaitEvent(firstUpdateLatch, "SDK_UPDATE for H1"); + assertFiredOnce(handler1Count, "H1"); - // Wait to ensure first update is fully processed and stored + // Wait to ensure first update is fully processed Thread.sleep(1000); // When: a second handler H2 is registered for sdkUpdate after one sdkUpdate has already fired CountDownLatch secondUpdateLatch = new CountDownLatch(2); secondUpdateLatchRef.set(secondUpdateLatch); - fixture.client.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - handler2Count.incrementAndGet(); - secondUpdateLatch.countDown(); - } - }); + EventCapture handler2Capture = captureUpdateEvent(fixture.client); // Then: H2 does not receive a replay for past sdkUpdate events Thread.sleep(500); - assertEquals("H2 should not receive replay", 0, handler2Count.get()); + assertEquals("H2 should not receive replay", 0, handler2Capture.count.get()); - // Ensure handlers are registered and first update is fully processed before pushing second update + // Ensure handlers are registered before pushing second update Thread.sleep(500); - - // Send keep-alive to ensure SSE connection is still active if (fixture.streamingData != null) { TestingHelper.pushKeepAlive(fixture.streamingData); } - // When: another internal "splitsUpdated" event is notified (with incrementing change number) - // Use a higher change number to ensure it's accepted after the first update + // When: another internal "splitsUpdated" event is notified fixture.pushSplitUpdate("2001", "2000"); // Then: both H1 and H2 are invoked for that second sdkUpdate - boolean secondUpdateFired = secondUpdateLatch.await(15, TimeUnit.SECONDS); - assertTrue("Second SDK_UPDATE should fire. H1 count: " + handler1Count.get() + - ", H2 count: " + handler2Count.get() + - ", secondUpdateLatch count: " + secondUpdateLatch.getCount(), secondUpdateFired); + awaitEvent(secondUpdateLatch, "Second SDK_UPDATE", 15); // H1 should now have 2 total invocations (1 from first + 1 from second) - assertEquals("H1 should have 2 total invocations", 2, handler1Count.get()); + assertFiredTimes(handler1Count, "H1", 2); // H2 should have 1 invocation (only from second update, no replay) - assertEquals("H2 should have 1 invocation (no replay)", 1, handler2Count.get()); + assertFiredOnce(handler2Capture.count, "H2"); fixture.destroy(); } @@ -672,81 +540,46 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { */ @Test public void sdkReadyTimedOutEmittedWhenReadinessTimeoutElapses() throws Exception { - // Given: handlers are registered - // And: the readiness timeout is configured to a short timeout (2 seconds) - // Use a mock server that delays responses to prevent sync from completing quickly + // Given: the readiness timeout is configured to a short timeout (2 seconds) SplitClientConfig config = SplitClientConfig.builder() .serviceEndpoints(endpoints()) - .ready(2000) // 2 second timeout + .ready(2000) .featuresRefreshRate(999999) .segmentsRefreshRate(999999) .impressionsRefreshRate(999999) - .syncEnabled(true) // Keep sync enabled but delay responses + .syncEnabled(true) .trafficType("account") .build(); // Set up mock server to delay responses so sync doesn't complete before timeout - final Dispatcher delayedDispatcher = new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) { - final String path = request.getPath(); - if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { - // Delay response to prevent sync from completing - return new MockResponse() - .setResponseCode(200) - .setBody(IntegrationHelper.dummyAllSegments()) - .setBodyDelay(5, TimeUnit.SECONDS); // 5 second delay - } else if (path.contains("/splitChanges")) { - // Delay response to prevent sync from completing - long id = mCurSplitReqId++; - return new MockResponse() - .setResponseCode(200) - .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id)) - .setBodyDelay(5, TimeUnit.SECONDS); // 5 second delay - } else if (path.contains("/testImpressions/bulk")) { - return new MockResponse().setResponseCode(200); - } - return new MockResponse().setResponseCode(404); - } - }; - mWebServer.setDispatcher(delayedDispatcher); + mWebServer.setDispatcher(createDelayedDispatcher(5)); SplitFactory factory = buildFactory(config); + SplitClient client = factory.client(new Key("key_1")); - AtomicInteger timeoutHandlerCount = new AtomicInteger(0); - AtomicInteger readyHandlerCount = new AtomicInteger(0); - CountDownLatch timeoutLatch = new CountDownLatch(1); - CountDownLatch readyLatch = new CountDownLatch(1); + EventCapture timeoutCapture = new EventCapture<>(); + AtomicInteger readyCount = new AtomicInteger(0); - SplitClient client = factory.client(new Key("key_1")); client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client) { - timeoutHandlerCount.incrementAndGet(); - timeoutLatch.countDown(); + public void onPostExecution(SplitClient c) { + timeoutCapture.increment(); } }); - client.on(SplitEvent.SDK_READY, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client) { - readyHandlerCount.incrementAndGet(); - readyLatch.countDown(); + public void onPostExecution(SplitClient c) { + readyCount.incrementAndGet(); } }); - // When: the timeout elapses without sdkReady firing (due to delayed responses) - boolean timeoutFired = timeoutLatch.await(5, TimeUnit.SECONDS); - // Then: sdkReadyTimedOut is emitted exactly once - assertTrue("SDK_READY_TIMED_OUT should fire after timeout. " + - "Timeout count: " + timeoutHandlerCount.get() + ", Ready count: " + readyHandlerCount.get(), - timeoutFired); - assertEquals("Timeout handler should be invoked once", 1, timeoutHandlerCount.get()); + awaitEvent(timeoutCapture.latch, "SDK_READY_TIMED_OUT", 5); + assertFiredOnce(timeoutCapture.count, "Timeout handler"); // And: sdkReady is not emitted (sync didn't complete in time) Thread.sleep(500); - assertEquals("SDK_READY should not fire before timeout", 0, readyHandlerCount.get()); + assertEquals("SDK_READY should not fire before timeout", 0, readyCount.get()); factory.destroy(); } @@ -765,11 +598,10 @@ public void onPostExecution(SplitClient client) { */ @Test public void sdkReadyTimedOutSuppressedWhenSdkReadyFiresBeforeTimeout() throws Exception { - // Given: handlers are registered - // And: the readiness timeout is configured to a longer timeout (10 seconds) + // Given: the readiness timeout is configured to a longer timeout (10 seconds) SplitClientConfig config = SplitClientConfig.builder() .serviceEndpoints(endpoints()) - .ready(10000) // 10 second timeout + .ready(10000) .featuresRefreshRate(999999) .segmentsRefreshRate(999999) .impressionsRefreshRate(999999) @@ -778,29 +610,25 @@ public void sdkReadyTimedOutSuppressedWhenSdkReadyFiresBeforeTimeout() throws Ex .build(); SplitFactory factory = buildFactory(config); - AtomicInteger timeoutHandlerCount = new AtomicInteger(0); - AtomicInteger readyHandlerCount = new AtomicInteger(0); - CountDownLatch readyLatch = new CountDownLatch(1); - SplitClient client = factory.client(new Key("key_1")); + + AtomicInteger timeoutCount = new AtomicInteger(0); client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client) { - timeoutHandlerCount.incrementAndGet(); + public void onPostExecution(SplitClient c) { + timeoutCount.incrementAndGet(); } }); - registerReadyHandler(client, readyHandlerCount, readyLatch); - // When: internal events for sdkReadyFromCache and sdkReady complete before the timeout elapses - boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + EventCapture readyCapture = captureReadyEvent(client); // Then: sdkReady is emitted - assertTrue("SDK_READY should fire", readyFired); - assertEquals("Ready handler should be invoked once", 1, readyHandlerCount.get()); + awaitEvent(readyCapture.latch, "SDK_READY"); + assertFiredOnce(readyCapture.count, "Ready handler"); // And: sdkReadyTimedOut is not emitted - Thread.sleep(2000); // Wait a bit to ensure timeout doesn't fire - assertEquals("SDK_READY_TIMED_OUT should not fire (suppressed)", 0, timeoutHandlerCount.get()); + Thread.sleep(2000); + assertEquals("SDK_READY_TIMED_OUT should not fire (suppressed)", 0, timeoutCount.get()); factory.destroy(); } @@ -819,26 +647,18 @@ public void onPostExecution(SplitClient client) { */ @Test public void syncCompletionDoesNotTriggerSdkUpdateDuringInitialSync() throws Exception { - // Given: handlers are registered - SplitClientConfig config = buildConfig(); - SplitFactory factory = buildFactory(config); - - AtomicInteger updateHandlerCount = new AtomicInteger(0); - AtomicInteger readyHandlerCount = new AtomicInteger(0); - CountDownLatch readyLatch = new CountDownLatch(1); - + SplitFactory factory = buildFactory(buildConfig()); SplitClient client = factory.client(new Key("key_1")); - registerUpdateHandler(client, updateHandlerCount, null); - registerReadyHandler(client, readyHandlerCount, readyLatch); + + EventCapture updateCapture = captureUpdateEvent(client); + CountDownLatch readyLatch = captureLegacyReadyEvent(client); // When: sync completes (happens automatically during initialization) - // The *_UPDATED events fire before SDK_READY, so SDK_UPDATE shouldn't fire - boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_READY should fire", readyFired); + awaitEvent(readyLatch, "SDK_READY"); // Then: sdkUpdate is NOT emitted because the *_UPDATED events were notified before sdkReady fired Thread.sleep(1000); - assertEquals("SDK_UPDATE should not fire during initial sync", 0, updateHandlerCount.get()); + assertEquals("SDK_UPDATE should not fire during initial sync", 0, updateCapture.count.get()); factory.destroy(); } @@ -872,7 +692,7 @@ public void handlersInvokedSequentiallyErrorsIsolated() throws Exception { // Given: three handlers H1, H2 and H3 are registered for sdkUpdate in that order // And: H2 throws an exception when invoked - fixture.client.addEventListener(new SdkEventListener() { + fixture.client.addEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler1Count.incrementAndGet(); @@ -881,7 +701,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { } }); - fixture.client.addEventListener(new SdkEventListener() { + fixture.client.addEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler2Count.incrementAndGet(); @@ -891,7 +711,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { } }); - fixture.client.addEventListener(new SdkEventListener() { + fixture.client.addEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler3Count.incrementAndGet(); @@ -943,30 +763,15 @@ public void metadataCorrectlyPropagatedToHandlers() throws Exception { // Given: sdkReady has already been emitted (with streaming support) TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); - AtomicInteger updateHandlerCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); - CountDownLatch updateLatch = new CountDownLatch(1); - - // Given: a handler H is registered for sdkUpdate which inspects the received metadata - fixture.client.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - updateHandlerCount.incrementAndGet(); - receivedMetadata.set(metadata); - updateLatch.countDown(); - } - }); + EventCapture capture = captureUpdateEvent(fixture.client); // When: an internal "splitsUpdated" event is notified via SSE fixture.pushSplitUpdate(); // Then: sdkUpdate is emitted and handler H is invoked once - boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_UPDATE should fire", updateFired); - assertEquals("Handler should be invoked exactly once", 1, updateHandlerCount.get()); - - // And: handler H receives metadata - assertNotNull("Metadata should not be null", receivedMetadata.get()); + awaitEvent(capture.latch, "SDK_UPDATE"); + assertFiredOnce(capture.count, "SDK_UPDATE handler"); + assertNotNull("Metadata should not be null", capture.metadata.get()); fixture.destroy(); } @@ -988,29 +793,23 @@ public void destroyingClientStopsEventsAndClearsHandlers() throws Exception { // Given: sdkReady has already been emitted (with streaming support) TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); - AtomicInteger handler1Count = new AtomicInteger(0); - AtomicInteger handler2Count = new AtomicInteger(0); - - // Given: a handler H registered for sdkUpdate before destroy - fixture.client.addEventListener(createOnUpdateListener(handler1Count, null, null)); + EventCapture handler1 = captureUpdateEvent(fixture.client); // When: the client is destroyed fixture.client.destroy(); - fixture.pushSplitUpdate("3000", "2000"); // Handler H is never invoked (handlers were cleared on destroy) Thread.sleep(1000); - assertEquals("Handler H1 should not be invoked after destroy", 0, handler1Count.get()); + assertEquals("Handler H1 should not be invoked after destroy", 0, handler1.count.get()); // When: registering a new handler H2 for sdkUpdate after destroy - fixture.client.addEventListener(createOnUpdateListener(handler2Count, null, null)); - + EventCapture handler2 = captureUpdateEvent(fixture.client); fixture.pushSplitUpdate("4000", "3000"); Thread.sleep(1000); - assertEquals("Handler H1 should still be 0", 0, handler1Count.get()); - assertEquals("Handler H2 should not be invoked after destroy", 0, handler2Count.get()); + assertEquals("Handler H1 should still be 0", 0, handler1.count.get()); + assertEquals("Handler H2 should not be invoked after destroy", 0, handler2.count.get()); fixture.destroy(); } @@ -1032,41 +831,17 @@ public void sdkScopedEventsFanOutToMultipleClients() throws Exception { // Given: a factory with two clients (with streaming support) TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("key_A"), new Key("key_B")); - AtomicInteger handlerACount = new AtomicInteger(0); - AtomicInteger handlerBCount = new AtomicInteger(0); - CountDownLatch updateLatchA = new CountDownLatch(1); - CountDownLatch updateLatchB = new CountDownLatch(1); - - // And: handlers HA and HB are registered for sdkUpdate - fixture.clientA.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - handlerACount.incrementAndGet(); - updateLatchA.countDown(); - } - }); - - fixture.clientB.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - handlerBCount.incrementAndGet(); - updateLatchB.countDown(); - } - }); + EventCapture captureA = captureUpdateEvent(fixture.mClientA); + EventCapture captureB = captureUpdateEvent(fixture.mClientB); // When: a SDK-scoped internal "splitsUpdated" event is notified via SSE fixture.pushSplitUpdate(); // Then: sdkUpdate is emitted once per client - boolean updateAFired = updateLatchA.await(10, TimeUnit.SECONDS); - boolean updateBFired = updateLatchB.await(10, TimeUnit.SECONDS); - - assertTrue("SDK_UPDATE should fire for ClientA", updateAFired); - assertTrue("SDK_UPDATE should fire for ClientB", updateBFired); - - // And: handler HA is invoked once and handler HB is invoked once - assertEquals("Handler A should be invoked once", 1, handlerACount.get()); - assertEquals("Handler B should be invoked once", 1, handlerBCount.get()); + awaitEvent(captureA.latch, "SDK_UPDATE for ClientA"); + awaitEvent(captureB.latch, "SDK_UPDATE for ClientB"); + assertFiredOnce(captureA.count, "Handler A"); + assertFiredOnce(captureB.count, "Handler B"); fixture.destroy(); } @@ -1086,39 +861,17 @@ public void clientScopedEventsDoNotFanOutToOtherClients() throws Exception { // Given: a factory with two clients (with streaming support) TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("userA"), new Key("userB")); - AtomicInteger handlerACount = new AtomicInteger(0); - AtomicInteger handlerBCount = new AtomicInteger(0); - CountDownLatch updateLatchA = new CountDownLatch(1); - CountDownLatch updateLatchB = new CountDownLatch(1); - - // And: handlers HA and HB are registered for sdkUpdate - fixture.clientA.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - handlerACount.incrementAndGet(); - updateLatchA.countDown(); - } - }); - - fixture.clientB.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - handlerBCount.incrementAndGet(); - updateLatchB.countDown(); - } - }); + EventCapture captureA = captureUpdateEvent(fixture.mClientA); + EventCapture captureB = captureUpdateEvent(fixture.mClientB); // When: a SDK-scoped split update notification arrives (affects all clients) fixture.pushSplitUpdate(); // Then: both clients receive SDK_UPDATE since splitsUpdated is SDK-scoped - boolean updateAFired = updateLatchA.await(10, TimeUnit.SECONDS); - boolean updateBFired = updateLatchB.await(10, TimeUnit.SECONDS); - - assertTrue("SDK_UPDATE should fire for ClientA", updateAFired); - assertTrue("SDK_UPDATE should fire for ClientB", updateBFired); - assertEquals("Handler A should be invoked once", 1, handlerACount.get()); - assertEquals("Handler B should be invoked once", 1, handlerBCount.get()); + awaitEvent(captureA.latch, "SDK_UPDATE for ClientA"); + awaitEvent(captureB.latch, "SDK_UPDATE for ClientB"); + assertFiredOnce(captureA.count, "Handler A"); + assertFiredOnce(captureB.count, "Handler B"); fixture.destroy(); } @@ -1137,28 +890,16 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { public void sdkUpdateMetadataContainsTypeForFlagsUpdate() throws Exception { TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); - AtomicReference receivedMetadata = new AtomicReference<>(); - CountDownLatch updateLatch = new CountDownLatch(1); - - fixture.client.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - receivedMetadata.set(metadata); - updateLatch.countDown(); - } - }); + EventCapture capture = captureUpdateEvent(fixture.client); fixture.pushSplitUpdate(); - boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_UPDATE should fire", updateFired); - - assertNotNull("Metadata should not be null", receivedMetadata.get()); + awaitEvent(capture.latch, "SDK_UPDATE"); + assertNotNull("Metadata should not be null", capture.metadata.get()); assertEquals("Type should be FLAGS_UPDATE", - SdkUpdateMetadata.Type.FLAGS_UPDATE, receivedMetadata.get().getType()); - - assertNotNull("Names should not be null", receivedMetadata.get().getNames()); - assertFalse("Names should not be empty", receivedMetadata.get().getNames().isEmpty()); + SdkUpdateMetadata.Type.FLAGS_UPDATE, capture.metadata.get().getType()); + assertNotNull("Names should not be null", capture.metadata.get().getNames()); + assertFalse("Names should not be empty", capture.metadata.get().getNames().isEmpty()); fixture.destroy(); } @@ -1179,28 +920,16 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { public void sdkUpdateMetadataContainsTypeForSegmentsUpdate() throws Exception { TestClientFixture fixture = createStreamingClientWithRbsAndWaitForReady(new Key("key_1")); - AtomicReference receivedMetadata = new AtomicReference<>(); - CountDownLatch updateLatch = new CountDownLatch(1); - - fixture.client.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - receivedMetadata.set(metadata); - updateLatch.countDown(); - } - }); + EventCapture capture = captureUpdateEvent(fixture.client); fixture.pushRbsUpdate(); - boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); - assertTrue("SDK_UPDATE should fire for RBS update", updateFired); - - assertNotNull("Metadata should not be null", receivedMetadata.get()); + awaitEvent(capture.latch, "SDK_UPDATE for RBS"); + assertNotNull("Metadata should not be null", capture.metadata.get()); assertEquals("Type should be SEGMENTS_UPDATE", - SdkUpdateMetadata.Type.SEGMENTS_UPDATE, receivedMetadata.get().getType()); - - assertNotNull("Names should not be null", receivedMetadata.get().getNames()); - assertTrue("Names should be empty for SEGMENTS_UPDATE", receivedMetadata.get().getNames().isEmpty()); + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, capture.metadata.get().getType()); + assertNotNull("Names should not be null", capture.metadata.get().getNames()); + assertTrue("Names should be empty for SEGMENTS_UPDATE", capture.metadata.get().getNames().isEmpty()); fixture.destroy(); } @@ -1217,45 +946,14 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { */ @Test public void sdkUpdateFiresOnlyOnceWhenBothFlagsAndRbsChange() throws Exception { - // Track number of /splitChanges calls AtomicInteger splitChangesHitCount = new AtomicInteger(0); - final Dispatcher pollingDispatcher = new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) { - final String path = request.getPath(); - if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { - return new MockResponse().setResponseCode(200).setBody(IntegrationHelper.dummyAllSegments()); - } else if (path.contains("/splitChanges")) { - int count = splitChangesHitCount.incrementAndGet(); - if (count <= 1) { - // Initial sync: empty - return new MockResponse().setResponseCode(200) - .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)); - } else { - // Polling sync: return BOTH flag and RBS changes - // s and t must be equal to signal end of sync loop - String responseWithBothChanges = "{\"ff\":{\"s\":2000,\"t\":2000,\"d\":[" + - "{\"trafficTypeName\":\"user\",\"name\":\"test_split\",\"status\":\"ACTIVE\"," + - "\"killed\":false,\"defaultTreatment\":\"off\",\"changeNumber\":2000," + - "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\"," + - "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}," + - "\"partitions\":[{\"treatment\":\"on\",\"size\":100}]}]}" + - "]},\"rbs\":{\"s\":2000,\"t\":2000,\"d\":[" + - "{\"name\":\"test_rbs\",\"status\":\"ACTIVE\",\"trafficTypeName\":\"user\"," + - "\"excluded\":{\"keys\":[],\"segments\":[]}," + - "\"conditions\":[{\"matcherGroup\":{\"combiner\":\"AND\"," + - "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}}]}" + - "]}}"; - return new MockResponse().setResponseCode(200).setBody(responseWithBothChanges); - } - } else if (path.contains("/testImpressions/bulk")) { - return new MockResponse().setResponseCode(200); - } - return new MockResponse().setResponseCode(404); - } - }; - mWebServer.setDispatcher(pollingDispatcher); + mWebServer.setDispatcher(createPollingDispatcher( + count -> count <= 1 + ? IntegrationHelper.emptyTargetingRulesChanges(1000, 1000) + : IntegrationHelper.targetingRulesChangesWithFlagAndRbs("test_split", "test_rbs", 2000), + count -> IntegrationHelper.dummyAllSegments() + )); // Use polling mode with short refresh rate to trigger sync quickly SplitClientConfig config = new TestableSplitConfigBuilder() @@ -1285,7 +983,7 @@ public void onPostExecution(SplitClient c) { List receivedMetadataList = new ArrayList<>(); CountDownLatch updateLatch = new CountDownLatch(1); - client.addEventListener(new SdkEventListener() { + client.addEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { synchronized (receivedMetadataList) { @@ -1329,18 +1027,183 @@ public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { @Test public void sdkUpdateMetadataContainsTypeForMembershipSegmentsUpdate() throws Exception { verifySdkUpdateForSegmentsPollingWithEmptyNames( - // Initial sync: segment1, segment2 - "{\"ms\":{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":1000},\"ls\":{\"k\":[],\"cn\":1000}}", - // Polling: segment1 removed, segment3 added - "{\"ms\":{\"k\":[{\"n\":\"segment2\"},{\"n\":\"segment3\"}],\"cn\":2000},\"ls\":{\"k\":[],\"cn\":1000}}" + IntegrationHelper.membershipsResponse(new String[]{"segment1", "segment2"}, 1000), + IntegrationHelper.membershipsResponse(new String[]{"segment2", "segment3"}, 2000) ); } /** - * Scenario: sdkUpdateMetadata contains Type.SEGMENTS_UPDATE for large segments update (polling) + * Scenario: sdkUpdateMetadata includes flag names for polling flag updates *

- * Given sdkReady has already been emitted - * And a handler H is registered for sdkUpdate + * Given sdkReady has already been emitted in polling mode + * When polling returns a flag update + * Then sdkUpdate metadata contains FLAGS_UPDATE with non-empty names + */ + @Test + public void sdkUpdateMetadataContainsNamesForPollingFlagsUpdate() throws Exception { + mWebServer.setDispatcher(createPollingDispatcher( + count -> count <= 1 + ? IntegrationHelper.emptyTargetingRulesChanges(1000, 1000) + : IntegrationHelper.targetingRulesChangesWithFlag("polling_flag", 2000), + count -> IntegrationHelper.dummyAllSegments() + )); + + SplitClientConfig config = new TestableSplitConfigBuilder() + .serviceEndpoints(endpoints()) + .ready(30000) + .featuresRefreshRate(3) + .segmentsRefreshRate(999999) + .impressionsRefreshRate(999999) + .streamingEnabled(false) + .trafficType("account") + .build(); + + SplitFactory factory = buildFactory(config); + SplitClient client = factory.client(); + + CountDownLatch readyLatch = new CountDownLatch(1); + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient c) { + readyLatch.countDown(); + } + }); + assertTrue("SDK_READY should fire", readyLatch.await(10, TimeUnit.SECONDS)); + + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch updateLatch = new CountDownLatch(1); + client.addEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + assertTrue("SDK_UPDATE should fire", updateLatch.await(15, TimeUnit.SECONDS)); + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertEquals("Type should be FLAGS_UPDATE", + SdkUpdateMetadata.Type.FLAGS_UPDATE, receivedMetadata.get().getType()); + assertNotNull("Names should not be null", receivedMetadata.get().getNames()); + assertTrue("Names should include polling_flag", receivedMetadata.get().getNames().contains("polling_flag")); + + factory.destroy(); + } + + /** + * Scenario: sdkReady should include non-null metadata on fresh install + *

+ * Given the SDK starts with empty storage (fresh install) + * When SDK_READY fires + * Then metadata should be present (initialCacheLoad=true, lastUpdateTimestamp=null) + */ + @Test + public void sdkReadyMetadataNotNullOnFreshInstall() throws Exception { + SplitFactory factory = buildFactory(buildConfig()); + SplitClient client = factory.client(new Key("key_1")); + + EventCapture capture = captureReadyEvent(client); + + awaitEvent(capture.latch, "SDK_READY"); + assertNotNull("Metadata should not be null", capture.metadata.get()); + assertNotNull("initialCacheLoad should not be null", capture.metadata.get().isInitialCacheLoad()); + assertTrue("initialCacheLoad should be true for fresh install", capture.metadata.get().isInitialCacheLoad()); + assertEquals("lastUpdateTimestamp should be null for fresh install", + null, capture.metadata.get().getLastUpdateTimestamp()); + + factory.destroy(); + } + + /** + * Scenario: sdkUpdateMetadata should include SEGMENTS_UPDATE when only one client changes (polling) + *

+ * Given two clients are created in polling mode + * And only client1 receives a membership change on polling + * When polling updates occur + * Then only client1 receives SDK_UPDATE with SEGMENTS_UPDATE metadata + */ + @Test + public void sdkUpdateMetadataForSingleClientMembershipPolling() throws Exception { + AtomicInteger key1MembershipHits = new AtomicInteger(0); + final String initialMemberships = IntegrationHelper.membershipsResponse(new String[]{"segment1"}, 1000); + final String updatedMemberships = IntegrationHelper.membershipsResponse(new String[]{"segment2"}, 2000); + + mWebServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + if (path.contains("key_1")) { + int count = key1MembershipHits.incrementAndGet(); + return new MockResponse().setResponseCode(200) + .setBody(count <= 1 ? initialMemberships : updatedMemberships); + } + return new MockResponse().setResponseCode(200).setBody(initialMemberships); + } else if (path.contains("/splitChanges")) { + return new MockResponse().setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)); + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }); + + SplitFactory factory = buildFactory(createPollingConfig(999999, 3)); + SplitClient client1 = factory.client(new Key("key_1")); + SplitClient client2 = factory.client(new Key("key_2")); + + EventCapture client1Capture = captureUpdateEvent(client1); + EventCapture client2Capture = captureUpdateEvent(client2); + + waitForReady(client1); + waitForReady(client2); + + awaitEvent(client1Capture.latch, "Client1 SDK_UPDATE", 20); + assertNotNull("Client1 metadata should not be null", client1Capture.metadata.get()); + assertEquals("Type should be SEGMENTS_UPDATE", + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Capture.metadata.get().getType()); + + Thread.sleep(1000); + assertEquals("Client2 should not receive SDK_UPDATE", 0, client2Capture.count.get()); + + factory.destroy(); + } + + /** + * Scenario: sdkUpdateMetadata contains SEGMENTS_UPDATE when only one streaming client changes + *

+ * Given two clients are created with streaming enabled + * And a membership keylist update targets only client1 + * When the SSE notification is pushed + * Then only client1 receives SDK_UPDATE with SEGMENTS_UPDATE metadata + */ + @Test + public void sdkUpdateMetadataForSingleClientMembershipStreaming() throws Exception { + TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("key1"), new Key("key2")); + + EventCapture client1Capture = captureUpdateEvent(fixture.mClientA); + EventCapture client2Capture = captureUpdateEvent(fixture.mClientB); + + // Keylist update: only key1 is included + fixture.pushMembershipKeyListUpdate("key1", "streaming_segment"); + + awaitEvent(client1Capture.latch, "Client1 SDK_UPDATE"); + assertNotNull("Client1 metadata should not be null", client1Capture.metadata.get()); + assertEquals("Type should be SEGMENTS_UPDATE", + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, client1Capture.metadata.get().getType()); + + Thread.sleep(500); + assertEquals("Client2 should not receive SDK_UPDATE", 0, client2Capture.count.get()); + + fixture.destroy(); + } + + /** + * Scenario: sdkUpdateMetadata contains Type.SEGMENTS_UPDATE for large segments update (polling) + *

+ * Given sdkReady has already been emitted + * And a handler H is registered for sdkUpdate * When large segments change via polling (server returns different large segments) * Then sdkUpdate is emitted * And handler H receives metadata with getType() returning Type.SEGMENTS_UPDATE @@ -1349,10 +1212,8 @@ public void sdkUpdateMetadataContainsTypeForMembershipSegmentsUpdate() throws Ex @Test public void sdkUpdateMetadataContainsTypeForLargeSegmentsUpdate() throws Exception { verifySdkUpdateForSegmentsPollingWithEmptyNames( - // Initial sync: large_segment1, large_segment2 - "{\"ms\":{\"k\":[],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment1\"},{\"n\":\"large_segment2\"}],\"cn\":1000}}", - // Polling: large_segment1 removed, large_segment3 added - "{\"ms\":{\"k\":[],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment2\"},{\"n\":\"large_segment3\"}],\"cn\":2000}}" + IntegrationHelper.membershipsResponse(new String[]{}, 1000L, new String[]{"large_segment1", "large_segment2"}, 1000L), + IntegrationHelper.membershipsResponse(new String[]{}, 1000L, new String[]{"large_segment2", "large_segment3"}, 2000L) ); } @@ -1367,10 +1228,12 @@ public void sdkUpdateMetadataContainsTypeForLargeSegmentsUpdate() throws Excepti */ @Test public void twoDistinctSdkUpdateEventsWhenBothSegmentsAndLargeSegmentsChange() throws Exception { - // Initial sync: segment1, segment2 in ms; large_segment1, large_segment2 in ls - String initialResponse = "{\"ms\":{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment1\"},{\"n\":\"large_segment2\"}],\"cn\":1000}}"; - // Polling: both ms and ls change - String pollingResponse = "{\"ms\":{\"k\":[{\"n\":\"segment2\"},{\"n\":\"segment3\"}],\"cn\":2000},\"ls\":{\"k\":[{\"n\":\"large_segment2\"},{\"n\":\"large_segment3\"}],\"cn\":2000}}"; + String initialResponse = IntegrationHelper.membershipsResponse( + new String[]{"segment1", "segment2"}, 1000L, + new String[]{"large_segment1", "large_segment2"}, 1000L); + String pollingResponse = IntegrationHelper.membershipsResponse( + new String[]{"segment2", "segment3"}, 2000L, + new String[]{"large_segment2", "large_segment3"}, 2000L); List metadataList = waitForSegmentsPollingUpdates(initialResponse, pollingResponse, 2); @@ -1470,7 +1333,7 @@ public void onPostExecution(SplitClient c) { CountDownLatch updateLatch = new CountDownLatch(expectedEventCount * 2); // Register new API handler (addEventListener) - client.addEventListener(new SdkEventListener() { + client.addEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { synchronized (receivedMetadataList) { @@ -1509,7 +1372,7 @@ public void onPostExecution(SplitClient c) { * Scenario: Multiple listeners with onUpdate are both invoked *

* Given sdkReady has already been emitted - * And two different SdkEventListener instances (L1 and L2) with onUpdate handlers are registered + * And two different SplitEventListener instances (L1 and L2) with onUpdate handlers are registered * When a split update notification arrives via SSE * Then SDK_UPDATE is emitted once * And both L1.onUpdate and L2.onUpdate are invoked exactly once each @@ -1518,22 +1381,17 @@ public void onPostExecution(SplitClient c) { public void multipleListenersWithOnUpdateBothInvoked() throws Exception { TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); - AtomicInteger listener1Count = new AtomicInteger(0); - AtomicInteger listener2Count = new AtomicInteger(0); - AtomicReference listener1Metadata = new AtomicReference<>(); - AtomicReference listener2Metadata = new AtomicReference<>(); - CountDownLatch updateLatch = new CountDownLatch(2); - - fixture.client.addEventListener(createOnUpdateListener(listener1Count, listener1Metadata, updateLatch)); - fixture.client.addEventListener(createOnUpdateListener(listener2Count, listener2Metadata, updateLatch)); + EventCapture capture1 = captureUpdateEvent(fixture.client); + EventCapture capture2 = captureUpdateEvent(fixture.client); fixture.pushSplitUpdate(); - assertTrue("Both listeners should be invoked", updateLatch.await(10, TimeUnit.SECONDS)); - assertEquals("Listener 1 should be invoked exactly once", 1, listener1Count.get()); - assertEquals("Listener 2 should be invoked exactly once", 1, listener2Count.get()); - assertNotNull("Listener 1 should receive metadata", listener1Metadata.get()); - assertNotNull("Listener 2 should receive metadata", listener2Metadata.get()); + awaitEvent(capture1.latch, "Listener 1 SDK_UPDATE"); + awaitEvent(capture2.latch, "Listener 2 SDK_UPDATE"); + assertFiredOnce(capture1.count, "Listener 1"); + assertFiredOnce(capture2.count, "Listener 2"); + assertNotNull("Listener 1 should receive metadata", capture1.metadata.get()); + assertNotNull("Listener 2 should receive metadata", capture2.metadata.get()); fixture.destroy(); } @@ -1542,7 +1400,7 @@ public void multipleListenersWithOnUpdateBothInvoked() throws Exception { * Scenario: Multiple listeners with onReady are both invoked *

* Given the SDK is starting - * And two different SdkEventListener instances (L1 and L2) with onReady handlers are registered + * And two different SplitEventListener instances (L1 and L2) with onReady handlers are registered * When SDK_READY fires * Then both L1.onReady and L2.onReady are invoked exactly once each * And both receive SdkReadyMetadata @@ -1553,20 +1411,15 @@ public void multipleListenersWithOnReadyBothInvoked() throws Exception { SplitFactory factory = buildFactory(buildConfig()); SplitClient client = factory.client(new Key("key_1")); - AtomicInteger listener1Count = new AtomicInteger(0); - AtomicInteger listener2Count = new AtomicInteger(0); - AtomicReference listener1Metadata = new AtomicReference<>(); - AtomicReference listener2Metadata = new AtomicReference<>(); - CountDownLatch readyLatch = new CountDownLatch(2); + EventCapture capture1 = captureReadyEvent(client); + EventCapture capture2 = captureReadyEvent(client); - client.addEventListener(createOnReadyListener(listener1Count, listener1Metadata, readyLatch)); - client.addEventListener(createOnReadyListener(listener2Count, listener2Metadata, readyLatch)); - - assertTrue("Both listeners should be invoked", readyLatch.await(10, TimeUnit.SECONDS)); - assertEquals("Listener 1 should be invoked exactly once", 1, listener1Count.get()); - assertEquals("Listener 2 should be invoked exactly once", 1, listener2Count.get()); - assertNotNull("Listener 1 should receive metadata", listener1Metadata.get()); - assertNotNull("Listener 2 should receive metadata", listener2Metadata.get()); + awaitEvent(capture1.latch, "Listener 1 SDK_READY"); + awaitEvent(capture2.latch, "Listener 2 SDK_READY"); + assertFiredOnce(capture1.count, "Listener 1"); + assertFiredOnce(capture2.count, "Listener 2"); + assertNotNull("Listener 1 should receive metadata", capture1.metadata.get()); + assertNotNull("Listener 2 should receive metadata", capture2.metadata.get()); factory.destroy(); } @@ -1575,8 +1428,8 @@ public void multipleListenersWithOnReadyBothInvoked() throws Exception { * Scenario: Listeners with different callbacks (onReady and onUpdate) each invoked on correct event *

* Given the SDK is starting - * And a SdkEventListener L1 with onReady handler is registered - * And a SdkEventListener L2 with onUpdate handler is registered + * And a SplitEventListener L1 with onReady handler is registered + * And a SplitEventListener L2 with onUpdate handler is registered * When SDK_READY fires * Then L1.onReady is invoked * And L2.onUpdate is NOT invoked (wrong event type) @@ -1588,24 +1441,19 @@ public void multipleListenersWithOnReadyBothInvoked() throws Exception { public void listenersWithDifferentCallbacksInvokedOnCorrectEventType() throws Exception { TestClientFixture fixture = createStreamingClient(new Key("key_1")); - AtomicInteger onReadyCount = new AtomicInteger(0); - AtomicInteger onUpdateCount = new AtomicInteger(0); - CountDownLatch readyLatch = new CountDownLatch(1); - CountDownLatch updateLatch = new CountDownLatch(1); + EventCapture readyCapture = captureReadyEvent(fixture.client); + EventCapture updateCapture = captureUpdateEvent(fixture.client); - fixture.client.addEventListener(createOnReadyListener(onReadyCount, null, readyLatch)); - fixture.client.addEventListener(createOnUpdateListener(onUpdateCount, null, updateLatch)); - - assertTrue("SDK_READY should fire", readyLatch.await(10, TimeUnit.SECONDS)); - assertEquals("onReady should be invoked exactly once", 1, onReadyCount.get()); - assertEquals("onUpdate should NOT be invoked on SDK_READY", 0, onUpdateCount.get()); + awaitEvent(readyCapture.latch, "SDK_READY"); + assertFiredOnce(readyCapture.count, "onReady"); + assertEquals("onUpdate should NOT be invoked on SDK_READY", 0, updateCapture.count.get()); fixture.waitForSseConnection(); fixture.pushSplitUpdate(); - assertTrue("SDK_UPDATE should fire", updateLatch.await(10, TimeUnit.SECONDS)); - assertEquals("onUpdate should be invoked exactly once", 1, onUpdateCount.get()); - assertEquals("onReady should still be 1 (not invoked again)", 1, onReadyCount.get()); + awaitEvent(updateCapture.latch, "SDK_UPDATE"); + assertFiredOnce(updateCapture.count, "onUpdate"); + assertFiredOnce(readyCapture.count, "onReady (not invoked again)"); fixture.destroy(); } @@ -1614,7 +1462,7 @@ public void listenersWithDifferentCallbacksInvokedOnCorrectEventType() throws Ex * Scenario: Multiple listeners with both onReady and onUpdate in same listener *

* Given the SDK is starting - * And two SdkEventListener instances (L1 and L2) each with both onReady and onUpdate handlers + * And two SplitEventListener instances (L1 and L2) each with both onReady and onUpdate handlers * When SDK_READY fires * Then both L1.onReady and L2.onReady are invoked exactly once each * And neither L1.onUpdate nor L2.onUpdate are invoked @@ -1625,30 +1473,30 @@ public void listenersWithDifferentCallbacksInvokedOnCorrectEventType() throws Ex public void multipleListenersWithBothReadyAndUpdateHandlers() throws Exception { TestClientFixture fixture = createStreamingClient(new Key("key_1")); - AtomicInteger listener1ReadyCount = new AtomicInteger(0); - AtomicInteger listener1UpdateCount = new AtomicInteger(0); - AtomicInteger listener2ReadyCount = new AtomicInteger(0); - AtomicInteger listener2UpdateCount = new AtomicInteger(0); + AtomicInteger l1ReadyCount = new AtomicInteger(0); + AtomicInteger l1UpdateCount = new AtomicInteger(0); + AtomicInteger l2ReadyCount = new AtomicInteger(0); + AtomicInteger l2UpdateCount = new AtomicInteger(0); CountDownLatch readyLatch = new CountDownLatch(2); CountDownLatch updateLatch = new CountDownLatch(2); - fixture.client.addEventListener(createDualListener(listener1ReadyCount, readyLatch, listener1UpdateCount, updateLatch)); - fixture.client.addEventListener(createDualListener(listener2ReadyCount, readyLatch, listener2UpdateCount, updateLatch)); + fixture.client.addEventListener(createDualListener(l1ReadyCount, readyLatch, l1UpdateCount, updateLatch)); + fixture.client.addEventListener(createDualListener(l2ReadyCount, readyLatch, l2UpdateCount, updateLatch)); - assertTrue("Both onReady handlers should be invoked", readyLatch.await(10, TimeUnit.SECONDS)); - assertEquals("Listener 1 onReady should be invoked once", 1, listener1ReadyCount.get()); - assertEquals("Listener 2 onReady should be invoked once", 1, listener2ReadyCount.get()); - assertEquals("Listener 1 onUpdate should NOT be invoked on SDK_READY", 0, listener1UpdateCount.get()); - assertEquals("Listener 2 onUpdate should NOT be invoked on SDK_READY", 0, listener2UpdateCount.get()); + awaitEvent(readyLatch, "Both onReady handlers"); + assertFiredOnce(l1ReadyCount, "Listener 1 onReady"); + assertFiredOnce(l2ReadyCount, "Listener 2 onReady"); + assertEquals("Listener 1 onUpdate should NOT be invoked on SDK_READY", 0, l1UpdateCount.get()); + assertEquals("Listener 2 onUpdate should NOT be invoked on SDK_READY", 0, l2UpdateCount.get()); fixture.waitForSseConnection(); fixture.pushSplitUpdate(); - assertTrue("Both onUpdate handlers should be invoked", updateLatch.await(10, TimeUnit.SECONDS)); - assertEquals("Listener 1 onUpdate should be invoked once", 1, listener1UpdateCount.get()); - assertEquals("Listener 2 onUpdate should be invoked once", 1, listener2UpdateCount.get()); - assertEquals("Listener 1 onReady should still be 1", 1, listener1ReadyCount.get()); - assertEquals("Listener 2 onReady should still be 1", 1, listener2ReadyCount.get()); + awaitEvent(updateLatch, "Both onUpdate handlers"); + assertFiredOnce(l1UpdateCount, "Listener 1 onUpdate"); + assertFiredOnce(l2UpdateCount, "Listener 2 onUpdate"); + assertFiredOnce(l1ReadyCount, "Listener 1 onReady (not invoked again)"); + assertFiredOnce(l2ReadyCount, "Listener 2 onReady (not invoked again)"); fixture.destroy(); } @@ -1657,8 +1505,8 @@ public void multipleListenersWithBothReadyAndUpdateHandlers() throws Exception { * Scenario: Multiple listeners with onReady replay to late subscribers *

* Given SDK_READY has already been emitted - * And a SdkEventListener L1 with onReady was registered before SDK_READY and was invoked - * When a new SdkEventListener L2 with onReady is registered after SDK_READY has fired + * And a SplitEventListener L1 with onReady was registered before SDK_READY and was invoked + * When a new SplitEventListener L2 with onReady is registered after SDK_READY has fired * Then L2.onReady is invoked (replay) * And L1.onReady is NOT invoked again */ @@ -1666,29 +1514,20 @@ public void multipleListenersWithBothReadyAndUpdateHandlers() throws Exception { public void multipleListenersWithOnReadyReplayToLateSubscribers() throws Exception { TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); - AtomicInteger listener1Count = new AtomicInteger(0); - AtomicInteger listener2Count = new AtomicInteger(0); - CountDownLatch listener1Latch = new CountDownLatch(1); - CountDownLatch listener2Latch = new CountDownLatch(1); - - fixture.client.addEventListener(createOnReadyListener(listener1Count, null, listener1Latch)); - assertTrue("Listener 1 should receive replay", listener1Latch.await(5, TimeUnit.SECONDS)); - assertEquals("Listener 1 should be invoked once (replay)", 1, listener1Count.get()); + EventCapture capture1 = captureReadyEvent(fixture.client); + awaitEvent(capture1.latch, "Listener 1 replay", 5); + assertFiredOnce(capture1.count, "Listener 1 (replay)"); - fixture.client.addEventListener(createOnReadyListener(listener2Count, null, listener2Latch)); - assertTrue("Listener 2 should receive replay", listener2Latch.await(5, TimeUnit.SECONDS)); - assertEquals("Listener 2 should be invoked once (replay)", 1, listener2Count.get()); + EventCapture capture2 = captureReadyEvent(fixture.client); + awaitEvent(capture2.latch, "Listener 2 replay", 5); + assertFiredOnce(capture2.count, "Listener 2 (replay)"); Thread.sleep(500); - assertEquals("Listener 1 should still be 1 (not invoked again)", 1, listener1Count.get()); + assertFiredOnce(capture1.count, "Listener 1 (not invoked again)"); fixture.destroy(); } - /** - * Creates a client and waits for SDK_READY to fire. - * Returns a TestClientFixture containing the factory, client, and ready latch. - */ private TestClientFixture createClientAndWaitForReady(SplitClientConfig config, Key key) throws InterruptedException { SplitFactory factory = buildFactory(config); SplitClient client = factory.client(key); @@ -1707,17 +1546,12 @@ public void onPostExecution(SplitClient client) { return new TestClientFixture(factory, client, readyLatch); } - /** - * Creates a client with default config and waits for SDK_READY. - */ private TestClientFixture createClientAndWaitForReady(Key key) throws InterruptedException { return createClientAndWaitForReady(buildConfig(), key); } /** * Creates a client with streaming enabled but does NOT wait for SDK_READY. - * Useful for tests that need to register handlers before SDK_READY fires. - * Returns a fixture that can push SSE messages to trigger SDK_UPDATE. */ private TestClientFixture createStreamingClient(Key key) throws IOException { BlockingQueue streamingData = new LinkedBlockingDeque<>(); @@ -1740,10 +1574,6 @@ private TestClientFixture createStreamingClient(Key key) throws IOException { return new TestClientFixture(factory, client, null, streamingData, sseLatch); } - /** - * Creates a client with streaming enabled and waits for SDK_READY. - * Returns a fixture that can push SSE messages to trigger SDK_UPDATE. - */ private TestClientFixture createStreamingClientAndWaitForReady(Key key) throws InterruptedException, IOException { TestClientFixture fixture = createStreamingClient(key); @@ -1764,9 +1594,6 @@ public void onPostExecution(SplitClient client) { return new TestClientFixture(fixture.factory, fixture.client, readyLatch, fixture.streamingData, fixture.sseLatch); } - /** - * Creates a standard streaming dispatcher for mock HTTP responses. - */ private HttpResponseMockDispatcher createStreamingDispatcher(BlockingQueue streamingData, CountDownLatch sseLatch) { return new HttpResponseMockDispatcher() { @Override @@ -1795,9 +1622,6 @@ public HttpStreamResponseMock getStreamResponse(URI uri) { }; } - /** - * Creates two clients with streaming enabled and waits for both to be ready. - */ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key keyB) throws InterruptedException, IOException { BlockingQueue streamingData = new LinkedBlockingDeque<>(); CountDownLatch sseLatch = new CountDownLatch(1); @@ -1817,16 +1641,11 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key SplitClient clientA = factory.client(keyA); SplitClient clientB = factory.client(keyB); - CountDownLatch readyLatchA = new CountDownLatch(1); - CountDownLatch readyLatchB = new CountDownLatch(1); - - registerReadyHandler(clientA, null, readyLatchA); - registerReadyHandler(clientB, null, readyLatchB); + CountDownLatch readyLatchA = captureLegacyReadyEvent(clientA); + CountDownLatch readyLatchB = captureLegacyReadyEvent(clientB); - boolean readyA = readyLatchA.await(30, TimeUnit.SECONDS); - boolean readyB = readyLatchB.await(30, TimeUnit.SECONDS); - assertTrue("ClientA SDK_READY should fire", readyA); - assertTrue("ClientB SDK_READY should fire", readyB); + awaitEvent(readyLatchA, "ClientA SDK_READY", 30); + awaitEvent(readyLatchB, "ClientB SDK_READY", 30); // Wait for SSE connection and send keep-alive sseLatch.await(10, TimeUnit.SECONDS); @@ -1835,87 +1654,10 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key return new TwoClientFixture(factory, clientA, clientB, streamingData); } - /** - * Registers a handler for SDK_READY_FROM_CACHE that captures metadata and counts invocations. - */ - private void registerCacheReadyHandler(SplitClient client, AtomicInteger count, - AtomicReference metadata, - CountDownLatch latch) { - client.addEventListener(new SdkEventListener() { - @Override - public void onReadyFromCache(SplitClient client, SdkReadyMetadata eventMetadata) { - count.incrementAndGet(); - if (metadata != null) metadata.set(eventMetadata); - if (latch != null) latch.countDown(); - } - }); - } - - /** - * Registers a handler for SDK_UPDATE that counts invocations and optionally captures metadata. - */ - private void registerUpdateHandler(SplitClient client, AtomicInteger count, - AtomicReference metadata) { - client.addEventListener(new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) { - count.incrementAndGet(); - if (metadata != null) metadata.set(eventMetadata); - } - }); - } - - /** - * Registers a handler for SDK_READY that counts invocations and optionally counts down a latch. - */ - private void registerReadyHandler(SplitClient client, AtomicInteger count, CountDownLatch latch) { - client.on(SplitEvent.SDK_READY, new SplitEventTask() { - @Override - public void onPostExecution(SplitClient client) { - if (count != null) count.incrementAndGet(); - if (latch != null) latch.countDown(); - } - }); - } - - /** - * Creates a SdkEventListener that counts onReady invocations and captures metadata. - */ - private SdkEventListener createOnReadyListener(AtomicInteger count, - AtomicReference metadata, - CountDownLatch latch) { - return new SdkEventListener() { - @Override - public void onReady(SplitClient client, SdkReadyMetadata eventMetadata) { - if (count != null) count.incrementAndGet(); - if (metadata != null) metadata.set(eventMetadata); - if (latch != null) latch.countDown(); - } - }; - } - - /** - * Creates a SdkEventListener that counts onUpdate invocations and captures metadata. - */ - private SdkEventListener createOnUpdateListener(AtomicInteger count, - AtomicReference metadata, - CountDownLatch latch) { - return new SdkEventListener() { - @Override - public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) { - if (count != null) count.incrementAndGet(); - if (metadata != null) metadata.set(eventMetadata); - if (latch != null) latch.countDown(); - } - }; - } - - /** - * Creates a SdkEventListener with both onReady and onUpdate handlers. - */ - private SdkEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch, + private SplitEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch, AtomicInteger updateCount, CountDownLatch updateLatch) { - return new SdkEventListener() { + return new SplitEventListener() { + @Override public void onReady(SplitClient client, SdkReadyMetadata metadata) { if (readyCount != null) readyCount.incrementAndGet(); @@ -1930,7 +1672,6 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { }; } - private static final String SPLIT_UPDATE_PAYLOAD = "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0="; /** * Helper class to hold factory and client together for cleanup. @@ -1967,13 +1708,15 @@ void waitForSseConnection() throws InterruptedException { } void pushSplitUpdate() { - pushSplitUpdate("9999999999999", "1000"); + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.splitChangeV2CompressionType0()); + } } void pushSplitUpdate(String changeNumber, String previousChangeNumber) { if (streamingData != null) { pushMessage(streamingData, IntegrationHelper.splitChangeV2( - changeNumber, previousChangeNumber, "0", SPLIT_UPDATE_PAYLOAD)); + changeNumber, previousChangeNumber, "0", IntegrationHelper.SPLIT_UPDATE_PAYLOAD_TYPE0)); } } @@ -1989,9 +1732,8 @@ void pushRbsUpdate() { void pushRbsUpdate(String changeNumber, String previousChangeNumber) { if (streamingData != null) { - // RBS payload: {"name":"rbs_test","status":"ACTIVE","trafficTypeName":"user","excluded":{"keys":[],"segments":[]},"conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"user"},"matcherType":"ALL_KEYS","negate":false}]}}]} - String RBS_UPDATE_PAYLOAD = "eyJuYW1lIjoicmJzX3Rlc3QiLCJzdGF0dXMiOiJBQ1RJVkUiLCJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiZXhjbHVkZWQiOnsia2V5cyI6W10sInNlZ21lbnRzIjpbXX0sImNvbmRpdGlvbnMiOlt7Im1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX19XX0="; - pushMessage(streamingData, IntegrationHelper.rbsChange(changeNumber, previousChangeNumber, RBS_UPDATE_PAYLOAD)); + pushMessage(streamingData, IntegrationHelper.rbsChange( + changeNumber, previousChangeNumber, IntegrationHelper.RBS_UPDATE_PAYLOAD_TYPE0)); } } @@ -2004,33 +1746,40 @@ void destroy() { * Helper class to hold factory and two clients together for cleanup. */ private static class TwoClientFixture { - final SplitFactory factory; - final SplitClient clientA; - final SplitClient clientB; - final BlockingQueue streamingData; - - TwoClientFixture(SplitFactory factory, SplitClient clientA, SplitClient clientB) { - this(factory, clientA, clientB, null); - } + final SplitFactory mFactory; + final SplitClient mClientA; + final SplitClient mClientB; + final BlockingQueue mStreamingData; TwoClientFixture(SplitFactory factory, SplitClient clientA, SplitClient clientB, BlockingQueue streamingData) { - this.factory = factory; - this.clientA = clientA; - this.clientB = clientB; - this.streamingData = streamingData; + mFactory = factory; + mClientA = clientA; + mClientB = clientB; + mStreamingData = streamingData; } void pushSplitUpdate() { - if (streamingData != null) { - pushMessage(streamingData, IntegrationHelper.splitChangeV2CompressionType0()); + if (mStreamingData != null) { + pushMessage(mStreamingData, IntegrationHelper.splitChangeV2CompressionType0()); + } + } + + void pushMembershipKeyListUpdate(String key, String segmentName) { + if (mStreamingData != null) { + pushMessage(mStreamingData, membershipKeyListUpdateMessage(key, segmentName)); } } void destroy() { - factory.destroy(); + mFactory.destroy(); } } + private static String membershipKeyListUpdateMessage(String key, String segmentName) { + MySegmentsV2PayloadDecoder decoder = new MySegmentsV2PayloadDecoder(); + BigInteger hashedKey = decoder.hashKey(key); + return IntegrationHelper.membershipKeyListUpdate(hashedKey, segmentName, 2000); + } private static void pushMessage(BlockingQueue queue, String message) { try { queue.put(message + "\n"); @@ -2052,7 +1801,7 @@ private void populateDatabaseWithCacheData(long timestamp) { entity.setName("split_" + i); long cn = 1000L + i; finalChangeNumber = cn; - entity.setBody(String.format("{\"name\":\"split_%d\", \"changeNumber\": %d}", i, cn)); + entity.setBody(IntegrationHelper.splitEntityBody("split_" + i, cn)); splitEntities.add(entity); } mDatabase.splitDao().insert(splitEntities); @@ -2062,14 +1811,14 @@ private void populateDatabaseWithCacheData(long timestamp) { // Populate segments for default key MySegmentEntity segmentEntity = new MySegmentEntity(); segmentEntity.setUserKey("DEFAULT_KEY"); - segmentEntity.setSegmentList("{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":null}"); + segmentEntity.setSegmentList(IntegrationHelper.segmentListJson("segment1", "segment2")); segmentEntity.setUpdatedAt(System.currentTimeMillis() / 1000); mDatabase.mySegmentDao().update(segmentEntity); // Populate segments for key_1 MySegmentEntity segmentEntity2 = new MySegmentEntity(); segmentEntity2.setUserKey("key_1"); - segmentEntity2.setSegmentList("{\"k\":[{\"n\":\"segment1\"}],\"cn\":null}"); + segmentEntity2.setSegmentList(IntegrationHelper.segmentListJson("segment1")); segmentEntity2.setUpdatedAt(System.currentTimeMillis() / 1000); mDatabase.mySegmentDao().update(segmentEntity2); } @@ -2101,11 +1850,177 @@ public void onPostExecution(SplitClient client) { return new TestClientFixture(fixture.factory, fixture.client, readyLatch, fixture.streamingData, fixture.sseLatch); } - /** - * Populates the database with RBS change number for instant update testing. - */ private void populateDatabaseWithRbsData() { // Set RBS change number so streaming notifications trigger in-place updates mDatabase.generalInfoDao().update(new GeneralInfoEntity("rbsChangeNumber", 1000L)); } + + private static class EventCapture { + final AtomicInteger count = new AtomicInteger(0); + final AtomicReference metadata = new AtomicReference<>(); + final CountDownLatch latch; + + EventCapture() { + this(1); + } + + EventCapture(int expectedCount) { + this.latch = new CountDownLatch(expectedCount); + } + + void capture(M meta) { + count.incrementAndGet(); + metadata.set(meta); + latch.countDown(); + } + + void increment() { + count.incrementAndGet(); + latch.countDown(); + } + + boolean await(int seconds) throws InterruptedException { + return latch.await(seconds, TimeUnit.SECONDS); + } + } + + private void awaitEvent(CountDownLatch latch, String eventName) throws InterruptedException { + awaitEvent(latch, eventName, 10); + } + + private void awaitEvent(CountDownLatch latch, String eventName, int timeoutSeconds) throws InterruptedException { + boolean fired = latch.await(timeoutSeconds, TimeUnit.SECONDS); + assertTrue(eventName + " should fire", fired); + } + + private void assertFiredOnce(AtomicInteger count, String eventName) { + assertEquals(eventName + " should be invoked exactly once", 1, count.get()); + } + + private void assertFiredTimes(AtomicInteger count, String eventName, int expectedTimes) { + assertEquals(eventName + " should be invoked " + expectedTimes + " time(s)", expectedTimes, count.get()); + } + + private EventCapture captureReadyEvent(SplitClient client) { + EventCapture capture = new EventCapture<>(); + client.addEventListener(new SplitEventListener() { + @Override + public void onReady(SplitClient c, SdkReadyMetadata metadata) { + capture.capture(metadata); + } + }); + return capture; + } + + private EventCapture captureCacheReadyEvent(SplitClient client) { + EventCapture capture = new EventCapture<>(); + client.addEventListener(new SplitEventListener() { + @Override + public void onReadyFromCache(SplitClient c, SdkReadyMetadata metadata) { + capture.capture(metadata); + } + }); + return capture; + } + + private EventCapture captureUpdateEvent(SplitClient client) { + return captureUpdateEvent(client, 1); + } + + private EventCapture captureUpdateEvent(SplitClient client, int expectedCount) { + EventCapture capture = new EventCapture<>(expectedCount); + client.addEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + capture.capture(metadata); + } + }); + return capture; + } + + private CountDownLatch captureLegacyReadyEvent(SplitClient client) { + CountDownLatch latch = new CountDownLatch(1); + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient c) { + latch.countDown(); + } + }); + return latch; + } + + /** + * Creates a polling dispatcher that returns different responses based on hit count. + */ + private Dispatcher createPollingDispatcher( + Function splitChangesResponseFn, + Function membershipsResponseFn) { + AtomicInteger splitChangesHits = new AtomicInteger(0); + AtomicInteger membershipsHits = new AtomicInteger(0); + + return new Dispatcher() { + @NonNull + @Override + public MockResponse dispatch(@NonNull RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + int count = membershipsHits.incrementAndGet(); + String body = membershipsResponseFn != null + ? membershipsResponseFn.apply(count) + : IntegrationHelper.dummyAllSegments(); + return new MockResponse().setResponseCode(200).setBody(body); + } else if (path.contains("/splitChanges")) { + int count = splitChangesHits.incrementAndGet(); + String body = splitChangesResponseFn != null + ? splitChangesResponseFn.apply(count) + : IntegrationHelper.emptyTargetingRulesChanges(1000, 1000); + return new MockResponse().setResponseCode(200).setBody(body); + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + } + + private Dispatcher createDelayedDispatcher(long delaySeconds) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + return new MockResponse() + .setResponseCode(200) + .setBody(IntegrationHelper.dummyAllSegments()) + .setBodyDelay(delaySeconds, TimeUnit.SECONDS); + } else if (path.contains("/splitChanges")) { + long id = mCurSplitReqId++; + return new MockResponse() + .setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id)) + .setBodyDelay(delaySeconds, TimeUnit.SECONDS); + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + } + + private SplitClientConfig createPollingConfig(int featuresRefreshRate, int segmentsRefreshRate) { + return new TestableSplitConfigBuilder() + .serviceEndpoints(endpoints()) + .ready(30000) + .featuresRefreshRate(featuresRefreshRate) + .segmentsRefreshRate(segmentsRefreshRate) + .impressionsRefreshRate(999999) + .streamingEnabled(false) + .trafficType("account") + .build(); + } + + private void waitForReady(SplitClient client) throws InterruptedException { + CountDownLatch latch = captureLegacyReadyEvent(client); + awaitEvent(latch, "SDK_READY"); + } } diff --git a/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java index 27d871a02..82ec302e3 100644 --- a/main/src/androidTest/java/tests/service/EventsManagerTest.java +++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java @@ -17,7 +17,7 @@ import helper.TestingHelper; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SdkUpdateMetadata; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; @@ -198,7 +198,7 @@ public void onPostExecutionView(SplitClient client) { }); // Register for SDK_UPDATE with metadata callback using SdkEventListener - eventManager.registerEventListener(new SdkEventListener() { + eventManager.registerEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { receivedMetadata.set(metadata); @@ -315,7 +315,7 @@ public void onPostExecutionView(SplitClient client) { }); // Register SdkEventListener to receive typed metadata - eventManager.registerEventListener(new SdkEventListener() { + eventManager.registerEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { backgroundCalled.set(true); diff --git a/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java b/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java index 30bccc1b3..fc3e7a02c 100644 --- a/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java +++ b/main/src/main/java/io/split/android/client/AlwaysReturnControlSplitClient.java @@ -3,7 +3,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.grammar.Treatments; @@ -173,7 +173,7 @@ public void on(SplitEvent event, SplitEventTask task) { } @Override - public void addEventListener(SdkEventListener listener) { + public void addEventListener(SplitEventListener listener) { // no-op } diff --git a/main/src/main/java/io/split/android/client/SplitClientImpl.java b/main/src/main/java/io/split/android/client/SplitClientImpl.java index 307618238..571efa169 100644 --- a/main/src/main/java/io/split/android/client/SplitClientImpl.java +++ b/main/src/main/java/io/split/android/client/SplitClientImpl.java @@ -12,7 +12,7 @@ import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -204,7 +204,7 @@ public void on(SplitEvent event, SplitEventTask task) { } @Override - public void addEventListener(@NonNull SdkEventListener listener) { + public void addEventListener(@NonNull SplitEventListener listener) { if (mIsClientDestroyed) { Logger.w("Client has already been destroyed. Cannot add event listener"); return; diff --git a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java index 0b5b6a0c3..1b5e58499 100644 --- a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java +++ b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java @@ -25,7 +25,7 @@ import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; import io.split.android.client.attributes.AttributesMerger; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -272,7 +272,7 @@ public void on(SplitEvent event, SplitEventTask task) { } @Override - public void addEventListener(@NonNull SdkEventListener listener) { + public void addEventListener(@NonNull SplitEventListener listener) { if (mIsClientDestroyed) { Logger.w("Client has already been destroyed. Cannot add event listener"); return; diff --git a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java index 3b2a4be33..f41271796 100644 --- a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java +++ b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java @@ -159,6 +159,12 @@ public void close() { } + @VisibleForTesting + @Nullable + SSLSocketFactory getSslSocketFactory() { + return mSslSocketFactory; + } + private Proxy initializeProxy(HttpProxy proxy) { if (proxy != null) { return new Proxy( @@ -279,7 +285,7 @@ public HttpClient build() { if (mProxy != null) { mSslSocketFactory = createSslSocketFactoryFromProxy(mProxy); - } else { + } else if (LegacyTlsUpdater.couldBeOld()) { try { mSslSocketFactory = new Tls12OnlySocketFactory(); } catch (NoSuchAlgorithmException | KeyManagementException e) { @@ -287,6 +293,9 @@ public HttpClient build() { } catch (Exception e) { Logger.e("Unknown TLS v12 error: " + e.getLocalizedMessage()); } + } else { + // Use platform default + mSslSocketFactory = null; } } diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java index 0ea6127c8..705331080 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java @@ -327,11 +327,25 @@ private void updateStorage(boolean clearBeforeUpdate, SplitChange splitChange, R mRuleBasedSegmentStorage.clear(); } ProcessedSplitChange processedSplitChange = mSplitChangeProcessor.process(splitChange); - mLastProcessedSplitChange.set(processedSplitChange); + if (hasFlagUpdates(processedSplitChange)) { + mLastProcessedSplitChange.set(processedSplitChange); + } mSplitsStorage.update(processedSplitChange, mExecutor); updateRbsStorage(ruleBasedSegmentChange); } + private boolean hasFlagUpdates(@Nullable ProcessedSplitChange processedSplitChange) { + if (processedSplitChange == null) { + return false; + } + List activeSplits = processedSplitChange.getActiveSplits(); + if (activeSplits != null && !activeSplits.isEmpty()) { + return true; + } + List archivedSplits = processedSplitChange.getArchivedSplits(); + return archivedSplits != null && !archivedSplits.isEmpty(); + } + /** * Gets the list of updated flag names from the last sync operation. * This includes both active (added/modified) and archived (removed) splits. diff --git a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java index c76b3c9a0..16d40a060 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java @@ -16,7 +16,7 @@ import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -136,14 +136,14 @@ public void addEventListenerWithNullListenerDoesNotRegisterAndLogsWarning() { try (MockedStatic logger = mockStatic(Logger.class)) { splitClient.addEventListener(null); - verify(eventsManager, never()).registerEventListener(any(SdkEventListener.class)); + verify(eventsManager, never()).registerEventListener(any(SplitEventListener.class)); logger.verify(() -> Logger.w("SDK Event Listener cannot be null")); } } @Test public void addEventListenerWithValidListenerRegistersListener() { - SdkEventListener listener = mock(SdkEventListener.class); + SplitEventListener listener = mock(SplitEventListener.class); splitClient.addEventListener(listener); @@ -155,10 +155,10 @@ public void addEventListenerDoesNotRegisterWhenClientIsDestroyedAndLogsWarning() try (MockedStatic logger = mockStatic(Logger.class)) { splitClient.destroy(); - SdkEventListener listener = mock(SdkEventListener.class); + SplitEventListener listener = mock(SplitEventListener.class); splitClient.addEventListener(listener); - verify(eventsManager, never()).registerEventListener(any(SdkEventListener.class)); + verify(eventsManager, never()).registerEventListener(any(SplitEventListener.class)); logger.verify(() -> Logger.w("Client has already been destroyed. Cannot add event listener")); } } diff --git a/main/src/test/java/io/split/android/client/events/EventsManagerTest.java b/main/src/test/java/io/split/android/client/events/EventsManagerTest.java index 6861d84c4..4df176da1 100644 --- a/main/src/test/java/io/split/android/client/events/EventsManagerTest.java +++ b/main/src/test/java/io/split/android/client/events/EventsManagerTest.java @@ -282,7 +282,7 @@ public void sdkUpdateWithTypedTaskReceivesMetadata() throws InterruptedException waitForSdkReady(eventManager, readyLatch); - eventManager.registerEventListener(new SdkEventListener() { + eventManager.registerEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { receivedMetadata.set(metadata); @@ -309,7 +309,7 @@ public void sdkUpdateWithTypedTaskReceivesMetadataOnMainThread() throws Interrup waitForSdkReady(eventManager, readyLatch); - eventManager.registerEventListener(new SdkEventListener() { + eventManager.registerEventListener(new SplitEventListener() { @Override public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { receivedMetadata.set(metadata); @@ -362,7 +362,7 @@ public void sdkEventListenerCallsBothBackgroundAndMainThreadMethods() throws Int waitForSdkReady(eventManager, readyLatch); - eventManager.registerEventListener(new SdkEventListener() { + eventManager.registerEventListener(new SplitEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { backgroundMethodCalled[0] = true; @@ -396,7 +396,7 @@ public void sdkReadyFromCacheTypedTaskReceivesMetadata() throws InterruptedExcep AtomicReference receivedMetadata = new AtomicReference<>(); // Register an event listener - eventManager.registerEventListener(new SdkEventListener() { + eventManager.registerEventListener(new SplitEventListener() { @Override public void onReadyFromCache(SplitClient client, SdkReadyMetadata metadata) { receivedMetadata.set(metadata); diff --git a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java index 1b8b7a38c..9dd91d706 100644 --- a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java @@ -37,7 +37,7 @@ import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; import io.split.android.client.attributes.AttributesMerger; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -450,7 +450,7 @@ public void addEventListenerWithNullListenerDoesNotRegister() { try (MockedStatic logger = mockStatic(Logger.class)) { client.addEventListener(null); - verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class)); + verify(mockEventsManager, never()).registerEventListener(any(SplitEventListener.class)); logger.verify(() -> Logger.w("SDK Event Listener cannot be null")); } } @@ -459,18 +459,18 @@ public void addEventListenerWithNullListenerDoesNotRegister() { public void addEventListenerDoesNotRegisterWhenClientIsDestroyed() { try (MockedStatic logger = mockStatic(Logger.class)) { client.destroy(); - SdkEventListener listener = mock(SdkEventListener.class); + SplitEventListener listener = mock(SplitEventListener.class); client.addEventListener(listener); - verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class)); + verify(mockEventsManager, never()).registerEventListener(any(SplitEventListener.class)); logger.verify(() -> Logger.w("Client has already been destroyed. Cannot add event listener")); } } @Test public void addEventListenerWithValidListenerRegistersListener() { - SdkEventListener listener = mock(SdkEventListener.class); + SplitEventListener listener = mock(SplitEventListener.class); client.addEventListener(listener); diff --git a/main/src/test/java/io/split/android/client/network/HttpClientTest.java b/main/src/test/java/io/split/android/client/network/HttpClientTest.java index 2daa5063b..3ecc24ee2 100644 --- a/main/src/test/java/io/split/android/client/network/HttpClientTest.java +++ b/main/src/test/java/io/split/android/client/network/HttpClientTest.java @@ -20,6 +20,8 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -403,6 +405,40 @@ public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest mProxyServer.shutdown(); } + @Test + public void buildUsesTls12FactoryWhenLegacyAndNoProxy() throws Exception { + Context context = mock(Context.class); + + try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { + legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(true); + + HttpClient legacyClient = new HttpClientImpl.Builder() + .setContext(context) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + legacyMock.verify(() -> LegacyTlsUpdater.update(context)); + assertTrue(((HttpClientImpl) legacyClient).getSslSocketFactory() instanceof Tls12OnlySocketFactory); + } + } + + @Test + public void buildUsesDefaultSslWhenNotLegacyAndNoProxy() throws Exception { + Context context = mock(Context.class); + + try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { + legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(false); + + HttpClient modernClient = new HttpClientImpl.Builder() + .setContext(context) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + legacyMock.verify(() -> LegacyTlsUpdater.update(context), Mockito.never()); + assertNull(((HttpClientImpl) modernClient).getSslSocketFactory()); + } + } + @Test public void copyStreamToByteArrayWithSimpleString() { diff --git a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java index e60147600..ec8c7db04 100644 --- a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java @@ -42,6 +42,7 @@ import io.split.android.client.dtos.RuleBasedSegmentChange; import io.split.android.client.dtos.SplitChange; import io.split.android.client.dtos.TargetingRulesChange; +import io.split.android.client.dtos.Status; import io.split.android.client.network.SplitHttpHeadersBuilder; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutionStatus; @@ -756,6 +757,31 @@ public void getLastUpdatedSplitNamesReturnsFlagNamesAfterSync() throws HttpFetch // The exact number depends on the splits in the test data, but it should not be null } + @Test + public void getLastUpdatedFlagNamesPreservesLastNonEmptyChange() throws HttpFetcherException { + Split split = new Split(); + split.name = "split_1"; + split.status = Status.ACTIVE; + + SplitChange firstSplitChange = SplitChange.create(-1, 100L, Collections.singletonList(split)); + SplitChange secondSplitChange = SplitChange.create(100L, 100L, Collections.emptyList()); + + RuleBasedSegmentChange firstRbsChange = RuleBasedSegmentChange.create(-1, 10L, Collections.emptyList()); + RuleBasedSegmentChange secondRbsChange = RuleBasedSegmentChange.create(10L, 10L, Collections.emptyList()); + + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(firstSplitChange, firstRbsChange)) + .thenReturn(TargetingRulesChange.create(secondSplitChange, secondRbsChange)); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(10L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + List result = mSplitsSyncHelper.getLastUpdatedFlagNames(); + assertEquals(1, result.size()); + assertTrue(result.contains("split_1")); + } + @Test public void getLastUpdatedFlagNamesIncludesArchivedSplits() throws HttpFetcherException { Split archivedSplit = new Split(); diff --git a/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java index bc276e320..b8eb66b9f 100644 --- a/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java +++ b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java @@ -5,7 +5,7 @@ import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.ListenableEventsManager; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitInternalEvent; @@ -44,7 +44,7 @@ public boolean eventAlreadyTriggered(SplitEvent event) { } @Override - public void registerEventListener(SdkEventListener listener) { + public void registerEventListener(SplitEventListener listener) { // Stub implementation - does nothing } }