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/ListenableEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
index a8ad9c0f1..43498e379 100644
--- a/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
+++ b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java
@@ -8,7 +8,7 @@ public interface ListenableEventsManager {
 
     void register(SplitEvent event, SplitEventTask task);
 
-    void registerEventListener(SdkEventListener listener);
+    void registerEventListener(SplitEventListener listener);
 
     boolean eventAlreadyTriggered(SplitEvent event);
 }
diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
index 5977fbbb2..8fc801117 100644
--- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
+++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java
@@ -113,7 +113,7 @@ public void register(SplitEvent event, SplitEventTask task) {
     }
 
     @Override
-    public void registerEventListener(SdkEventListener listener) {
+    public void registerEventListener(SplitEventListener listener) {
         requireNonNull(listener);
 
         // Register SDK_READY handlers (bg + main)
@@ -190,7 +190,7 @@ private EventHandler createMainThreadHandler(final Sp
     }
 
     // SdkEventListener handlers for SDK_READY
-    private EventHandler createReadyBackgroundHandler(final SdkEventListener listener) {
+    private EventHandler createReadyBackgroundHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -198,7 +198,7 @@ private EventHandler createReadyBackgroundHandler(fin
         };
     }
 
-    private EventHandler createReadyMainThreadHandler(final SdkEventListener listener) {
+    private EventHandler createReadyMainThreadHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -207,7 +207,7 @@ private EventHandler createReadyMainThreadHandler(fin
     }
 
     // SdkEventListener handlers for SDK_UPDATE
-    private EventHandler createUpdateBackgroundHandler(final SdkEventListener listener) {
+    private EventHandler createUpdateBackgroundHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata);
@@ -215,7 +215,7 @@ private EventHandler createUpdateBackgroundHandler(fi
         };
     }
 
-    private EventHandler createUpdateMainThreadHandler(final SdkEventListener listener) {
+    private EventHandler createUpdateMainThreadHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata);
@@ -224,7 +224,7 @@ private EventHandler createUpdateMainThreadHandler(fi
     }
 
     // SdkEventListener handlers for SDK_READY_FROM_CACHE
-    private EventHandler createReadyFromCacheBackgroundHandler(final SdkEventListener listener) {
+    private EventHandler createReadyFromCacheBackgroundHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
@@ -232,7 +232,7 @@ private EventHandler createReadyFromCacheBackgroundHa
         };
     }
 
-    private EventHandler createReadyFromCacheMainThreadHandler(final SdkEventListener listener) {
+    private EventHandler createReadyFromCacheMainThreadHandler(final SplitEventListener listener) {
         return (event, metadata) -> {
             SplitClient client = mResources.getSplitClient();
             SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata);
diff --git a/main/src/androidTest/java/fake/SplitClientStub.java b/main/src/androidTest/java/fake/SplitClientStub.java
index b9d354bf9..14c0fcde4 100644
--- a/main/src/androidTest/java/fake/SplitClientStub.java
+++ b/main/src/androidTest/java/fake/SplitClientStub.java
@@ -11,7 +11,7 @@
 import io.split.android.client.EvaluationOptions;
 import io.split.android.client.SplitClient;
 import io.split.android.client.SplitResult;
-import io.split.android.client.events.SdkEventListener;
+import io.split.android.client.events.SplitEventListener;
 import io.split.android.client.events.SplitEvent;
 import io.split.android.client.events.SplitEventTask;
 
@@ -122,7 +122,7 @@ public void on(SplitEvent event, SplitEventTask task) {
     }
 
     @Override
-    public void addEventListener(SdkEventListener listener) {
+    public void addEventListener(SplitEventListener listener) {
         // Stub implementation - does nothing
     }
 
diff --git a/main/src/androidTest/java/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/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/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 } }