diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index dd26c3568..94a0bf3c3 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,6 +1,11 @@ name: SonarCloud Analysis on: + push: + branches: + - master + - development + - '*_baseline' pull_request: branches: - '*' @@ -38,114 +43,101 @@ jobs: - name: Build project and run tests with coverage run: | # Build the project - ./gradlew assembleDebug --stacktrace - - # Run tests with coverage - allow test failures - ./gradlew testDebugUnitTest jacocoTestReport --stacktrace - TEST_RESULT=$? - if [ $TEST_RESULT -ne 0 ]; then - echo "Some tests failed, but continuing to check for coverage data..." - # Even if tests fail, JaCoCo should generate a report with partial coverage - # from the tests that did pass - fi - - - name: Prepare class files for SonarQube analysis - run: | - echo "Searching for compiled class files..." - - # Create the target directory - mkdir -p build/intermediates/runtime_library_classes_dir/debug - - # Find all directories containing class files with better patterns - CLASS_DIRS=$(find build -name "*.class" -type f -exec dirname {} \; | sort -u | grep -E "(javac|kotlin-classes|runtime_library)" | head -10) - - if [ -z "$CLASS_DIRS" ]; then - echo "WARNING: No class files found in the build directory!" - echo "Searching in all build subdirectories..." - find build -name "*.class" -type f | head -20 + ./gradlew assembleDebug + + # Run tests - continue even if some tests fail + ./gradlew testDebugUnitTest || echo "Some tests failed, but continuing to generate coverage report..." + + # Generate JaCoCo aggregate report separately (ensures it runs even if tests failed) + ./gradlew jacocoRootReport + + # Log report location for debugging + REPORT_PATH="build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" + if [ -f "$REPORT_PATH" ]; then + echo "✓ JaCoCo report generated at: $REPORT_PATH ($(wc -c < "$REPORT_PATH") bytes)" else - echo "Found class files in the following directories:" - echo "$CLASS_DIRS" - - # Copy classes from all relevant directories, not just the first one - for CLASS_DIR in $CLASS_DIRS; do - if [ -d "$CLASS_DIR" ] && [ "$(find "$CLASS_DIR" -name "*.class" | wc -l)" -gt 0 ]; then - echo "Copying classes from $CLASS_DIR" - cp -r "$CLASS_DIR"/* build/intermediates/runtime_library_classes_dir/debug/ 2>/dev/null || echo "Failed to copy from $CLASS_DIR" - fi - done - - # Verify the target directory now has class files - CLASS_COUNT=$(find build/intermediates/runtime_library_classes_dir/debug -name "*.class" | wc -l) - echo "Target directory now contains $CLASS_COUNT class files" + echo "✗ JaCoCo report was NOT generated at: $REPORT_PATH" fi - # Update sonar-project.properties with all found class directories - echo "" >> sonar-project.properties - echo "# Additional binary paths found during build" >> sonar-project.properties - if [ -n "$CLASS_DIRS" ]; then - # Convert newlines to commas for sonar.java.binaries - BINARY_PATHS=$(echo "$CLASS_DIRS" | tr '\n' ',' | sed 's/,$//') - echo "sonar.java.binaries=build/intermediates/runtime_library_classes_dir/debug,$BINARY_PATHS" >> sonar-project.properties + - name: Verify class files and coverage data for SonarQube analysis + run: | + echo "=== Verifying Build Artifacts for SonarQube ===" + echo "" + + # Dynamically get modules from settings.gradle (extract module names from "include ':modulename'" lines) + MODULES=$(grep "^include" settings.gradle | cut -d"'" -f2 | cut -d":" -f2 | tr '\n' ' ') + echo "Detected modules: $MODULES" + echo "" + + echo "Checking compiled class files for each module:" + for module in $MODULES; do + MODULE_CLASSES_DIR="${module}/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes" + if [ -d "$MODULE_CLASSES_DIR" ]; then + CLASS_COUNT=$(find "$MODULE_CLASSES_DIR" -name "*.class" | wc -l) + echo " ✓ ${module}: Found $CLASS_COUNT class files in $MODULE_CLASSES_DIR" + else + echo " ✗ ${module}: Class directory not found: $MODULE_CLASSES_DIR" + fi + done + + echo "" + echo "Checking JaCoCo coverage report:" + if [ -f build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml ]; then + REPORT_SIZE=$(wc -c < build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml) + PACKAGE_COUNT=$(grep -c "> sonar-project.properties + echo " ✗ JaCoCo report file not found" fi - - echo "Checking for JaCoCo report files..." - find build -name "*.xml" | grep jacoco || echo "No JaCoCo XML files found" - find build -name "*.exec" | grep jacoco || echo "No JaCoCo exec files found" - - echo "Contents of JaCoCo report directory:" - ls -la build/reports/jacoco/jacocoTestReport/ || echo "Directory not found" - + + echo "" + echo "Checking JaCoCo execution data for each module:" + for module in $MODULES; do + EXEC_FILE="${module}/build/jacoco/testDebugUnitTest.exec" + if [ -f "$EXEC_FILE" ]; then + EXEC_SIZE=$(wc -c < "$EXEC_FILE") + echo " ✓ ${module}: Found execution data ($EXEC_SIZE bytes) in $EXEC_FILE" + else + echo " ✗ ${module}: Execution data not found: $EXEC_FILE" + fi + done + echo "" - echo "Checking test execution results:" + echo "Test execution summary:" TEST_RESULT_FILES=$(find build -name "TEST-*.xml" 2>/dev/null) if [ -n "$TEST_RESULT_FILES" ]; then - echo "Found test result files:" - echo "$TEST_RESULT_FILES" - # Count total tests, failures, errors TOTAL_TESTS=$(cat $TEST_RESULT_FILES | grep -o 'tests="[0-9]*"' | cut -d'"' -f2 | awk '{sum+=$1} END {print sum}') TOTAL_FAILURES=$(cat $TEST_RESULT_FILES | grep -o 'failures="[0-9]*"' | cut -d'"' -f2 | awk '{sum+=$1} END {print sum}') TOTAL_ERRORS=$(cat $TEST_RESULT_FILES | grep -o 'errors="[0-9]*"' | cut -d'"' -f2 | awk '{sum+=$1} END {print sum}') - echo "Test summary: $TOTAL_TESTS tests, $TOTAL_FAILURES failures, $TOTAL_ERRORS errors" - else - echo "No test result files found" - fi - - echo "" - echo "Checking JaCoCo report content:" - if [ -f build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml ]; then - echo "Report file size: $(wc -c < build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml) bytes" - echo "First 500 chars of report:" - head -c 500 build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml - echo "" - echo "" - echo "Counting coverage elements:" - grep -c " + + + + diff --git a/main/src/main/java/io/split/android/client/EvaluationOptions.java b/api/src/main/java/io/split/android/client/EvaluationOptions.java similarity index 100% rename from main/src/main/java/io/split/android/client/EvaluationOptions.java rename to api/src/main/java/io/split/android/client/EvaluationOptions.java diff --git a/main/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java similarity index 89% rename from main/src/main/java/io/split/android/client/SplitClient.java rename to api/src/main/java/io/split/android/client/SplitClient.java index 63d35f457..5a553d281 100644 --- a/main/src/main/java/io/split/android/client/SplitClient.java +++ b/api/src/main/java/io/split/android/client/SplitClient.java @@ -7,6 +7,7 @@ import java.util.Map; import io.split.android.client.attributes.AttributesManager; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; @@ -179,6 +180,42 @@ public interface SplitClient extends AttributesManager { void on(SplitEvent event, SplitEventTask task); + /** + * Registers an event listener for SDK events that provide typed metadata. + *

+ * This method provides type-safe callbacks for SDK_READY, SDK_UPDATE, and SDK_READY_FROM_CACHE events. + * Override the methods you need in the listener. + *

+ * Multiple listeners can be registered. Each listener will be invoked once per event. + *

+ * Example usage: + *

{@code
+     * client.addEventListener(new SdkEventListener() {
+     *     @Override
+     *     public void onReady(SplitClient client, SdkReadyMetadata metadata) {
+     *         Boolean initialCacheLoad = metadata.isInitialCacheLoad();
+     *         // Handle SDK ready on background thread
+     *     }
+     *
+     *     @Override
+     *     public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
+     *         SdkUpdateMetadata.Type type = metadata.getType(); // FLAGS_UPDATE or SEGMENTS_UPDATE
+     *         List names = metadata.getNames(); // updated flag/segment names
+     *         // Handle on background thread
+     *     }
+     *
+     *     @Override
+     *     public void onReadyFromCacheView(SplitClient client, SdkReadyMetadata metadata) {
+     *         // Handle on main/UI thread
+     *         Boolean initialCacheLoad = metadata.isInitialCacheLoad();
+     *     }
+     * });
+     * }
+ * + * @param listener the event listener to register. Must not be null. + */ + void addEventListener(@NonNull SplitEventListener listener); + /** * Enqueue a new event to be sent to Split data collection services. *

diff --git a/main/src/main/java/io/split/android/client/SplitResult.java b/api/src/main/java/io/split/android/client/SplitResult.java similarity index 100% rename from main/src/main/java/io/split/android/client/SplitResult.java rename to api/src/main/java/io/split/android/client/SplitResult.java diff --git a/main/src/main/java/io/split/android/client/api/Key.java b/api/src/main/java/io/split/android/client/api/Key.java similarity index 100% rename from main/src/main/java/io/split/android/client/api/Key.java rename to api/src/main/java/io/split/android/client/api/Key.java diff --git a/main/src/main/java/io/split/android/client/attributes/AttributesManager.java b/api/src/main/java/io/split/android/client/attributes/AttributesManager.java similarity index 100% rename from main/src/main/java/io/split/android/client/attributes/AttributesManager.java rename to api/src/main/java/io/split/android/client/attributes/AttributesManager.java diff --git a/api/src/main/java/io/split/android/client/events/SdkReadyMetadata.java b/api/src/main/java/io/split/android/client/events/SdkReadyMetadata.java new file mode 100644 index 000000000..977576373 --- /dev/null +++ b/api/src/main/java/io/split/android/client/events/SdkReadyMetadata.java @@ -0,0 +1,52 @@ +package io.split.android.client.events; + +import androidx.annotation.Nullable; + +/** + * Typed metadata for SDK_READY and SDK_READY_FROM_CACHE events. + *

+ * Contains information about the cache state when the SDK becomes ready. + */ +public final class SdkReadyMetadata { + + @Nullable + private final Boolean mInitialCacheLoad; + + @Nullable + private final Long mLastUpdateTimestamp; + + /** + * Creates a new SdkReadyMetadata instance. + * + * @param initialCacheLoad true if this is an initial cache load with no usable cache, or null if not available + * @param lastUpdateTimestamp the last successful cache timestamp in milliseconds since epoch, or null if not available + */ + public SdkReadyMetadata(@Nullable Boolean initialCacheLoad, @Nullable Long lastUpdateTimestamp) { + mInitialCacheLoad = initialCacheLoad; + mLastUpdateTimestamp = lastUpdateTimestamp; + } + + /** + * Returns whether this is an initial cache load with no usable cache. + *

+ * This is true when the SDK starts without any prior cached data (fresh install), + * meaning data was fetched from the server for the first time. + * + * @return true if initial cache load, false otherwise, or null if not available + */ + @Nullable + public Boolean isInitialCacheLoad() { + return mInitialCacheLoad; + } + + /** + * Returns the last successful cache timestamp in milliseconds since epoch. + * + * @return the timestamp, or null if not available + */ + @Nullable + public Long getLastUpdateTimestamp() { + return mLastUpdateTimestamp; + } +} + diff --git a/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java b/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java new file mode 100644 index 000000000..83dea400a --- /dev/null +++ b/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java @@ -0,0 +1,75 @@ +package io.split.android.client.events; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * Typed metadata for SDK_UPDATE events. + *

+ * Contains information about the type of update and the names of entities that were updated. + */ +public final class SdkUpdateMetadata { + + /** + * The type of update that triggered the SDK_UPDATE event. + */ + public enum Type { + /** + * Feature flags were updated. + *

+ * {@link #getNames()} returns the list of flag names that changed. + */ + FLAGS_UPDATE, + + /** + * Segments were updated (rule-based segments, memberships, or large segments). + *

+ * Note: {@link #getNames()} always returns an empty list for this type. + * Segment names are not included in the metadata. + */ + SEGMENTS_UPDATE + } + + @Nullable + private final Type mType; + + @NonNull + private final List mNames; + + /** + * Creates a new SdkUpdateMetadata instance. + * + * @param type the type of update, or null if not available + * @param names the list of entity names that were updated, or null to use an empty list + */ + public SdkUpdateMetadata(@Nullable Type type, @Nullable List names) { + mType = type; + mNames = names != null ? names : Collections.emptyList(); + } + + /** + * Returns the type of update that triggered this event. + * + * @return the update type, or null if not available + */ + @Nullable + public Type getType() { + return mType; + } + + /** + * Returns the list of entity names that changed in this update. + *

+ * For {@link Type#FLAGS_UPDATE}, this contains flag names that were updated. + * For {@link Type#SEGMENTS_UPDATE}, this is always an empty list (segment names are not included). + * + * @return the list of updated entity names, never null (empty list for SEGMENTS_UPDATE or if none) + */ + @NonNull + public List getNames() { + return mNames; + } +} diff --git a/main/src/main/java/io/split/android/client/events/SplitEvent.java b/api/src/main/java/io/split/android/client/events/SplitEvent.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/SplitEvent.java rename to api/src/main/java/io/split/android/client/events/SplitEvent.java diff --git a/api/src/main/java/io/split/android/client/events/SplitEventListener.java b/api/src/main/java/io/split/android/client/events/SplitEventListener.java new file mode 100644 index 000000000..424f5503b --- /dev/null +++ b/api/src/main/java/io/split/android/client/events/SplitEventListener.java @@ -0,0 +1,115 @@ +package io.split.android.client.events; + +import io.split.android.client.SplitClient; + +/** + * Abstract class for handling SDK events with typed metadata. + *

+ * Extend this class and override the methods you need to handle specific SDK events. + * Each event has two callback options: + *

+ *

+ * Example usage: + *

{@code
+ * client.addEventListener(new SdkEventListener() {
+ *     @Override
+ *     public void onReady(SplitClient client, SdkReadyMetadata metadata) {
+ *         Boolean initialCacheLoad = metadata.isInitialCacheLoad();
+ *         // Handle ready on background thread
+ *     }
+ *
+ *     @Override
+ *     public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
+ *         SdkUpdateMetadata.Type type = metadata.getType(); // FLAGS_UPDATE or SEGMENTS_UPDATE
+ *         List names = metadata.getNames(); // updated flag/segment names
+ *         // Handle updates on background thread
+ *     }
+ *
+ *     @Override
+ *     public void onReadyFromCacheView(SplitClient client, SdkReadyMetadata metadata) {
+ *         // Handle cache ready on main/UI thread
+ *         Boolean initialCacheLoad = metadata.isInitialCacheLoad();
+ *     }
+ * });
+ * }
+ */ +public abstract class SplitEventListener { + + /** + * Called when SDK_READY event occurs, executed on a background thread. + *

+ * Override this method to handle SDK_READY events with typed metadata. + * + * @param client the Split client instance + * @param metadata the typed metadata containing ready state information + */ + public void onReady(SplitClient client, SdkReadyMetadata metadata) { + // Default empty implementation + } + + /** + * Called when SDK_READY event occurs, executed on the main/UI thread. + *

+ * Override this method to handle SDK_READY events with typed metadata on the main thread. + * Use this when you need to update UI components. + * + * @param client the Split client instance + * @param metadata the typed metadata containing ready state information + */ + public void onReadyView(SplitClient client, SdkReadyMetadata metadata) { + // Default empty implementation + } + + /** + * Called when SDK_UPDATE event occurs, executed on a background thread. + *

+ * Override this method to handle SDK_UPDATE events with typed metadata. + * + * @param client the Split client instance + * @param metadata the typed metadata containing updated flag information + */ + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + // Default empty implementation + } + + /** + * Called when SDK_READY_FROM_CACHE event occurs, executed on a background thread. + *

+ * Override this method to handle SDK_READY_FROM_CACHE events with typed metadata. + * + * @param client the Split client instance + * @param metadata the typed metadata containing cache information + */ + public void onReadyFromCache(SplitClient client, SdkReadyMetadata metadata) { + // Default empty implementation + } + + /** + * Called when SDK_UPDATE event occurs, executed on the main/UI thread. + *

+ * Override this method to handle SDK_UPDATE events with typed metadata on the main thread. + * Use this when you need to update UI components. + * + * @param client the Split client instance + * @param metadata the typed metadata containing updated flag information + */ + public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { + // Default empty implementation + } + + /** + * Called when SDK_READY_FROM_CACHE event occurs, executed on the main/UI thread. + *

+ * Override this method to handle SDK_READY_FROM_CACHE events with typed metadata on the main thread. + * Use this when you need to update UI components. + * + * @param client the Split client instance + * @param metadata the typed metadata containing cache information + */ + public void onReadyFromCacheView(SplitClient client, SdkReadyMetadata metadata) { + // Default empty implementation + } +} 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 new file mode 100644 index 000000000..7c053b55f --- /dev/null +++ b/api/src/main/java/io/split/android/client/events/SplitEventTask.java @@ -0,0 +1,58 @@ +package io.split.android.client.events; + +import io.split.android.client.SplitClient; + +/** + * Base class for handling Split SDK events. + *

+ * Extend this class and override the methods you need to handle specific SDK events. + *

+ * Threading: + *

+ *

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

+ * Example usage: + *

{@code
+ * client.on(SplitEvent.SDK_READY, new SplitEventTask() {
+ *     @Override
+ *     public void onPostExecution(SplitClient client) {
+ *         // SDK is ready, start using Split
+ *     }
+ * });
+ * }
+ */ +public class SplitEventTask { + /** + * Called when an event occurs, executed on a background thread. + *

+ * Override this method to handle events on a background thread. + * This method is executed immediately and is faster than {@link #onPostExecutionView(SplitClient)}. + * + * @param client the Split client instance + * @throws SplitEventTaskMethodNotImplementedException if not overridden (default behavior) + */ + public void onPostExecution(SplitClient client) { + throw new SplitEventTaskMethodNotImplementedException(); + } + + /** + * Called when an event occurs, executed on the main/UI thread. + *

+ * Override this method to handle events on the main thread. + * Use this when you need to update UI components. + *

+ * Note: This method is queued on the main looper, so execution may be delayed + * compared to {@link #onPostExecution(SplitClient)}. + * + * @param client the Split client instance + * @throws SplitEventTaskMethodNotImplementedException if not overridden (default behavior) + */ + public void onPostExecutionView(SplitClient client) { + throw new SplitEventTaskMethodNotImplementedException(); + } +} diff --git a/main/src/main/java/io/split/android/client/events/SplitEventTaskMethodNotImplementedException.java b/api/src/main/java/io/split/android/client/events/SplitEventTaskMethodNotImplementedException.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/SplitEventTaskMethodNotImplementedException.java rename to api/src/main/java/io/split/android/client/events/SplitEventTaskMethodNotImplementedException.java diff --git a/api/src/test/java/io/split/android/client/api/.gitkeep b/api/src/test/java/io/split/android/client/api/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/api/src/test/java/io/split/android/client/events/SdkReadyMetadataTest.java b/api/src/test/java/io/split/android/client/events/SdkReadyMetadataTest.java new file mode 100644 index 000000000..35d898c57 --- /dev/null +++ b/api/src/test/java/io/split/android/client/events/SdkReadyMetadataTest.java @@ -0,0 +1,66 @@ +package io.split.android.client.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class SdkReadyMetadataTest { + + @Test + public void isInitialCacheLoadReturnsNullWhenConstructedWithNull() { + SdkReadyMetadata metadata = new SdkReadyMetadata(null, null); + + assertNull(metadata.isInitialCacheLoad()); + } + + @Test + public void isInitialCacheLoadReturnsTrueWhenConstructedWithTrue() { + SdkReadyMetadata metadata = new SdkReadyMetadata(true, null); + + assertTrue(metadata.isInitialCacheLoad()); + } + + @Test + public void isInitialCacheLoadReturnsFalseWhenConstructedWithFalse() { + SdkReadyMetadata metadata = new SdkReadyMetadata(false, null); + + assertFalse(metadata.isInitialCacheLoad()); + } + + @Test + public void getLastUpdateTimestampReturnsNullWhenConstructedWithNull() { + SdkReadyMetadata metadata = new SdkReadyMetadata(null, null); + + assertNull(metadata.getLastUpdateTimestamp()); + } + + @Test + public void getLastUpdateTimestampReturnsValueWhenConstructedWithValue() { + long timestamp = 1704067200000L; + SdkReadyMetadata metadata = new SdkReadyMetadata(null, timestamp); + + assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); + } + + @Test + public void bothValuesReturnCorrectlyWhenBothAreSet() { + long timestamp = 1704067200000L; + SdkReadyMetadata metadata = new SdkReadyMetadata(true, timestamp); + + assertTrue(metadata.isInitialCacheLoad()); + assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); + } + + @Test + public void bothValuesReturnCorrectlyWhenInitialCacheLoadIsFalse() { + long timestamp = 1704067200000L; + SdkReadyMetadata metadata = new SdkReadyMetadata(false, timestamp); + + assertFalse(metadata.isInitialCacheLoad()); + assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); + } +} + diff --git a/api/src/test/java/io/split/android/client/events/SdkUpdateMetadataTest.java b/api/src/test/java/io/split/android/client/events/SdkUpdateMetadataTest.java new file mode 100644 index 000000000..fadd2fe09 --- /dev/null +++ b/api/src/test/java/io/split/android/client/events/SdkUpdateMetadataTest.java @@ -0,0 +1,84 @@ +package io.split.android.client.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class SdkUpdateMetadataTest { + + @Test + public void getNamesReturnsEmptyListWhenConstructedWithNull() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(null, null); + + assertEquals(Collections.emptyList(), metadata.getNames()); + } + + @Test + public void getNamesReturnsEmptyListWhenConstructedWithEmptyList() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(null, Collections.emptyList()); + + assertEquals(Collections.emptyList(), metadata.getNames()); + } + + @Test + public void getNamesReturnsListWhenConstructedWithList() { + List names = Arrays.asList("flag1", "flag2", "flag3"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.FLAGS_UPDATE, names); + + assertEquals(names, metadata.getNames()); + } + + @Test + public void getNamesReturnsSingleItemList() { + List names = Collections.singletonList("singleFlag"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.FLAGS_UPDATE, names); + + assertEquals(names, metadata.getNames()); + assertEquals(1, metadata.getNames().size()); + assertEquals("singleFlag", metadata.getNames().get(0)); + } + + @Test + public void getTypeReturnsNullWhenConstructedWithNull() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(null, null); + + assertNull(metadata.getType()); + } + + @Test + public void getTypeReturnsFlagsUpdateWhenConstructedWithFlagsUpdate() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.FLAGS_UPDATE, null); + + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, metadata.getType()); + } + + @Test + public void getTypeReturnsSegmentsUpdateWhenConstructedWithSegmentsUpdate() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, null); + + assertEquals(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, metadata.getType()); + } + + @Test + public void flagsUpdateMetadataContainsBothTypeAndNames() { + List flags = Arrays.asList("flag1", "flag2"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.FLAGS_UPDATE, flags); + + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, metadata.getType()); + assertEquals(flags, metadata.getNames()); + } + + @Test + public void segmentsUpdateMetadataContainsBothTypeAndNames() { + List segments = Arrays.asList("segment1", "segment2"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, segments); + + assertEquals(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, metadata.getType()); + assertEquals(segments, metadata.getNames()); + } +} diff --git a/build.gradle b/build.gradle index d36e81704..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() @@ -8,17 +5,18 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:9.0.0-alpha13' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0' + classpath 'com.android.tools.build:gradle:9.0.0-rc02' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10' classpath "com.vanniktech:gradle-maven-publish-plugin:0.34.0" } } apply plugin: 'com.android.fused-library' apply plugin: 'com.vanniktech.maven.publish' +apply from: "$rootDir/gradle/jacoco-root.gradle" ext { - splitVersion = '5.4.3-rc4' + splitVersion = '5.5.0-rc7' jacocoVersion = '0.8.8' } @@ -33,16 +31,49 @@ allprojects { } } +// The SonarQube Gradle plugin is NOT yet compatible with AGP 9.0 +// Using CLI scanner wrapper as workaround until plugin is updated -// Define exclusions for JaCoCo coverage -def coverageExclusions = [ - '**/R.class', - '**/R$*.class', - '**/BuildConfig.*', - '**/Manifest*.*', - '**/*Test*.*', - 'android/**/*.*' -] +// Sonar task that wraps the CLI scanner (uses sonar-project.properties) +tasks.register('sonar') { + group = 'verification' + description = 'Run SonarQube analysis (uses sonar-scanner CLI for AGP 9.0 compatibility)' + + dependsOn 'jacocoRootReport' + + doLast { + def sonarToken = System.getProperty('sonar.token') ?: project.findProperty('sonar.token') + def sonarHost = System.getProperty('sonar.host.url') ?: project.findProperty('sonar.host.url') ?: 'https://sonarcloud.io' + def sonarOrg = System.getProperty('sonar.organization') ?: project.findProperty('sonar.organization') + + if (!sonarToken) { + throw new GradleException('SonarQube token required') + } + + // Find sonar-scanner + def scannerPath = ['sonar-scanner', '/opt/homebrew/bin/sonar-scanner', '/usr/local/bin/sonar-scanner'] + .find { path -> + try { + def proc = new ProcessBuilder(path, '--version').redirectErrorStream(true).start() + return proc.waitFor() == 0 + } catch (Exception e) { return false } + } + + if (!scannerPath) { + throw new GradleException('sonar-scanner not found. Install with: brew install sonar-scanner') + } + + println "Running sonar-scanner..." + def cmd = [scannerPath, "-Dsonar.token=${sonarToken}", "-Dsonar.host.url=${sonarHost}"] + if (sonarOrg) cmd.add("-Dsonar.organization=${sonarOrg}") + cmd.add("-Dsonar.projectVersion=${splitVersion}") + + def proc = new ProcessBuilder(cmd).directory(rootDir).inheritIO().start() + if (proc.waitFor() != 0) { + throw new GradleException("sonar-scanner failed") + } + } +} androidFusedLibrary { namespace = 'io.split.android.android_client' @@ -106,6 +137,71 @@ repositories { dependencies { include project(':main') include project(':logger') + include project(':events') + include project(':events-domain') + include project(':api') +} + +def javadocSourceProjects = providers.provider { + def includeConfig = configurations.findByName("include") + if (includeConfig == null) { + return [] + } + includeConfig.allDependencies + .withType(org.gradle.api.artifacts.ProjectDependency) + .collect { dep -> + def projectPath = null + if (dep.metaClass.hasProperty(dep, 'dependencyProject')) { + projectPath = dep.dependencyProject?.path + } else if (dep.metaClass.hasProperty(dep, 'dependencyProjectPath')) { + projectPath = dep.dependencyProjectPath + } else if (dep.metaClass.hasProperty(dep, 'path')) { + projectPath = dep.path + } + return projectPath ? project(projectPath) : null + } + .findAll { it != null } +} + +def javadocSourceDirsProvider = providers.provider { + files(javadocSourceProjects.get().collect { sourceProject -> + def androidExtension = sourceProject.extensions.findByName("android") + def sourceDirs = androidExtension?.sourceSets?.main?.java?.srcDirs ?: [] + return sourceDirs.findAll { it.exists() } + }) +} + +def sourcesJarTask = tasks.register('sourcesJar', Jar) { + archiveBaseName = 'android-client' + archiveVersion = splitVersion + archiveClassifier = 'sources' + destinationDirectory = layout.buildDirectory.dir('libs') + from(javadocSourceDirsProvider) +} + +tasks.register('javadocJar', Jar) { + archiveBaseName = 'android-client' + archiveVersion = splitVersion + archiveClassifier = 'javadoc' + destinationDirectory = layout.buildDirectory.dir('libs') + def javadocDir = layout.buildDirectory.dir('intermediates/java_doc_dir/release') + from(javadocDir) + doFirst { + if (!javadocDir.get().asFile.exists()) { + throw new GradleException("Javadoc directory not found: ${javadocDir.get().asFile}") + } + } +} + +afterEvaluate { + def agpJavadocTask = tasks.findByName('javaDocRelease') ?: + tasks.findByName('javaDocJar') ?: + tasks.findByName('javaDoc') + if (agpJavadocTask != null) { + tasks.named('javadocJar').configure { + dependsOn agpJavadocTask + } + } } def splitPOM = { @@ -325,34 +421,6 @@ afterEvaluate { } afterEvaluate { - def emptySourcesJarTask = tasks.findByName("emptySourcesJar") - if (emptySourcesJarTask != null) { - // Ensure only one sources artifact is published - publishing.publications.withType(MavenPublication) { publication -> - if (publication.name == "maven") { - // Keep only the sources from fusedLibraryComponent - publication.artifacts.removeAll { artifact -> - // Remove artifacts that match the emptySourcesJar task output - try { - def artifactFile = artifact.file - if (artifactFile != null && artifactFile.exists()) { - def emptySourcesFile = emptySourcesJarTask.archiveFile.get().asFile - artifactFile == emptySourcesFile - } else { - artifact.classifier == "sources" && artifact.extension == "jar" && - !publication.artifacts.any { - it != artifact && it.classifier == "sources" && - it.buildDependencies != null - } - } - } catch (Exception e) { - false - } - } - } - } - } - // Disable Gradle Module Metadata (.module file) to avoid variant ambiguity tasks.configureEach { task -> // Match tasks related to module metadata generation @@ -368,9 +436,38 @@ afterEvaluate { // This causes lint to crash when consumers use checkDependencies: true publishing.publications.withType(MavenPublication) { publication -> if (publication.name == "maven") { + publication.artifact(tasks.named('javadocJar')) + publication.artifact(tasks.named('sourcesJar')) publication.artifacts.removeAll { artifact -> artifact.file?.name?.endsWith('lint.jar') ?: false } } } } + +// Remove duplicate sources JAR artifact before publishing +// The vanniktech plugin adds emptySourcesJar while AGP 9.0 also provides one from fusedLibraryComponent +// We want to keep the AGP one (merged_sources_jar) which has actual sources +// This must be done at task execution time because artifacts are resolved lazily +gradle.taskGraph.whenReady { graph -> + graph.allTasks.findAll { it.name.startsWith('publishMavenPublication') }.each { publishTask -> + publishTask.doFirst { + def pub = publication + if (pub.name == "maven") { + def sourcesJarFile = tasks.named('sourcesJar').get().archiveFile.get().asFile + def sourcesArtifacts = pub.artifacts.findAll { it.classifier == "sources" && it.extension == "jar" } + sourcesArtifacts.findAll { it.file != null && it.file != sourcesJarFile }.each { artifact -> + pub.artifacts.remove(artifact) + println "Removed duplicate sources artifact: ${artifact.file?.absolutePath}" + } + + def javadocJarFile = tasks.named('javadocJar').get().archiveFile.get().asFile + def javadocArtifacts = pub.artifacts.findAll { it.classifier == "javadoc" && it.extension == "jar" } + javadocArtifacts.findAll { it.file != null && it.file != javadocJarFile }.each { artifact -> + pub.artifacts.remove(artifact) + println "Removed duplicate javadoc artifact: ${artifact.file?.absolutePath}" + } + } + } + } +} diff --git a/events-domain/.gitignore b/events-domain/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/events-domain/.gitignore @@ -0,0 +1 @@ +/build diff --git a/events-domain/README.md b/events-domain/README.md new file mode 100644 index 000000000..b64917133 --- /dev/null +++ b/events-domain/README.md @@ -0,0 +1,3 @@ +# Events Domain module + +This module provides Split SDK specific events management implementation. diff --git a/events-domain/build.gradle b/events-domain/build.gradle new file mode 100644 index 000000000..04cbce16f --- /dev/null +++ b/events-domain/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.events' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + + implementation project(':api') + implementation project(':events') + implementation project(':logger') + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/events-domain/consumer-rules.pro b/events-domain/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/events-domain/proguard-rules.pro b/events-domain/proguard-rules.pro new file mode 100644 index 000000000..cf504086a --- /dev/null +++ b/events-domain/proguard-rules.pro @@ -0,0 +1,22 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + diff --git a/events-domain/src/androidTest/java/.gitkeep b/events-domain/src/androidTest/java/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/events-domain/src/main/AndroidManifest.xml b/events-domain/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cf2d636b6 --- /dev/null +++ b/events-domain/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/events-domain/src/main/java/io/split/android/client/events/.gitkeep b/events-domain/src/main/java/io/split/android/client/events/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/events-domain/src/main/java/io/split/android/client/events/DualExecutorRegistration.java b/events-domain/src/main/java/io/split/android/client/events/DualExecutorRegistration.java new file mode 100644 index 000000000..cf8633a20 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/DualExecutorRegistration.java @@ -0,0 +1,133 @@ +package io.split.android.client.events; + +import androidx.annotation.NonNull; + +import java.util.concurrent.Executor; + +import io.harness.events.EventHandler; +import io.harness.events.EventsManager; +import io.harness.events.Logging; + +/** + * Utility for registering event handlers that need to execute on two different threads. + *

+ * This is useful when an event should trigger both background work and UI updates. + * Each callback is wrapped with its executor before registration. + * + * @param event type + * @param internal event type (for EventsManager) + * @param metadata type + */ +public class DualExecutorRegistration { + + @NonNull + private final Executor mBackgroundExecutor; + @NonNull + private final Executor mMainThreadExecutor; + @NonNull + private final Logging mLogging; + + /** + * Creates a new DualExecutorRegistration with a {@link SplitLogging} instance. + * + * @param backgroundExecutor executor for background execution + * @param mainThreadExecutor executor for main thread execution + */ + public DualExecutorRegistration(@NonNull Executor backgroundExecutor, + @NonNull Executor mainThreadExecutor) { + this(backgroundExecutor, mainThreadExecutor, new SplitLogging()); + } + + /** + * Creates a new DualExecutorRegistration. + *

+ * Package-private for testing. + * + * @param backgroundExecutor executor for background execution + * @param mainThreadExecutor executor for main thread execution + * @param logging logging instance + */ + DualExecutorRegistration(@NonNull Executor backgroundExecutor, + @NonNull Executor mainThreadExecutor, + @NonNull Logging logging) { + if (backgroundExecutor == null) { + throw new IllegalArgumentException("backgroundExecutor cannot be null"); + } + if (mainThreadExecutor == null) { + throw new IllegalArgumentException("mainThreadExecutor cannot be null"); + } + if (logging == null) { + throw new IllegalArgumentException("logging cannot be null"); + } + mBackgroundExecutor = backgroundExecutor; + mMainThreadExecutor = mainThreadExecutor; + mLogging = logging; + } + + /** + * Registers two handlers for the same event, each executing on its respective thread. + * + * @param eventsManager the events manager to register with + * @param event the event to register for + * @param backgroundCallback callback to execute on the background thread + * @param mainThreadCallback callback to execute on the main thread + */ + public void register(@NonNull EventsManager eventsManager, + @NonNull E event, + @NonNull EventHandler backgroundCallback, + @NonNull EventHandler mainThreadCallback) { + if (eventsManager == null || event == null) { + return; + } + + if (backgroundCallback != null) { + eventsManager.register(event, wrapWithExecutor(backgroundCallback, mBackgroundExecutor)); + } + + if (mainThreadCallback != null) { + eventsManager.register(event, wrapWithExecutor(mainThreadCallback, mMainThreadExecutor)); + } + } + + /** + * Registers a single handler for the background thread only. + * + * @param eventsManager the events manager to register with + * @param event the event to register for + * @param backgroundCallback callback to execute on the background thread + */ + public void registerBackground(@NonNull EventsManager eventsManager, + @NonNull E event, + @NonNull EventHandler backgroundCallback) { + if (eventsManager == null || event == null || backgroundCallback == null) { + return; + } + eventsManager.register(event, wrapWithExecutor(backgroundCallback, mBackgroundExecutor)); + } + + /** + * Registers a single handler for the main thread only. + * + * @param eventsManager the events manager to register with + * @param event the event to register for + * @param mainThreadCallback callback to execute on the main thread + */ + public void registerMainThread(@NonNull EventsManager eventsManager, + @NonNull E event, + @NonNull EventHandler mainThreadCallback) { + if (eventsManager == null || event == null || mainThreadCallback == null) { + return; + } + eventsManager.register(event, wrapWithExecutor(mainThreadCallback, mMainThreadExecutor)); + } + + private EventHandler wrapWithExecutor(EventHandler handler, Executor executor) { + return (event, metadata) -> executor.execute(() -> { + try { + handler.handle(event, metadata); + } catch (Exception e) { + mLogging.logError("Exception in event handler: " + e.getMessage()); + } + }); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java new file mode 100644 index 000000000..93e0f6c3f --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java @@ -0,0 +1,150 @@ +package io.split.android.client.events; + +import static java.util.Objects.requireNonNull; + +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.api.Key; + +/** + * Coordinator for SDK-scoped events that should be propagated to all client event managers. + *

+ * This coordinator keeps track of all registered {@link ISplitEventsManager} instances + * and forwards SDK-scoped internal events (like splits updates) to all of them. + *

+ * Client-scoped events (like segments updates for a specific key) should be sent + * directly to the corresponding client's event manager. + */ +public class EventsManagerCoordinator implements ISplitEventsManager, EventsManagerRegistry { + + /** + * Set of SDK-scoped internal events that should be propagated to all registered managers. + */ + private static final Set SDK_SCOPED_EVENTS = EnumSet.of( + SplitInternalEvent.SPLITS_UPDATED, + SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, + SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, + SplitInternalEvent.SPLIT_KILLED_NOTIFICATION, + SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + SplitInternalEvent.ENCRYPTION_MIGRATION_DONE + ); + + private final ConcurrentMap mManagers = new ConcurrentHashMap<>(); + private final Set mTriggered = Collections.newSetFromMap(new ConcurrentHashMap()); + private final ConcurrentMap mTriggeredMetadata = new ConcurrentHashMap<>(); + private final Object mEventLock = new Object(); + + /** + * Notifies an SDK-scoped internal event. + *

+ * If the event is SDK-scoped (like splits updates), it will be propagated + * to all registered event managers. Client-scoped events are ignored and should + * be sent directly to the corresponding client's event manager. + * + * @param internalEvent the internal event to notify + */ + @Override + public void notifyInternalEvent(SplitInternalEvent internalEvent) { + notifyInternalEvent(internalEvent, null); + } + + /** + * Notifies an SDK-scoped internal event with metadata. + *

+ * If the event is SDK-scoped (like splits updates), it will be propagated + * to all registered event managers. Client-scoped events are ignored and should + * be sent directly to the corresponding client's event manager. + * + * @param internalEvent the internal event to notify + * @param metadata the event metadata, can be null + */ + @Override + public void notifyInternalEvent(SplitInternalEvent internalEvent, @Nullable EventMetadata metadata) { + requireNonNull(internalEvent); + + if (!SDK_SCOPED_EVENTS.contains(internalEvent)) { + // Client-scoped events should be sent directly to the client's manager + return; + } + + synchronized (mEventLock) { + mTriggered.add(internalEvent); + if (metadata != null) { + mTriggeredMetadata.put(internalEvent, metadata); + } + + for (ISplitEventsManager manager : mManagers.values()) { + manager.notifyInternalEvent(internalEvent, metadata); + } + } + } + + /** + * Registers an events manager for a client key. + *

+ * Any SDK-scoped events that occurred prior to registration will be propagated + * to the newly registered manager. + * + * @param key the client key + * @param splitEventsManager the events manager for that client + */ + @Override + public void registerEventsManager(Key key, ISplitEventsManager splitEventsManager) { + requireNonNull(key); + requireNonNull(splitEventsManager); + + mManagers.put(key, splitEventsManager); + + // Propagate any events that occurred before registration + propagateTriggeredEvents(splitEventsManager); + } + + /** + * Unregisters the events manager for a client key. + *

+ * If the removed manager is a {@link SplitEventsManager}, its {@code destroy()} method + * will be called to clean up resources. + * + * @param key the client key to unregister + */ + @Override + public void unregisterEventsManager(Key key) { + if (key != null) { + ISplitEventsManager removed = mManagers.remove(key); + if (removed instanceof SplitEventsManager) { + ((SplitEventsManager) removed).destroy(); + } + } + } + + private void propagateTriggeredEvents(ISplitEventsManager splitEventsManager) { + synchronized (mEventLock) { + for (SplitInternalEvent event : mTriggered) { + splitEventsManager.notifyInternalEvent(event, mTriggeredMetadata.get(event)); + } + } + } + + /** + * Checks if an external event has already been triggered in any registered manager. + * + * @param event the event to check + * @return true if the event has already been triggered in any manager, false otherwise + */ + @Override + public boolean eventAlreadyTriggered(SplitEvent event) { + for (ISplitEventsManager manager : mManagers.values()) { + if (manager.eventAlreadyTriggered(event)) { + return true; + } + } + return false; + } +} diff --git a/main/src/main/java/io/split/android/client/events/EventsManagerRegistry.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerRegistry.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/EventsManagerRegistry.java rename to events-domain/src/main/java/io/split/android/client/events/EventsManagerRegistry.java diff --git a/events-domain/src/main/java/io/split/android/client/events/ISplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/ISplitEventsManager.java new file mode 100644 index 000000000..d350b35d3 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/ISplitEventsManager.java @@ -0,0 +1,26 @@ +package io.split.android.client.events; + +import androidx.annotation.Nullable; + +import io.split.android.client.events.metadata.EventMetadata; + +public interface ISplitEventsManager { + + void notifyInternalEvent(SplitInternalEvent internalEvent); + + /** + * Notifies an internal event with metadata. + * + * @param internalEvent the internal event + * @param metadata the event metadata, can be null + */ + void notifyInternalEvent(SplitInternalEvent internalEvent, @Nullable EventMetadata metadata); + + /** + * Checks if an external event has already been triggered. + * + * @param event the event to check + * @return true if the event has already been triggered (reached its max executions), false otherwise + */ + boolean eventAlreadyTriggered(SplitEvent event); +} diff --git a/main/src/main/java/io/split/android/client/events/ListenableEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java similarity index 84% rename from main/src/main/java/io/split/android/client/events/ListenableEventsManager.java rename to events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java index f0b4aff46..43498e379 100644 --- a/main/src/main/java/io/split/android/client/events/ListenableEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java @@ -8,5 +8,7 @@ public interface ListenableEventsManager { void register(SplitEvent event, SplitEventTask task); + void registerEventListener(SplitEventListener listener); + boolean eventAlreadyTriggered(SplitEvent event); } diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventDelivery.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventDelivery.java new file mode 100644 index 000000000..5930fd21c --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventDelivery.java @@ -0,0 +1,51 @@ +package io.split.android.client.events; + +import androidx.annotation.VisibleForTesting; + +import io.harness.events.EventDelivery; +import io.harness.events.EventHandler; +import io.harness.events.Logging; +import io.split.android.client.events.metadata.EventMetadata; + +/** + * Event delivery implementation for Split SDK events. + *

+ * Execution context (background vs main thread) should be + * handled using {@link DualExecutorRegistration}. + */ +class SplitEventDelivery implements EventDelivery { + + private final Logging mLogging; + + /** + * Creates a new SplitEventDelivery with the default logging implementation. + */ + public SplitEventDelivery() { + this(new SplitLogging()); + } + + /** + * Creates a new SplitEventDelivery with a custom logging implementation. + * + * @param logging the logging implementation to use + */ + @VisibleForTesting + SplitEventDelivery(Logging logging) { + mLogging = logging != null ? logging : new SplitLogging(); + } + + @Override + public void deliver(EventHandler eventHandler, + SplitEvent event, + EventMetadata metadata) { + if (eventHandler == null || event == null) { + return; + } + + try { + eventHandler.handle(event, metadata); + } catch (Exception e) { + mLogging.logError("Exception delivering event " + event.name() + ": " + e.getMessage()); + } + } +} 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 new file mode 100644 index 000000000..8fc801117 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -0,0 +1,282 @@ +package io.split.android.client.events; + +import static java.util.Objects.requireNonNull; + +import androidx.annotation.VisibleForTesting; + +import java.util.concurrent.Executor; + +import io.harness.events.EventHandler; +import io.harness.events.EventsManager; +import io.harness.events.EventsManagers; + +import io.split.android.client.SplitClient; +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.events.metadata.TypedTaskConverter; +import io.split.android.client.events.executors.SplitEventExecutorResources; +import io.split.android.client.events.executors.SplitEventExecutorResourcesImpl; +import io.split.android.client.service.executor.SplitTaskExecutionInfo; +import io.split.android.client.service.executor.SplitTaskExecutor; +import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.utils.logger.Logger; + +/** + * Events manager for Split SDK. + */ +public class SplitEventsManager implements ISplitEventsManager, ListenableEventsManager { + + private final EventsManager mEventsManager; + private final DualExecutorRegistration mDualExecutorRegistration; + private SplitEventExecutorResources mResources; + + /** + * Creates a new SplitEventsManager. + * + * @param splitTaskExecutor the task executor for running callbacks + * @param blockUntilReady timeout in milliseconds for SDK_READY (0 = no timeout) + */ + public SplitEventsManager(SplitTaskExecutor splitTaskExecutor, final int blockUntilReady) { + requireNonNull(splitTaskExecutor); + + mResources = new SplitEventExecutorResourcesImpl(); + + // Create the events manager with Split SDK configuration + mEventsManager = EventsManagers.create( + SplitEventsManagerConfigFactory.create(), + new SplitEventDelivery() + ); + + // Create the dual executor registration for handling background + main thread callbacks + mDualExecutorRegistration = new DualExecutorRegistration<>( + createBackgroundExecutor(splitTaskExecutor), + createMainThreadExecutor(splitTaskExecutor) + ); + + // Start timeout thread if configured + if (blockUntilReady > 0) { + startTimeoutThread(blockUntilReady); + } + } + + /** + * Package-private constructor for testing. + */ + @VisibleForTesting + SplitEventsManager(EventsManager eventsManager, + DualExecutorRegistration dualExecutorRegistration, + SplitEventExecutorResources resources) { + mEventsManager = eventsManager; + mDualExecutorRegistration = dualExecutorRegistration; + mResources = resources; + } + + @VisibleForTesting + public void setExecutionResources(SplitEventExecutorResources resources) { + mResources = resources; + } + + @Override + public SplitEventExecutorResources getExecutorResources() { + return mResources; + } + + @Override + public void notifyInternalEvent(SplitInternalEvent internalEvent) { + requireNonNull(internalEvent); + mEventsManager.notifyInternalEvent(internalEvent, null); + } + + /** + * Notifies an internal event with metadata. + * + * @param internalEvent the internal event + * @param metadata the event metadata + */ + @Override + public void notifyInternalEvent(SplitInternalEvent internalEvent, EventMetadata metadata) { + requireNonNull(internalEvent); + mEventsManager.notifyInternalEvent(internalEvent, metadata); + } + + @Override + public void register(SplitEvent event, SplitEventTask task) { + requireNonNull(event); + requireNonNull(task); + + // Adapt SplitEventTask to EventHandler and register for both threads + mDualExecutorRegistration.register( + mEventsManager, + event, + createBackgroundHandler(task), + createMainThreadHandler(task) + ); + } + + @Override + public void registerEventListener(SplitEventListener listener) { + requireNonNull(listener); + + // Register SDK_READY handlers (bg + main) + mDualExecutorRegistration.register( + mEventsManager, + SplitEvent.SDK_READY, + createReadyBackgroundHandler(listener), + createReadyMainThreadHandler(listener) + ); + + // Register SDK_UPDATE handlers (bg + main) + mDualExecutorRegistration.register( + mEventsManager, + SplitEvent.SDK_UPDATE, + createUpdateBackgroundHandler(listener), + createUpdateMainThreadHandler(listener) + ); + + // Register SDK_READY_FROM_CACHE handlers (bg + main) + mDualExecutorRegistration.register( + mEventsManager, + SplitEvent.SDK_READY_FROM_CACHE, + createReadyFromCacheBackgroundHandler(listener), + createReadyFromCacheMainThreadHandler(listener) + ); + } + + @Override + public boolean eventAlreadyTriggered(SplitEvent event) { + return mEventsManager.eventAlreadyTriggered(event); + } + + /** + * Destroys this events manager. + * After calling this method, the manager will no longer process events. + */ + public void destroy() { + mEventsManager.destroy(); + } + + private void startTimeoutThread(final int blockUntilReady) { + Thread timeoutThread = new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(blockUntilReady); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SDK_READY_TIMEOUT_REACHED, null); + } catch (InterruptedException e) { + Logger.d("Waiting before to check if SDK is READY has been interrupted", e.getMessage()); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SDK_READY_TIMEOUT_REACHED, null); + } catch (Throwable e) { + Logger.d("Waiting before to check if SDK is READY interrupted ", e.getMessage()); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SDK_READY_TIMEOUT_REACHED, null); + } + } + }); + timeoutThread.setName("Split-SDKReadyTimeout"); + timeoutThread.setDaemon(true); + timeoutThread.start(); + } + + private EventHandler createBackgroundHandler(final SplitEventTask task) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + executeBackgroundTask(task, client, metadata); + }; + } + + private EventHandler createMainThreadHandler(final SplitEventTask task) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + executeMainThreadTask(task, client, metadata); + }; + } + + // SdkEventListener handlers for SDK_READY + private EventHandler createReadyBackgroundHandler(final SplitEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata); + executeMethod(() -> listener.onReady(client, typedMetadata)); + }; + } + + private EventHandler createReadyMainThreadHandler(final SplitEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata); + executeMethod(() -> listener.onReadyView(client, typedMetadata)); + }; + } + + // SdkEventListener handlers for SDK_UPDATE + private EventHandler createUpdateBackgroundHandler(final SplitEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + executeMethod(() -> listener.onUpdate(client, typedMetadata)); + }; + } + + private EventHandler createUpdateMainThreadHandler(final SplitEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + executeMethod(() -> listener.onUpdateView(client, typedMetadata)); + }; + } + + // SdkEventListener handlers for SDK_READY_FROM_CACHE + private EventHandler createReadyFromCacheBackgroundHandler(final SplitEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata); + executeMethod(() -> listener.onReadyFromCache(client, typedMetadata)); + }; + } + + private EventHandler createReadyFromCacheMainThreadHandler(final SplitEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata); + executeMethod(() -> listener.onReadyFromCacheView(client, typedMetadata)); + }; + } + + private void executeBackgroundTask(SplitEventTask task, SplitClient client, EventMetadata metadata) { + executeMethod(() -> task.onPostExecution(client)); + } + + private void executeMainThreadTask(SplitEventTask task, SplitClient client, EventMetadata metadata) { + executeMethod(() -> task.onPostExecutionView(client)); + } + + private void executeMethod(Runnable method) { + try { + method.run(); + } catch (SplitEventTaskMethodNotImplementedException e) { + // Method not implemented by client, ignore + } catch (Exception e) { + Logger.e("Error executing event task: " + e.getMessage()); + } + } + + private Executor createBackgroundExecutor(final SplitTaskExecutor taskExecutor) { + return command -> taskExecutor.submit(() -> { + try { + command.run(); + } catch (Exception e) { + Logger.e("Error in background executor: " + e.getMessage()); + } + return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); + }, null); + } + + private Executor createMainThreadExecutor(final SplitTaskExecutor taskExecutor) { + return command -> taskExecutor.submitOnMainThread(() -> { + try { + command.run(); + } catch (Exception e) { + Logger.e("Error in main thread executor: " + e.getMessage()); + } + return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); + }); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java new file mode 100644 index 000000000..28e2738be --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java @@ -0,0 +1,88 @@ +package io.split.android.client.events; + +import java.util.HashSet; +import java.util.Set; + +import io.harness.events.EventsManagerConfig; + +/** + * Factory for creating the {@link EventsManagerConfig} that defines the Split SDK event rules. + *

+ * This configuration encapsulates the relationships between internal SDK events + * and external client-facing events. + */ +final class SplitEventsManagerConfigFactory { + + private SplitEventsManagerConfigFactory() { + // Utility class + } + + /** + * Creates the EventsManagerConfig for the Split SDK. + *

+ * Event rules: + *

+ * + * @return the configured EventsManagerConfig + */ + static EventsManagerConfig create() { + // SDK_READY_FROM_CACHE fires when either: + // 1. Cache path: All cache loading events complete (AND), OR + // 2. Sync path: All sync events complete (AND) + Set cacheGroup = new HashSet<>(); + cacheGroup.add(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); + cacheGroup.add(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE); + cacheGroup.add(SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE); + cacheGroup.add(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE); + + Set syncGroup = new HashSet<>(); + syncGroup.add(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + syncGroup.add(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + + return EventsManagerConfig.builder() + .requireAll(SplitEvent.SDK_READY, + SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, + SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE) + + // SDK_READY_FROM_CACHE: OR of ANDs + // Fires when (cache group all done) OR (sync group all done) + .requireAny(SplitEvent.SDK_READY_FROM_CACHE, cacheGroup, syncGroup) + + .requireAny(SplitEvent.SDK_READY_TIMED_OUT, + SplitInternalEvent.SDK_READY_TIMEOUT_REACHED) + + .requireAny(SplitEvent.SDK_UPDATE, + SplitInternalEvent.SPLITS_UPDATED, + SplitInternalEvent.MY_SEGMENTS_UPDATED, + SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED, + SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + SplitInternalEvent.SPLIT_KILLED_NOTIFICATION) + + // SDK_READY requires SDK_READY_FROM_CACHE to fire first + .prerequisite(SplitEvent.SDK_READY, SplitEvent.SDK_READY_FROM_CACHE) + .prerequisite(SplitEvent.SDK_UPDATE, SplitEvent.SDK_READY) + + .suppressedBy(SplitEvent.SDK_READY_TIMED_OUT, SplitEvent.SDK_READY) + + .executionLimit(SplitEvent.SDK_READY, 1) + .executionLimit(SplitEvent.SDK_READY_FROM_CACHE, 1) + .executionLimit(SplitEvent.SDK_READY_TIMED_OUT, 1) + .executionLimit(SplitEvent.SDK_UPDATE, -1) // unlimited + + // Metadata sources + .metadataSource(SplitEvent.SDK_READY, SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE) + // Cache path: if SDK_READY_FROM_CACHE fired because cache was loaded, use storage load metadata. + .metadataSource(SplitEvent.SDK_READY_FROM_CACHE, cacheGroup, + SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE) + // Sync path: if SDK_READY_FROM_CACHE fired alongside SDK_READY, use sync completion metadata. + .metadataSource(SplitEvent.SDK_READY_FROM_CACHE, syncGroup, + SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE) + + .build(); + } +} diff --git a/main/src/main/java/io/split/android/client/events/SplitInternalEvent.java b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java similarity index 53% rename from main/src/main/java/io/split/android/client/events/SplitInternalEvent.java rename to events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java index ab070f2f6..3849e8e5b 100644 --- a/main/src/main/java/io/split/android/client/events/SplitInternalEvent.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java @@ -1,20 +1,26 @@ package io.split.android.client.events; /** - * Created by sarrubia on 4/6/18. + * Internal events used to track SDK initialization and data updates. */ - public enum SplitInternalEvent { + // Cache loading events MY_SEGMENTS_LOADED_FROM_STORAGE, SPLITS_LOADED_FROM_STORAGE, - MY_SEGMENTS_FETCHED, - MY_SEGMENTS_UPDATED, - SPLITS_FETCHED, - SPLITS_UPDATED, - SDK_READY_TIMEOUT_REACHED, - SPLIT_KILLED_NOTIFICATION, ATTRIBUTES_LOADED_FROM_STORAGE, ENCRYPTION_MIGRATION_DONE, + + // Data update events (fired only when data actually changed) + MY_SEGMENTS_UPDATED, + SPLITS_UPDATED, MY_LARGE_SEGMENTS_UPDATED, RULE_BASED_SEGMENTS_UPDATED, + SPLIT_KILLED_NOTIFICATION, + + // Sync completion events (fired when sync completes, regardless of data change) + TARGETING_RULES_SYNC_COMPLETE, + MEMBERSHIPS_SYNC_COMPLETE, + + // Other events + SDK_READY_TIMEOUT_REACHED, } diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitLogging.java b/events-domain/src/main/java/io/split/android/client/events/SplitLogging.java new file mode 100644 index 000000000..4613f6c86 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/SplitLogging.java @@ -0,0 +1,35 @@ +package io.split.android.client.events; + +import io.harness.events.Logging; +import io.split.android.client.utils.logger.Logger; + +/** + * Implementation of {@link Logging} that delegates to the Split SDK {@link Logger}. + */ +public class SplitLogging implements Logging { + + @Override + public void logError(String message) { + Logger.e(message); + } + + @Override + public void logWarning(String message) { + Logger.w(message); + } + + @Override + public void logInfo(String message) { + Logger.i(message); + } + + @Override + public void logDebug(String message) { + Logger.d(message); + } + + @Override + public void logVerbose(String message) { + Logger.v(message); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/delivery/DualExecutorRegistration.java b/events-domain/src/main/java/io/split/android/client/events/delivery/DualExecutorRegistration.java new file mode 100644 index 000000000..56ec23658 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/delivery/DualExecutorRegistration.java @@ -0,0 +1,134 @@ +package io.split.android.client.events.delivery; + +import androidx.annotation.NonNull; + +import java.util.concurrent.Executor; + +import io.harness.events.EventHandler; +import io.harness.events.EventsManager; +import io.harness.events.Logging; +import io.split.android.client.events.logging.SplitLogging; + +/** + * Utility for registering event handlers that need to execute on two different threads. + *

+ * This is useful when an event should trigger both background work and UI updates. + * Each callback is wrapped with its executor before registration. + * + * @param event type + * @param internal event type (for EventsManager) + * @param metadata type + */ +public class DualExecutorRegistration { + + @NonNull + private final Executor mBackgroundExecutor; + @NonNull + private final Executor mMainThreadExecutor; + @NonNull + private final Logging mLogging; + + /** + * Creates a new DualExecutorRegistration with a {@link SplitLogging} instance. + * + * @param backgroundExecutor executor for background execution + * @param mainThreadExecutor executor for main thread execution + */ + public DualExecutorRegistration(@NonNull Executor backgroundExecutor, + @NonNull Executor mainThreadExecutor) { + this(backgroundExecutor, mainThreadExecutor, new SplitLogging()); + } + + /** + * Creates a new DualExecutorRegistration. + *

+ * Package-private for testing. + * + * @param backgroundExecutor executor for background execution + * @param mainThreadExecutor executor for main thread execution + * @param logging logging instance + */ + DualExecutorRegistration(@NonNull Executor backgroundExecutor, + @NonNull Executor mainThreadExecutor, + @NonNull Logging logging) { + if (backgroundExecutor == null) { + throw new IllegalArgumentException("backgroundExecutor cannot be null"); + } + if (mainThreadExecutor == null) { + throw new IllegalArgumentException("mainThreadExecutor cannot be null"); + } + if (logging == null) { + throw new IllegalArgumentException("logging cannot be null"); + } + mBackgroundExecutor = backgroundExecutor; + mMainThreadExecutor = mainThreadExecutor; + mLogging = logging; + } + + /** + * Registers two handlers for the same event, each executing on its respective thread. + * + * @param eventsManager the events manager to register with + * @param event the event to register for + * @param backgroundCallback callback to execute on the background thread + * @param mainThreadCallback callback to execute on the main thread + */ + public void register(@NonNull EventsManager eventsManager, + @NonNull E event, + @NonNull EventHandler backgroundCallback, + @NonNull EventHandler mainThreadCallback) { + if (eventsManager == null || event == null) { + return; + } + + if (backgroundCallback != null) { + eventsManager.register(event, wrapWithExecutor(backgroundCallback, mBackgroundExecutor)); + } + + if (mainThreadCallback != null) { + eventsManager.register(event, wrapWithExecutor(mainThreadCallback, mMainThreadExecutor)); + } + } + + /** + * Registers a single handler for the background thread only. + * + * @param eventsManager the events manager to register with + * @param event the event to register for + * @param backgroundCallback callback to execute on the background thread + */ + public void registerBackground(@NonNull EventsManager eventsManager, + @NonNull E event, + @NonNull EventHandler backgroundCallback) { + if (eventsManager == null || event == null || backgroundCallback == null) { + return; + } + eventsManager.register(event, wrapWithExecutor(backgroundCallback, mBackgroundExecutor)); + } + + /** + * Registers a single handler for the main thread only. + * + * @param eventsManager the events manager to register with + * @param event the event to register for + * @param mainThreadCallback callback to execute on the main thread + */ + public void registerMainThread(@NonNull EventsManager eventsManager, + @NonNull E event, + @NonNull EventHandler mainThreadCallback) { + if (eventsManager == null || event == null || mainThreadCallback == null) { + return; + } + eventsManager.register(event, wrapWithExecutor(mainThreadCallback, mMainThreadExecutor)); + } + + private EventHandler wrapWithExecutor(EventHandler handler, Executor executor) { + return (event, metadata) -> executor.execute(() -> { + try { + handler.handle(event, metadata); + } catch (Exception e) { + mLogging.logError("Exception in event handler: " + e.getMessage()); + } + }); + } +} diff --git a/main/src/main/java/io/split/android/client/events/executors/ClientEventSplitTask.java b/events-domain/src/main/java/io/split/android/client/events/executors/ClientEventSplitTask.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/executors/ClientEventSplitTask.java rename to events-domain/src/main/java/io/split/android/client/events/executors/ClientEventSplitTask.java diff --git a/main/src/main/java/io/split/android/client/events/executors/SplitEventExecutor.java b/events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/executors/SplitEventExecutor.java rename to events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutor.java diff --git a/main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorFactory.java b/events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorFactory.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorFactory.java rename to events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorFactory.java diff --git a/main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResources.java b/events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResources.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResources.java rename to events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResources.java diff --git a/main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResourcesImpl.java b/events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResourcesImpl.java similarity index 80% rename from main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResourcesImpl.java rename to events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResourcesImpl.java index 337d3e28e..c05a2e1c2 100644 --- a/main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResourcesImpl.java +++ b/events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResourcesImpl.java @@ -1,6 +1,6 @@ package io.split.android.client.events.executors; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import io.split.android.client.SplitClient; @@ -14,7 +14,7 @@ public class SplitEventExecutorResourcesImpl implements SplitEventExecutorResour @Override public void setSplitClient(SplitClient client) { - mClient = checkNotNull(client); + mClient = requireNonNull(client); } @Override diff --git a/main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorWithClient.java b/events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorWithClient.java similarity index 90% rename from main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorWithClient.java rename to events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorWithClient.java index c2f3af4db..0181ea703 100644 --- a/main/src/main/java/io/split/android/client/events/executors/SplitEventExecutorWithClient.java +++ b/events-domain/src/main/java/io/split/android/client/events/executors/SplitEventExecutorWithClient.java @@ -1,6 +1,6 @@ package io.split.android.client.events.executors; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import androidx.annotation.NonNull; @@ -18,7 +18,7 @@ public class SplitEventExecutorWithClient implements SplitEventExecutor { public SplitEventExecutorWithClient(@NonNull SplitTaskExecutor taskExecutor, @NonNull SplitEventTask task, @NonNull SplitClient client) { - mSplitTaskExecutor = checkNotNull(taskExecutor); + mSplitTaskExecutor = requireNonNull(taskExecutor); mBackgroundSplitTask = new ClientEventSplitTask(task, client, false); mMainThreadSplitTask = new ClientEventSplitTask(task, client, true); } diff --git a/events-domain/src/main/java/io/split/android/client/events/logging/SplitLogging.java b/events-domain/src/main/java/io/split/android/client/events/logging/SplitLogging.java new file mode 100644 index 000000000..e40e8fe9e --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/logging/SplitLogging.java @@ -0,0 +1,35 @@ +package io.split.android.client.events.logging; + +import io.harness.events.Logging; +import io.split.android.client.utils.logger.Logger; + +/** + * Implementation of {@link Logging} that delegates to the Split SDK {@link Logger}. + */ +public class SplitLogging implements Logging { + + @Override + public void logError(String message) { + Logger.e(message); + } + + @Override + public void logWarning(String message) { + Logger.w(message); + } + + @Override + public void logInfo(String message) { + Logger.i(message); + } + + @Override + public void logDebug(String message) { + Logger.d(message); + } + + @Override + public void logVerbose(String message) { + Logger.v(message); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadata.java b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadata.java new file mode 100644 index 000000000..b98c96f1e --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadata.java @@ -0,0 +1,53 @@ +package io.split.android.client.events.metadata; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collection; + +/** + * Represents metadata associated with SDK events. + *

+ * This is an internal API for SDK infrastructure use. + * Consumers should use the typed metadata classes instead: + * {@code SdkUpdateMetadata} and {@code SdkReadyMetadata}. + *

+ * Values are sanitized to only allow String, Number, Boolean, or List<String>. + */ +public interface EventMetadata { + + /** + * Returns the number of entries in this metadata. + */ + int size(); + + /** + * Returns whether this metadata has no entries. + */ + default boolean isEmpty() { + return size() == 0; + } + + /** + * Returns the collection of values in this metadata. + */ + @NonNull + Collection values(); + + /** + * Returns the value associated with the given key. + * + * @param key the key to look up + * @return the value associated with the key, or null if not found + */ + @Nullable + Object get(@NonNull String key); + + /** + * Returns whether this metadata contains the given key. + * + * @param key the key to check + * @return true if the key exists, false otherwise + */ + boolean containsKey(@NonNull String key); +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataBuilder.java b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataBuilder.java new file mode 100644 index 000000000..86c3b142b --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataBuilder.java @@ -0,0 +1,101 @@ +package io.split.android.client.events.metadata; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Builder for creating {@link EventMetadata} instances. + *

+ * Values are validated during put operations. Only String, Number, Boolean, + * and List<String> values are accepted. Invalid values will be silently ignored. + */ +class EventMetadataBuilder { + + private static final MetadataValidator DEFAULT_VALIDATOR = new MetadataValidatorImpl(); + + private final Map mData = new HashMap<>(); + private final MetadataValidator mValidator; + + EventMetadataBuilder() { + this(DEFAULT_VALIDATOR); + } + + @VisibleForTesting + EventMetadataBuilder(@NonNull MetadataValidator validator) { + mValidator = validator; + } + + /** + * Adds a String value to the metadata. + * + * @param key the key + * @param value the String value + * @return this builder + */ + @NonNull + public EventMetadataBuilder put(@NonNull String key, @NonNull String value) { + if (mValidator.isValidValue(value)) { + mData.put(key, value); + } + return this; + } + + /** + * Adds a Number value to the metadata. + * + * @param key the key + * @param value the Number value (Integer, Long, Double, Float, etc.) + * @return this builder + */ + @NonNull + public EventMetadataBuilder put(@NonNull String key, @NonNull Number value) { + if (mValidator.isValidValue(value)) { + mData.put(key, value); + } + return this; + } + + /** + * Adds a Boolean value to the metadata. + * + * @param key the key + * @param value the Boolean value + * @return this builder + */ + @NonNull + public EventMetadataBuilder put(@NonNull String key, boolean value) { + if (mValidator.isValidValue(value)) { + mData.put(key, value); + } + return this; + } + + /** + * Adds a List of Strings to the metadata. + * + * @param key the key + * @param value the list of strings + * @return this builder + */ + @NonNull + public EventMetadataBuilder put(@NonNull String key, @NonNull List value) { + if (mValidator.isValidValue(value)) { + mData.put(key, value); + } + return this; + } + + /** + * Builds the {@link EventMetadata} instance. + * + * @return a new immutable EventMetadata instance + */ + @NonNull + public EventMetadata build() { + return new EventMetadataImpl(mData); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataHelpers.java b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataHelpers.java new file mode 100644 index 000000000..16018110b --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataHelpers.java @@ -0,0 +1,80 @@ +package io.split.android.client.events.metadata; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +/** + * Helper class for creating {@link EventMetadata} instances. + *

+ * This keeps the metadata keys in a single place to avoid typos and inconsistencies. + */ +public class EventMetadataHelpers { + + private EventMetadataHelpers() { + // Utility class + } + + /** + * Creates metadata for SDK_UPDATE events when flags are updated. + * + * @param updatedFlagNames the list of flag names that were updated + * @return the event metadata with TYPE=FLAGS_UPDATE and NAMES containing the flag names + */ + public static EventMetadata createUpdatedFlagsMetadata(List updatedFlagNames) { + return new EventMetadataBuilder() + .put(MetadataKeys.TYPE, MetadataKeys.TYPE_FLAGS_UPDATE) + .put(MetadataKeys.NAMES, new ArrayList<>(new HashSet<>(updatedFlagNames))) + .build(); + } + + /** + * Creates metadata for SDK_UPDATE events when segments are updated. + *

+ * SEGMENTS_UPDATE always has empty names - segment names are not included in the metadata. + * + * @return the event metadata with TYPE=SEGMENTS_UPDATE and empty NAMES list + */ + public static EventMetadata createUpdatedSegmentsMetadata() { + return new EventMetadataBuilder() + .put(MetadataKeys.TYPE, MetadataKeys.TYPE_SEGMENTS_UPDATE) + .put(MetadataKeys.NAMES, Collections.emptyList()) + .build(); + } + + /** + * Creates metadata for the SDK_READY and SDK_READY_FROM_CACHE events. + * + * @param lastUpdateTimestamp the timestamp when the cache was last updated, or null if not available + * @param initialCacheLoad true if this is an initial cache load (no prior cache), false if loaded from cache + * @return the event metadata + */ + public static EventMetadata createReadyMetadata(@Nullable Long lastUpdateTimestamp, boolean initialCacheLoad) { + EventMetadataBuilder builder = new EventMetadataBuilder() + .put(MetadataKeys.INITIAL_CACHE_LOAD, initialCacheLoad); + + if (lastUpdateTimestamp != null) { + builder.put(MetadataKeys.LAST_UPDATE_TIMESTAMP, lastUpdateTimestamp); + } + + return builder.build(); + } + + /** + * Creates metadata for TARGETING_RULES_SYNC_COMPLETE based on whether cache was already loaded. + *

+ * If cache was already loaded (SDK_READY_FROM_CACHE fired), uses initialCacheLoad=false + * and includes the update timestamp. Otherwise, uses initialCacheLoad=true with no timestamp. + * + * @param cacheAlreadyLoaded true if SDK_READY_FROM_CACHE has already fired + * @param updateTimestamp the timestamp from storage, used only if cacheAlreadyLoaded is true + * @return the event metadata for sync complete + */ + public static EventMetadata createSyncCompleteMetadata(boolean cacheAlreadyLoaded, @Nullable Long updateTimestamp) { + Long timestamp = cacheAlreadyLoaded ? updateTimestamp : null; + return createReadyMetadata(timestamp, !cacheAlreadyLoaded); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataImpl.java b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataImpl.java new file mode 100644 index 000000000..97aace947 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataImpl.java @@ -0,0 +1,55 @@ +package io.split.android.client.events.metadata; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Implementation of {@link EventMetadata}. + * Use {@link EventMetadataBuilder} to create instances. + */ +class EventMetadataImpl implements EventMetadata { + + private final Map mData; + + EventMetadataImpl(@NonNull Map data) { + Map copy = new HashMap<>(); + for (Map.Entry entry : data.entrySet()) { + Object value = entry.getValue(); + if (value instanceof List) { + copy.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>((List) value))); + } else { + copy.put(entry.getKey(), value); + } + } + mData = Collections.unmodifiableMap(copy); + } + + @Override + public int size() { + return mData.size(); + } + + @NonNull + @Override + public Collection values() { + return mData.values(); + } + + @Nullable + @Override + public Object get(@NonNull String key) { + return mData.get(key); + } + + @Override + public boolean containsKey(@NonNull String key) { + return mData.containsKey(key); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataKeys.java b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataKeys.java new file mode 100644 index 000000000..73ff243e7 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataKeys.java @@ -0,0 +1,45 @@ +package io.split.android.client.events.metadata; + +/** + * Consolidated metadata keys for SDK events. + *

+ * Package-private - for internal SDK use only. + */ +final class MetadataKeys { + + private MetadataKeys() { + // no instances + } + + // SDK_UPDATE event keys + + /** + * The type of update (FLAGS_UPDATE or SEGMENTS_UPDATE). + */ + static final String TYPE = "type"; + + static final String TYPE_FLAGS_UPDATE = "FLAGS_UPDATE"; + static final String TYPE_SEGMENTS_UPDATE = "SEGMENTS_UPDATE"; + + /** + * Names of entities that changed in this update. + *

+ * For FLAGS_UPDATE, these are flag names. + * For SEGMENTS_UPDATE, these are rule-based segment names. + */ + static final String NAMES = "names"; + + // SDK_READY and SDK_READY_FROM_CACHE event keys + + /** + * True if this is an initial cache load with no usable cache. + */ + static final String INITIAL_CACHE_LOAD = "initialCacheLoad"; + + /** + * Last successful cache timestamp in milliseconds since epoch. + *

+ * May be absent when not available. + */ + static final String LAST_UPDATE_TIMESTAMP = "lastUpdateTimestamp"; +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidator.java b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidator.java new file mode 100644 index 000000000..3a25ff3ba --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidator.java @@ -0,0 +1,8 @@ +package io.split.android.client.events.metadata; + +import androidx.annotation.Nullable; + +interface MetadataValidator { + + boolean isValidValue(@Nullable Object value); +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidatorImpl.java b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidatorImpl.java new file mode 100644 index 000000000..64a579d53 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidatorImpl.java @@ -0,0 +1,31 @@ +package io.split.android.client.events.metadata; + +import androidx.annotation.Nullable; + +import java.util.List; + +class MetadataValidatorImpl implements MetadataValidator { + + @Override + public boolean isValidValue(@Nullable Object value) { + if (value == null) { + return false; + } + + if (value instanceof String || value instanceof Number || value instanceof Boolean) { + return true; + } + + if (value instanceof List) { + List list = (List) value; + for (Object item : list) { + if (!(item instanceof String)) { + return false; + } + } + return true; + } + + return false; + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/metadata/TypedTaskConverter.java b/events-domain/src/main/java/io/split/android/client/events/metadata/TypedTaskConverter.java new file mode 100644 index 000000000..9c2e2b526 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/TypedTaskConverter.java @@ -0,0 +1,66 @@ +package io.split.android.client.events.metadata; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; + +import io.split.android.client.events.SdkReadyMetadata; +import io.split.android.client.events.SdkUpdateMetadata; + +/** + * Converts {@link EventMetadata} to typed metadata objects for typed event tasks. +*/ +public class TypedTaskConverter { + + private TypedTaskConverter() { + // Utility class + } + + /** + * Converts EventMetadata to SdkUpdateMetadata. + * + * @param metadata the event metadata, may be null + * @return the typed metadata for SDK_UPDATE events + */ + @NonNull + @SuppressWarnings("unchecked") + public static SdkUpdateMetadata convertForSdkUpdate(@Nullable EventMetadata metadata) { + SdkUpdateMetadata.Type type = null; + List names = null; + + if (metadata != null) { + // Extract type + String typeString = (String) metadata.get(MetadataKeys.TYPE); + if (typeString != null) { + try { + type = SdkUpdateMetadata.Type.valueOf(typeString); + } catch (IllegalArgumentException ignored) { + // Unknown type, leave as null + } + } + + // Extract names + names = (List) metadata.get(MetadataKeys.NAMES); + } + + return new SdkUpdateMetadata(type, names); + } + + /** + * Converts EventMetadata to SdkReadyMetadata. + * + * @param metadata the event metadata, may be null + * @return the typed metadata for SDK_READY and SDK_READY_FROM_CACHE events + */ + @NonNull + public static SdkReadyMetadata convertForSdkReady(@Nullable EventMetadata metadata) { + Boolean initialCacheLoad = null; + Long lastUpdateTimestamp = null; + if (metadata != null) { + initialCacheLoad = (Boolean) metadata.get(MetadataKeys.INITIAL_CACHE_LOAD); + lastUpdateTimestamp = (Long) metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP); + } + return new SdkReadyMetadata(initialCacheLoad, lastUpdateTimestamp); + } +} diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTask.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTask.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitTask.java rename to events-domain/src/main/java/io/split/android/client/service/executor/SplitTask.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java similarity index 85% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java rename to events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java index 944ec329b..ada8052a8 100644 --- a/main/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java +++ b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java @@ -1,6 +1,6 @@ package io.split.android.client.service.executor; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -12,7 +12,7 @@ public class SplitTaskBatchItem { private final WeakReference listener; public SplitTaskBatchItem(@NonNull SplitTask task, @Nullable SplitTaskExecutionListener listener) { - this.task = checkNotNull(task); + this.task = requireNonNull(task); this.listener = new WeakReference<>(listener); } diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java similarity index 93% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java rename to events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java index 09683c24b..09f120cf9 100644 --- a/main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java +++ b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java @@ -1,6 +1,6 @@ package io.split.android.client.service.executor; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -43,9 +43,9 @@ public static SplitTaskExecutionInfo error(SplitTaskType taskType, private SplitTaskExecutionInfo(SplitTaskType taskType, @NonNull SplitTaskExecutionStatus status, @NonNull Map data) { - this.taskType = checkNotNull(taskType); - this.status = checkNotNull(status); - this.data = checkNotNull(data); + this.taskType = requireNonNull(taskType); + this.status = requireNonNull(status); + this.data = requireNonNull(data); } public SplitTaskExecutionStatus getStatus() { diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java rename to events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java rename to events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java rename to events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskType.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskType.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskType.java rename to events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskType.java diff --git a/main/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java b/events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java rename to events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java diff --git a/main/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java b/events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java similarity index 100% rename from main/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java rename to events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java diff --git a/events-domain/src/test/java/io/split/android/client/events/.gitkeep b/events-domain/src/test/java/io/split/android/client/events/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/events-domain/src/test/java/io/split/android/client/events/DualExecutorRegistrationTest.java b/events-domain/src/test/java/io/split/android/client/events/DualExecutorRegistrationTest.java new file mode 100644 index 000000000..a0a8e4bf0 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/DualExecutorRegistrationTest.java @@ -0,0 +1,231 @@ +package io.split.android.client.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import io.harness.events.EventHandler; +import io.harness.events.EventsManager; +import io.harness.events.Logging; + +public class DualExecutorRegistrationTest { + + private static final long TIMEOUT_MS = 1000; + private static final Executor DIRECT_EXECUTOR = Runnable::run; + + private EventsManager mockEventsManager; + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + mockEventsManager = mock(EventsManager.class); + } + + @Test + public void registerCallsEventsManagerTwice() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.register( + mockEventsManager, + "testEvent", + (e, m) -> {}, + (e, m) -> {} + ); + + verify(mockEventsManager, times(2)).register(eq("testEvent"), any()); + } + + @Test + public void registerBackgroundCallsEventsManagerOnce() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.registerBackground(mockEventsManager, "testEvent", (e, m) -> {}); + + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test + public void registerMainThreadCallsEventsManagerOnce() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.registerMainThread(mockEventsManager, "testEvent", (e, m) -> {}); + + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test + @SuppressWarnings("unchecked") + public void wrappedHandlersExecuteOnCorrectExecutors() throws InterruptedException { + ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setName("background-thread"); + return t; + }); + ExecutorService mainThreadExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setName("main-thread"); + return t; + }); + + DualExecutorRegistration registration = + new DualExecutorRegistration<>(backgroundExecutor, mainThreadExecutor); + + CountDownLatch latch = new CountDownLatch(2); + AtomicReference bgThreadName = new AtomicReference<>(); + AtomicReference mainThreadName = new AtomicReference<>(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(EventHandler.class); + + registration.register( + mockEventsManager, + "testEvent", + (e, m) -> { + bgThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + }, + (e, m) -> { + mainThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + } + ); + + verify(mockEventsManager, times(2)).register(eq("testEvent"), captor.capture()); + + // Invoke both captured handlers + for (EventHandler handler : captor.getAllValues()) { + handler.handle("testEvent", null); + } + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals("background-thread", bgThreadName.get()); + assertEquals("main-thread", mainThreadName.get()); + + backgroundExecutor.shutdown(); + mainThreadExecutor.shutdown(); + } + + @Test + @SuppressWarnings("unchecked") + public void wrappedHandlerSwallowsExceptions() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + AtomicInteger secondCallCount = new AtomicInteger(0); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(EventHandler.class); + + registration.register( + mockEventsManager, + "testEvent", + (e, m) -> { throw new RuntimeException("Test exception"); }, + (e, m) -> secondCallCount.incrementAndGet() + ); + + verify(mockEventsManager, times(2)).register(eq("testEvent"), captor.capture()); + + // Invoke both handlers - first throws, second should still work + for (EventHandler handler : captor.getAllValues()) { + handler.handle("testEvent", null); + } + + assertEquals(1, secondCallCount.get()); + } + + @Test + @SuppressWarnings("unchecked") + public void exceptionInHandlerIsLogged() { + Logging mockLogging = mock(Logging.class); + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR, mockLogging); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(EventHandler.class); + + registration.registerBackground( + mockEventsManager, + "testEvent", + (e, m) -> { throw new RuntimeException("Test exception message"); } + ); + + verify(mockEventsManager).register(eq("testEvent"), captor.capture()); + + captor.getValue().handle("testEvent", null); + + verify(mockLogging).logError(eq("Exception in event handler: Test exception message")); + } + + @Test + public void registerIgnoresNullEventsManager() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + // Should not throw + registration.register(null, "testEvent", (e, m) -> {}, (e, m) -> {}); + } + + @Test + public void registerIgnoresNullEvent() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + // Should not throw + registration.register(mockEventsManager, null, (e, m) -> {}, (e, m) -> {}); + + verify(mockEventsManager, times(0)).register(any(), any()); + } + + @Test + public void registerHandlesNullBackgroundCallback() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.register(mockEventsManager, "testEvent", null, (e, m) -> {}); + + // Only main thread callback should be registered + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test + public void registerHandlesNullMainThreadCallback() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.register(mockEventsManager, "testEvent", (e, m) -> {}, null); + + // Only background callback should be registered + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorThrowsOnNullBackgroundExecutor() { + new DualExecutorRegistration<>(null, DIRECT_EXECUTOR); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorThrowsOnNullMainThreadExecutor() { + new DualExecutorRegistration<>(DIRECT_EXECUTOR, null); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorThrowsOnNullLogging() { + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR, null); + } +} diff --git a/events-domain/src/test/java/io/split/android/client/events/SplitEventsManagerConfigFactoryTest.java b/events-domain/src/test/java/io/split/android/client/events/SplitEventsManagerConfigFactoryTest.java new file mode 100644 index 000000000..bff046a70 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/SplitEventsManagerConfigFactoryTest.java @@ -0,0 +1,214 @@ +package io.split.android.client.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Set; + +import io.harness.events.EventsManagerConfig; + +public class SplitEventsManagerConfigFactoryTest { + + @Test + public void configIsNotNull() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + assertNotNull(config); + } + + @Test + public void sdkReadyRequiresTargetingRulesSyncCompleteAndMembershipsSyncComplete() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Set requireAll = config.getRequireAll().get(SplitEvent.SDK_READY); + assertNotNull("SDK_READY should have requireAll configuration", requireAll); + assertTrue("SDK_READY should require TARGETING_RULES_SYNC_COMPLETE", + requireAll.contains(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + assertTrue("SDK_READY should require MEMBERSHIPS_SYNC_COMPLETE", + requireAll.contains(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE)); + assertEquals("SDK_READY should require exactly 2 events", 2, requireAll.size()); + } + + @Test + public void sdkReadyHasPrerequisiteSdkReadyFromCache() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Set prerequisites = config.getPrerequisites().get(SplitEvent.SDK_READY); + assertNotNull("SDK_READY should have prerequisites", prerequisites); + assertTrue("SDK_READY should require SDK_READY_FROM_CACHE as prerequisite", + prerequisites.contains(SplitEvent.SDK_READY_FROM_CACHE)); + } + + @Test + public void sdkReadyHasExecutionLimitOne() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Integer limit = config.getExecutionLimits().get(SplitEvent.SDK_READY); + assertNotNull("SDK_READY should have execution limit", limit); + assertEquals("SDK_READY should fire at most once", 1, (int) limit); + } + + @Test + public void sdkReadyFromCacheHasOrOfAndsConfiguration() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Set> requireAnyGroups = config.getRequireAny().get(SplitEvent.SDK_READY_FROM_CACHE); + assertNotNull("SDK_READY_FROM_CACHE should have requireAny configuration", requireAnyGroups); + assertEquals("SDK_READY_FROM_CACHE should have 2 groups (cache and sync)", 2, requireAnyGroups.size()); + + boolean hasCacheGroup = false; + boolean hasSyncGroup = false; + for (Set group : requireAnyGroups) { + if (group.size() == 4 && + group.contains(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE) && + group.contains(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE) && + group.contains(SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE) && + group.contains(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE)) { + hasCacheGroup = true; + } + if (group.size() == 2 && + group.contains(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE) && + group.contains(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE)) { + hasSyncGroup = true; + } + } + assertTrue("SDK_READY_FROM_CACHE should have cache group", hasCacheGroup); + assertTrue("SDK_READY_FROM_CACHE should have sync group", hasSyncGroup); + } + + @Test + public void sdkReadyFromCacheHasExecutionLimitOne() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Integer limit = config.getExecutionLimits().get(SplitEvent.SDK_READY_FROM_CACHE); + assertNotNull("SDK_READY_FROM_CACHE should have execution limit", limit); + assertEquals("SDK_READY_FROM_CACHE should fire at most once", 1, (int) limit); + } + @Test + public void sdkReadyTimedOutRequiresTimeoutReached() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Set> requireAnyGroups = config.getRequireAny().get(SplitEvent.SDK_READY_TIMED_OUT); + assertNotNull("SDK_READY_TIMED_OUT should have requireAny configuration", requireAnyGroups); + + boolean hasTimeoutTrigger = false; + for (Set group : requireAnyGroups) { + if (group.contains(SplitInternalEvent.SDK_READY_TIMEOUT_REACHED)) { + hasTimeoutTrigger = true; + break; + } + } + assertTrue("SDK_READY_TIMED_OUT should be triggered by SDK_READY_TIMEOUT_REACHED", hasTimeoutTrigger); + } + + @Test + public void sdkReadyTimedOutIsSuppressedBySdkReady() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Set suppressors = config.getSuppressedBy().get(SplitEvent.SDK_READY_TIMED_OUT); + assertNotNull("SDK_READY_TIMED_OUT should have suppressors", suppressors); + assertTrue("SDK_READY_TIMED_OUT should be suppressed by SDK_READY", + suppressors.contains(SplitEvent.SDK_READY)); + } + + @Test + public void sdkReadyTimedOutHasExecutionLimitOne() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Integer limit = config.getExecutionLimits().get(SplitEvent.SDK_READY_TIMED_OUT); + assertNotNull("SDK_READY_TIMED_OUT should have execution limit", limit); + assertEquals("SDK_READY_TIMED_OUT should fire at most once", 1, (int) limit); + } + + @Test + public void sdkUpdateHasCorrectTriggers() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Set> requireAnyGroups = config.getRequireAny().get(SplitEvent.SDK_UPDATE); + assertNotNull("SDK_UPDATE should have requireAny configuration", requireAnyGroups); + + // Each trigger should be in its own singleton group + boolean hasSplitsUpdated = false; + boolean hasMySegmentsUpdated = false; + boolean hasMyLargeSegmentsUpdated = false; + boolean hasRuleBasedSegmentsUpdated = false; + boolean hasSplitKilledNotification = false; + + for (Set group : requireAnyGroups) { + if (group.size() == 1) { + if (group.contains(SplitInternalEvent.SPLITS_UPDATED)) hasSplitsUpdated = true; + if (group.contains(SplitInternalEvent.MY_SEGMENTS_UPDATED)) hasMySegmentsUpdated = true; + if (group.contains(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED)) hasMyLargeSegmentsUpdated = true; + if (group.contains(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED)) hasRuleBasedSegmentsUpdated = true; + if (group.contains(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION)) hasSplitKilledNotification = true; + } + } + + assertTrue("SDK_UPDATE should be triggered by SPLITS_UPDATED", hasSplitsUpdated); + assertTrue("SDK_UPDATE should be triggered by MY_SEGMENTS_UPDATED", hasMySegmentsUpdated); + assertTrue("SDK_UPDATE should be triggered by MY_LARGE_SEGMENTS_UPDATED", hasMyLargeSegmentsUpdated); + assertTrue("SDK_UPDATE should be triggered by RULE_BASED_SEGMENTS_UPDATED", hasRuleBasedSegmentsUpdated); + assertTrue("SDK_UPDATE should be triggered by SPLIT_KILLED_NOTIFICATION", hasSplitKilledNotification); + } + + @Test + public void sdkUpdateHasPrerequisiteSdkReady() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Set prerequisites = config.getPrerequisites().get(SplitEvent.SDK_UPDATE); + assertNotNull("SDK_UPDATE should have prerequisites", prerequisites); + assertTrue("SDK_UPDATE should require SDK_READY as prerequisite", + prerequisites.contains(SplitEvent.SDK_READY)); + } + + @Test + public void sdkUpdateHasUnlimitedExecutions() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + Integer limit = config.getExecutionLimits().get(SplitEvent.SDK_UPDATE); + assertNotNull("SDK_UPDATE should have execution limit", limit); + assertEquals("SDK_UPDATE should have unlimited executions (-1)", -1, (int) limit); + } + + @Test + public void evaluationOrderIsNotEmpty() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + assertNotNull("Evaluation order should not be null", config.getEvaluationOrder()); + assertFalse("Evaluation order should not be empty", config.getEvaluationOrder().isEmpty()); + } + + @Test + public void evaluationOrderContainsAllConfiguredExternalEvents() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + assertTrue("Evaluation order should contain SDK_READY", + config.getEvaluationOrder().contains(SplitEvent.SDK_READY)); + assertTrue("Evaluation order should contain SDK_READY_FROM_CACHE", + config.getEvaluationOrder().contains(SplitEvent.SDK_READY_FROM_CACHE)); + assertTrue("Evaluation order should contain SDK_READY_TIMED_OUT", + config.getEvaluationOrder().contains(SplitEvent.SDK_READY_TIMED_OUT)); + assertTrue("Evaluation order should contain SDK_UPDATE", + config.getEvaluationOrder().contains(SplitEvent.SDK_UPDATE)); + } + + @Test + public void evaluationOrderHasPrerequisitesBeforeDependents() { + EventsManagerConfig config = SplitEventsManagerConfigFactory.create(); + + // SDK_READY_FROM_CACHE must come before SDK_READY (prerequisite) + int readyFromCacheIndex = config.getEvaluationOrder().indexOf(SplitEvent.SDK_READY_FROM_CACHE); + int readyIndex = config.getEvaluationOrder().indexOf(SplitEvent.SDK_READY); + assertTrue("SDK_READY_FROM_CACHE should be evaluated before SDK_READY", + readyFromCacheIndex < readyIndex); + + // SDK_READY must come before SDK_UPDATE (prerequisite) + int updateIndex = config.getEvaluationOrder().indexOf(SplitEvent.SDK_UPDATE); + assertTrue("SDK_READY should be evaluated before SDK_UPDATE", + readyIndex < updateIndex); + } +} + diff --git a/events-domain/src/test/java/io/split/android/client/events/TypedTaskConversionTest.java b/events-domain/src/test/java/io/split/android/client/events/TypedTaskConversionTest.java new file mode 100644 index 000000000..926a755c5 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/TypedTaskConversionTest.java @@ -0,0 +1,81 @@ +package io.split.android.client.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.events.metadata.EventMetadataHelpers; +import io.split.android.client.events.metadata.TypedTaskConverter; + +/** + * Tests for typed task metadata conversion. + */ +public class TypedTaskConversionTest { + + @Test + public void convertForSdkUpdateConvertsFlagsMetadataCorrectly() { + List expectedFlags = Arrays.asList("flag1", "flag2"); + + EventMetadata eventMetadata = EventMetadataHelpers.createUpdatedFlagsMetadata(expectedFlags); + + // Call conversion method + SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(eventMetadata); + + assertNotNull(converted); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, converted.getType()); + assertEquals(expectedFlags.size(), converted.getNames().size()); + assertTrue(converted.getNames().containsAll(expectedFlags)); + } + + @Test + public void convertForSdkUpdateConvertsSegmentsMetadataCorrectly() { + // SEGMENTS_UPDATE always has empty names + EventMetadata eventMetadata = EventMetadataHelpers.createUpdatedSegmentsMetadata(); + + // Call conversion method + SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(eventMetadata); + + assertNotNull(converted); + assertEquals(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, converted.getType()); + assertTrue("Names should be empty for SEGMENTS_UPDATE", converted.getNames().isEmpty()); + } + + @Test + public void convertForSdkReadyConvertsMetadataCorrectly() { + long expectedTimestamp = 1704067200000L; + + EventMetadata eventMetadata = EventMetadataHelpers.createReadyMetadata(expectedTimestamp, true); + + // Call conversion method + SdkReadyMetadata converted = TypedTaskConverter.convertForSdkReady(eventMetadata); + + assertNotNull(converted); + assertTrue(converted.isInitialCacheLoad()); + assertEquals(Long.valueOf(expectedTimestamp), converted.getLastUpdateTimestamp()); + } + + @Test + public void convertForSdkUpdateHandlesNullMetadata() { + SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(null); + + assertNotNull(converted); + assertNull(converted.getType()); + assertTrue(converted.getNames().isEmpty()); + } + + @Test + public void convertForSdkReadyHandlesNullMetadata() { + SdkReadyMetadata converted = TypedTaskConverter.convertForSdkReady(null); + + assertNotNull(converted); + assertNull(converted.isInitialCacheLoad()); + assertNull(converted.getLastUpdateTimestamp()); + } +} diff --git a/events-domain/src/test/java/io/split/android/client/events/delivery/DualExecutorRegistrationTest.java b/events-domain/src/test/java/io/split/android/client/events/delivery/DualExecutorRegistrationTest.java new file mode 100644 index 000000000..b748926e5 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/delivery/DualExecutorRegistrationTest.java @@ -0,0 +1,231 @@ +package io.split.android.client.events.delivery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import io.harness.events.EventHandler; +import io.harness.events.EventsManager; +import io.harness.events.Logging; + +public class DualExecutorRegistrationTest { + + private static final long TIMEOUT_MS = 1000; + private static final Executor DIRECT_EXECUTOR = Runnable::run; + + private EventsManager mockEventsManager; + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + mockEventsManager = mock(EventsManager.class); + } + + @Test + public void registerCallsEventsManagerTwice() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.register( + mockEventsManager, + "testEvent", + (e, m) -> {}, + (e, m) -> {} + ); + + verify(mockEventsManager, times(2)).register(eq("testEvent"), any()); + } + + @Test + public void registerBackgroundCallsEventsManagerOnce() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.registerBackground(mockEventsManager, "testEvent", (e, m) -> {}); + + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test + public void registerMainThreadCallsEventsManagerOnce() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.registerMainThread(mockEventsManager, "testEvent", (e, m) -> {}); + + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test + @SuppressWarnings("unchecked") + public void wrappedHandlersExecuteOnCorrectExecutors() throws InterruptedException { + ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setName("background-thread"); + return t; + }); + ExecutorService mainThreadExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setName("main-thread"); + return t; + }); + + DualExecutorRegistration registration = + new DualExecutorRegistration<>(backgroundExecutor, mainThreadExecutor); + + CountDownLatch latch = new CountDownLatch(2); + AtomicReference bgThreadName = new AtomicReference<>(); + AtomicReference mainThreadName = new AtomicReference<>(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(EventHandler.class); + + registration.register( + mockEventsManager, + "testEvent", + (e, m) -> { + bgThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + }, + (e, m) -> { + mainThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + } + ); + + verify(mockEventsManager, times(2)).register(eq("testEvent"), captor.capture()); + + // Invoke both captured handlers + for (EventHandler handler : captor.getAllValues()) { + handler.handle("testEvent", null); + } + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals("background-thread", bgThreadName.get()); + assertEquals("main-thread", mainThreadName.get()); + + backgroundExecutor.shutdown(); + mainThreadExecutor.shutdown(); + } + + @Test + @SuppressWarnings("unchecked") + public void wrappedHandlerSwallowsExceptions() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + AtomicInteger secondCallCount = new AtomicInteger(0); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(EventHandler.class); + + registration.register( + mockEventsManager, + "testEvent", + (e, m) -> { throw new RuntimeException("Test exception"); }, + (e, m) -> secondCallCount.incrementAndGet() + ); + + verify(mockEventsManager, times(2)).register(eq("testEvent"), captor.capture()); + + // Invoke both handlers - first throws, second should still work + for (EventHandler handler : captor.getAllValues()) { + handler.handle("testEvent", null); + } + + assertEquals(1, secondCallCount.get()); + } + + @Test + @SuppressWarnings("unchecked") + public void exceptionInHandlerIsLogged() { + Logging mockLogging = mock(Logging.class); + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR, mockLogging); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(EventHandler.class); + + registration.registerBackground( + mockEventsManager, + "testEvent", + (e, m) -> { throw new RuntimeException("Test exception message"); } + ); + + verify(mockEventsManager).register(eq("testEvent"), captor.capture()); + + captor.getValue().handle("testEvent", null); + + verify(mockLogging).logError(eq("Exception in event handler: Test exception message")); + } + + @Test + public void registerIgnoresNullEventsManager() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + // Should not throw + registration.register(null, "testEvent", (e, m) -> {}, (e, m) -> {}); + } + + @Test + public void registerIgnoresNullEvent() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + // Should not throw + registration.register(mockEventsManager, null, (e, m) -> {}, (e, m) -> {}); + + verify(mockEventsManager, times(0)).register(any(), any()); + } + + @Test + public void registerHandlesNullBackgroundCallback() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.register(mockEventsManager, "testEvent", null, (e, m) -> {}); + + // Only main thread callback should be registered + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test + public void registerHandlesNullMainThreadCallback() { + DualExecutorRegistration registration = + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR); + + registration.register(mockEventsManager, "testEvent", (e, m) -> {}, null); + + // Only background callback should be registered + verify(mockEventsManager, times(1)).register(eq("testEvent"), any()); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorThrowsOnNullBackgroundExecutor() { + new DualExecutorRegistration<>(null, DIRECT_EXECUTOR); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorThrowsOnNullMainThreadExecutor() { + new DualExecutorRegistration<>(DIRECT_EXECUTOR, null); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorThrowsOnNullLogging() { + new DualExecutorRegistration<>(DIRECT_EXECUTOR, DIRECT_EXECUTOR, null); + } +} diff --git a/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataBuilderTest.java b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataBuilderTest.java new file mode 100644 index 000000000..c9d638dee --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataBuilderTest.java @@ -0,0 +1,199 @@ +package io.split.android.client.events.metadata; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.List; + +public class EventMetadataBuilderTest { + + @Mock + private MetadataValidator mValidator; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void putStringUsesValidator() { + when(mValidator.isValidValue(any())).thenReturn(true); + + new EventMetadataBuilder(mValidator) + .put("key", "value"); + + verify(mValidator).isValidValue("value"); + } + + @Test + public void putNumberUsesValidator() { + when(mValidator.isValidValue(any())).thenReturn(true); + + new EventMetadataBuilder(mValidator) + .put("key", 42); + + verify(mValidator).isValidValue(42); + } + + @Test + public void putBooleanUsesValidator() { + when(mValidator.isValidValue(any())).thenReturn(true); + + new EventMetadataBuilder(mValidator) + .put("key", true); + + verify(mValidator).isValidValue(true); + } + + @Test + public void putListUsesValidator() { + when(mValidator.isValidValue(any())).thenReturn(true); + List list = Arrays.asList("a", "b"); + + new EventMetadataBuilder(mValidator) + .put("key", list); + + verify(mValidator).isValidValue(list); + } + + @Test + public void putIgnoresValueWhenValidatorReturnsFalse() { + when(mValidator.isValidValue(any())).thenReturn(false); + + EventMetadata metadata = new EventMetadataBuilder(mValidator) + .put("key", "value") + .build(); + + assertFalse(metadata.containsKey("key")); + } + + @Test + public void putIncludesValueWhenValidatorReturnsTrue() { + when(mValidator.isValidValue(any())).thenReturn(true); + + EventMetadata metadata = new EventMetadataBuilder(mValidator) + .put("key", "value") + .build(); + + assertEquals("value", metadata.get("key")); + } + + @Test + public void buildCreatesEmptyMetadataWhenNothingAdded() { + EventMetadata metadata = new EventMetadataBuilder().build(); + + assertTrue(metadata.isEmpty()); + } + + @Test + public void putStringAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("key", "value") + .build(); + + assertEquals("value", metadata.get("key")); + } + + @Test + public void putIntegerAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("count", 42) + .build(); + + assertEquals(Integer.valueOf(42), metadata.get("count")); + } + + @Test + public void putLongAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("timestamp", 1234567890L) + .build(); + + assertEquals(Long.valueOf(1234567890L), metadata.get("timestamp")); + } + + @Test + public void putDoubleAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("rate", 3.14) + .build(); + + assertEquals(Double.valueOf(3.14), metadata.get("rate")); + } + + @Test + public void putBooleanTrueAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("enabled", true) + .build(); + + assertEquals(Boolean.TRUE, metadata.get("enabled")); + } + + @Test + public void putBooleanFalseAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("disabled", false) + .build(); + + assertEquals(Boolean.FALSE, metadata.get("disabled")); + } + + @Test + public void putListOfStringsAddsValue() { + List flags = Arrays.asList("flag_1", "flag_2", "flag_3"); + + EventMetadata metadata = new EventMetadataBuilder() + .put("names", flags) + .build(); + + assertEquals(flags, metadata.get(MetadataKeys.NAMES)); + } + + @Test + public void chainingMultiplePutsWorks() { + EventMetadata metadata = new EventMetadataBuilder() + .put("string", "text") + .put("number", 100) + .put("flag", true) + .put("list", Arrays.asList("a", "b")) + .build(); + + assertEquals(4, metadata.size()); + assertEquals("text", metadata.get("string")); + assertEquals(Integer.valueOf(100), metadata.get("number")); + assertEquals(Boolean.TRUE, metadata.get("flag")); + assertEquals(Arrays.asList("a", "b"), metadata.get("list")); + } + + @Test + public void overwritingKeyUsesLastValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("key", "first") + .put("key", "second") + .build(); + + assertEquals("second", metadata.get("key")); + } + + @Test + public void buildReturnsNewInstanceEachTime() { + EventMetadataBuilder builder = new EventMetadataBuilder() + .put("key", "value"); + + EventMetadata metadata1 = builder.build(); + EventMetadata metadata2 = builder.build(); + + assertEquals(metadata1.get("key"), metadata2.get("key")); + } +} diff --git a/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataHelpersTest.java b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataHelpersTest.java new file mode 100644 index 000000000..9dca0abcf --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataHelpersTest.java @@ -0,0 +1,125 @@ +package io.split.android.client.events.metadata; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +public class EventMetadataHelpersTest { + + // Tests for createUpdatedFlagsMetadata + @Test + @SuppressWarnings("unchecked") + public void createUpdatedFlagsMetadataContainsTypeAndNames() { + List flags = Arrays.asList("flag1", "flag2", "flag3"); + EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata(flags); + + assertTrue(metadata.containsKey(MetadataKeys.TYPE)); + assertEquals(MetadataKeys.TYPE_FLAGS_UPDATE, metadata.get(MetadataKeys.TYPE)); + + // Check names + assertTrue(metadata.containsKey(MetadataKeys.NAMES)); + List result = (List) metadata.get(MetadataKeys.NAMES); + assertEquals(3, result.size()); + assertTrue(result.contains("flag1")); + assertTrue(result.contains("flag2")); + assertTrue(result.contains("flag3")); + } + + // Tests for createUpdatedSegmentsMetadata + @Test + @SuppressWarnings("unchecked") + public void createUpdatedSegmentsMetadataContainsTypeAndEmptyNames() { + EventMetadata metadata = EventMetadataHelpers.createUpdatedSegmentsMetadata(); + + assertTrue(metadata.containsKey(MetadataKeys.TYPE)); + assertEquals(MetadataKeys.TYPE_SEGMENTS_UPDATE, metadata.get(MetadataKeys.TYPE)); + + // Check names - should always be empty + assertTrue(metadata.containsKey(MetadataKeys.NAMES)); + List result = (List) metadata.get(MetadataKeys.NAMES); + assertTrue("Names should be empty for SEGMENTS_UPDATE", result.isEmpty()); + } + + // Tests for createReadyMetadata + @Test + public void createReadyMetadataWithTimestampAndInitialCacheLoadFalse() { + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(1234567890L, false); + + assertEquals(Long.valueOf(1234567890L), metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); + } + + @Test + public void createReadyMetadataWithNullTimestampAndInitialCacheLoadTrue() { + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(null, true); + + assertNull(metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); + } + + @Test + public void createReadyMetadataKeysAreCorrect() { + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(123L, false); + + assertTrue(metadata.containsKey(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertTrue(metadata.containsKey(MetadataKeys.INITIAL_CACHE_LOAD)); + assertEquals(2, metadata.size()); + } + + @Test + public void createReadyMetadataWithZeroTimestamp() { + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(0L, false); + + assertEquals(Long.valueOf(0L), metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); + } + + @Test + public void createReadyMetadataForCachePath() { + // Cache path: initialCacheLoad=false, timestamp from storage + long storedTimestamp = 1700000000000L; + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(storedTimestamp, false); + + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); + assertEquals(Long.valueOf(storedTimestamp), metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + } + + @Test + public void createReadyMetadataForSyncPath() { + // Sync path: initialCacheLoad=true, timestamp=null + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(null, true); + + assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); + assertNull(metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + } + + @Test + public void createSyncCompleteMetadataWhenCacheAlreadyLoaded() { + long updateTimestamp = 1234567890L; + EventMetadata metadata = EventMetadataHelpers.createSyncCompleteMetadata(true, updateTimestamp); + + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); + assertEquals(updateTimestamp, metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + } + + @Test + public void createSyncCompleteMetadataWhenCacheNotLoaded() { + EventMetadata metadata = EventMetadataHelpers.createSyncCompleteMetadata(false, 1234567890L); + + assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); + assertNull(metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + } + + @Test + public void createSyncCompleteMetadataIgnoresTimestampWhenCacheNotLoaded() { + // Even if a timestamp is provided, it should be ignored when cache is not loaded + EventMetadata metadata = EventMetadataHelpers.createSyncCompleteMetadata(false, 9999999999L); + + assertNull(metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + } +} diff --git a/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataImplTest.java b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataImplTest.java new file mode 100644 index 000000000..5f539c6a6 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataImplTest.java @@ -0,0 +1,149 @@ +package io.split.android.client.events.metadata; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +public class EventMetadataImplTest { + + @Test + public void sizeAndContainsKeyReflectStoredEntries() { + Map data = new HashMap<>(); + data.put("key1", "value1"); + data.put("key2", 42); + data.put("key3", true); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + assertEquals(3, metadata.size()); + assertTrue(metadata.containsKey("key1")); + assertTrue(metadata.containsKey("key2")); + assertTrue(metadata.containsKey("key3")); + } + + @Test + public void isEmptyReturnsTrueForEmptyMetadata() { + EventMetadataImpl metadata = new EventMetadataImpl(new HashMap<>()); + + assertTrue(metadata.isEmpty()); + } + + @Test + public void valuesReturnsAllValues() { + Map data = new HashMap<>(); + data.put("string", "value"); + data.put("number", 42); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + Collection values = metadata.values(); + + assertEquals(2, values.size()); + assertTrue(values.contains("value")); + assertTrue(values.contains(42)); + } + + @Test + public void valuesReturnsEmptyCollectionForEmptyMetadata() { + EventMetadataImpl metadata = new EventMetadataImpl(new HashMap<>()); + + assertTrue(metadata.values().isEmpty()); + } + + @Test + public void getReturnsValueForExistingKey() { + Map data = new HashMap<>(); + data.put("key", "value"); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + assertEquals("value", metadata.get("key")); + } + + @Test + public void getReturnsNullForNonExistingKey() { + Map data = new HashMap<>(); + data.put("key", "value"); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + assertNull(metadata.get("nonExistingKey")); + } + + @Test + public void containsKeyReturnsTrueForExistingKey() { + Map data = new HashMap<>(); + data.put("key", "value"); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + assertTrue(metadata.containsKey("key")); + } + + @Test + public void containsKeyReturnsFalseForNonExistingKey() { + Map data = new HashMap<>(); + data.put("key", "value"); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + assertFalse(metadata.containsKey("nonExistingKey")); + } + + @Test + public void metadataIsImmutableAfterConstruction() { + Map data = new HashMap<>(); + data.put("key", "value"); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + // Modify original map + data.put("newKey", "newValue"); + + // Metadata should not be affected + assertFalse(metadata.containsKey("newKey")); + assertEquals(1, metadata.size()); + } + + @Test + @SuppressWarnings("unchecked") + public void listIsDefensivelyCopiedDuringConstruction() { + List originalList = new ArrayList<>(Arrays.asList("flag_1", "flag_2")); + Map data = new HashMap<>(); + data.put("flags", originalList); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + // Modify original list after construction + originalList.add("flag_3"); + + // Metadata should not be affected + List storedList = (List) metadata.get("flags"); + assertEquals(2, storedList.size()); + assertEquals(Arrays.asList("flag_1", "flag_2"), storedList); + } + + @Test(expected = UnsupportedOperationException.class) + @SuppressWarnings("unchecked") + public void listReturnedByGetIsUnmodifiable() { + Map data = new HashMap<>(); + data.put("flags", Arrays.asList("flag_1", "flag_2")); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + List list = (List) metadata.get("flags"); + + // This should throw UnsupportedOperationException + list.add("flag_3"); + } +} diff --git a/events-domain/src/test/java/io/split/android/client/events/metadata/MetadataKeysTest.java b/events-domain/src/test/java/io/split/android/client/events/metadata/MetadataKeysTest.java new file mode 100644 index 000000000..bf5d6db35 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/MetadataKeysTest.java @@ -0,0 +1,32 @@ +package io.split.android.client.events.metadata; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * Tests for {@link MetadataKeys}. + * Verifies that all metadata keys are correctly defined. + */ +public class MetadataKeysTest { + + @Test + public void typeKeyHasCorrectValue() { + assertEquals("type", MetadataKeys.TYPE); + } + + @Test + public void namesKeyHasCorrectValue() { + assertEquals("names", MetadataKeys.NAMES); + } + + @Test + public void initialCacheLoadKeyHasCorrectValue() { + assertEquals("initialCacheLoad", MetadataKeys.INITIAL_CACHE_LOAD); + } + + @Test + public void lastUpdateTimestampKeyHasCorrectValue() { + assertEquals("lastUpdateTimestamp", MetadataKeys.LAST_UPDATE_TIMESTAMP); + } +} diff --git a/events-domain/src/test/java/io/split/android/client/events/metadata/MetadataValidatorImplTest.java b/events-domain/src/test/java/io/split/android/client/events/metadata/MetadataValidatorImplTest.java new file mode 100644 index 000000000..ad3098ef2 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/MetadataValidatorImplTest.java @@ -0,0 +1,132 @@ +package io.split.android.client.events.metadata; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +public class MetadataValidatorImplTest { + + private MetadataValidator mValidator; + + @Before + public void setUp() { + mValidator = new MetadataValidatorImpl(); + } + + @Test + public void isValidValueReturnsTrueForString() { + assertTrue(mValidator.isValidValue("value")); + } + + @Test + public void isValidValueReturnsTrueForEmptyString() { + assertTrue(mValidator.isValidValue("")); + } + + @Test + public void isValidValueReturnsTrueForInteger() { + assertTrue(mValidator.isValidValue(42)); + } + + @Test + public void isValidValueReturnsTrueForLong() { + assertTrue(mValidator.isValidValue(1234567890L)); + } + + @Test + public void isValidValueReturnsTrueForDouble() { + assertTrue(mValidator.isValidValue(3.14)); + } + + @Test + public void isValidValueReturnsTrueForFloat() { + assertTrue(mValidator.isValidValue(2.5f)); + } + + @Test + public void isValidValueReturnsTrueForBooleanTrue() { + assertTrue(mValidator.isValidValue(true)); + } + + @Test + public void isValidValueReturnsTrueForBooleanFalse() { + assertTrue(mValidator.isValidValue(false)); + } + + @Test + public void isValidValueReturnsTrueForListOfStrings() { + List list = Arrays.asList("flag_1", "flag_2", "flag_3"); + assertTrue(mValidator.isValidValue(list)); + } + + @Test + public void isValidValueReturnsTrueForEmptyList() { + assertTrue(mValidator.isValidValue(Collections.emptyList())); + } + + @Test + public void isValidValueReturnsTrueForSingleElementStringList() { + assertTrue(mValidator.isValidValue(Collections.singletonList("single"))); + } + + @Test + public void isValidValueReturnsFalseForNull() { + assertFalse(mValidator.isValidValue(null)); + } + + @Test + public void isValidValueReturnsFalseForListWithNullElement() { + List list = Arrays.asList("valid", null, "also_valid"); + assertFalse(mValidator.isValidValue(list)); + } + + @Test + public void isValidValueReturnsFalseForListWithMixedTypes() { + List mixedList = Arrays.asList("string", 123, true); + assertFalse(mValidator.isValidValue(mixedList)); + } + + @Test + public void isValidValueReturnsFalseForListOfIntegers() { + List intList = Arrays.asList(1, 2, 3); + assertFalse(mValidator.isValidValue(intList)); + } + + @Test + public void isValidValueReturnsFalseForListOfBooleans() { + List boolList = Arrays.asList(true, false, true); + assertFalse(mValidator.isValidValue(boolList)); + } + + @Test + public void isValidValueReturnsFalseForPlainObject() { + assertFalse(mValidator.isValidValue(new Object())); + } + + @Test + public void isValidValueReturnsFalseForMap() { + assertFalse(mValidator.isValidValue(new HashMap())); + } + + @Test + public void isValidValueReturnsFalseForNestedList() { + List> nestedList = Arrays.asList( + Arrays.asList("a", "b"), + Arrays.asList("c", "d") + ); + assertFalse(mValidator.isValidValue(nestedList)); + } + + @Test + public void isValidValueReturnsFalseForArray() { + String[] array = {"a", "b", "c"}; + assertFalse(mValidator.isValidValue(array)); + } +} diff --git a/events/.gitignore b/events/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/events/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/events/README.md b/events/README.md new file mode 100644 index 000000000..b91d8c63f --- /dev/null +++ b/events/README.md @@ -0,0 +1,78 @@ +# Events module + +This module provides a generic events management system. + +Allows the definition of internal and external events interdependencies, as well as registration. + +## Core Concepts + +### Internal vs External Events + +- **Internal Events**: Low-level events triggered by the system (e.g., data loaded, sync completed) +- **External Events**: High-level events exposed to consumers (e.g., SDK_READY, SDK_UPDATE) + +### Event Configuration + +Events are configured using `EventsManagerConfig.Builder`: + +- **`requireAll(external, internal...)`**: External event fires when ALL internal events have occurred +- **`requireAny(external, internal...)`**: External event fires when ANY internal event occurs +- **`requireAny(external, Set...)`**: OR-of-ANDs pattern; fires when any group is fully satisfied +- **`prerequisite(external, prerequisiteExternal)`**: External event can only fire after the prerequisite external event has fired +- **`suppressedBy(external, suppressorExternal)`**: External event is permanently suppressed if the suppressor external event has already fired +- **`executionLimit(external, limit)`**: Max times the event can fire (-1 = unlimited, 1 = once only) +- **`metadataSource(external, internal)`**: For `requireAll`, selects the internal event whose metadata will be delivered +- **`metadataSource(external, Set, internal)`**: For `requireAny` groups, selects the metadata source per group + +## Topological Sort for Evaluation Order + +The events system uses **topological sorting** to determine the order in which external events are evaluated. This is essential for correctness. + +### Evaluation Flow + +1. **Internal Event Arrives**: A single internal event can potentially satisfy conditions for multiple external events. +2. **Single-Pass Evaluation**: The system iterates through a pre-computed list of external events (`mEvaluationOrder`). +3. **Order Matters**: This list is topologically sorted so that events with dependencies (prerequisites/suppression) come *after* the events they depend on. +4. **Metadata Selection**: When an external event fires, metadata is resolved from the configured source event: + - `requireAll`: use the configured source internal event + - `requireAny`: use the source configured for the specific group that completed + +### Why It's Necessary + +When a single internal event notification could trigger multiple external events, they must be evaluated in the correct order based on their dependencies. + +#### Prerequisite Example + +``` +SDK_READY_FROM_CACHE ←prerequisite← SDK_READY +``` + +If both events' conditions are satisfied by the same internal event: + +- **Without sort**: If `SDK_READY` is checked first, `prerequisitesSatisfied()` returns `false` because `SDK_READY_FROM_CACHE` hasn't fired yet. `SDK_READY` misses its chance to fire in this cycle. +- **With sort**: `SDK_READY_FROM_CACHE` is evaluated first, fires, then `SDK_READY` sees its prerequisite satisfied and fires—all in one pass. + +#### SuppressedBy Example + +``` +SDK_READY ──suppressedBy──► SDK_READY_TIMED_OUT +``` + +If both events' conditions are satisfied by the same internal event: + +- **Without sort**: If `SDK_READY_TIMED_OUT` is checked first, `isSuppressed()` returns `false` because `SDK_READY` hasn't fired yet. Both events fire incorrectly. +- **With sort**: `SDK_READY` is evaluated first, fires, then `SDK_READY_TIMED_OUT` sees it's suppressed and doesn't fire. + +### Implementation Details + +The sorting logic is split into: + +- **`EventsManagerConfig`**: Holds the raw configuration. +- **`EvaluationOrderComputer`**: Gathers all configured events and builds the dependency graph based on prerequisites and suppressors. +- **`TopologicalSorter`**: A generic utility that performs the DFS-based topological sort with cycle detection. + +The topological sort treats both `prerequisite` and `suppressedBy` as dependency edges: +- If A has `prerequisite` B → B must be evaluated before A +- If A is `suppressedBy` B → B must be evaluated before A + +**Note:** All configured events are included in the evaluation order, even those without dependencies. Independent events can appear anywhere in the list relative to each other, but always before/after their dependents/dependencies as required. diff --git a/events/build.gradle b/events/build.gradle new file mode 100644 index 000000000..b4a4d8ee9 --- /dev/null +++ b/events/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.harness.events' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + compileOnly libs.jetbrainsAnnotations + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/events/consumer-rules.pro b/events/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/events/proguard-rules.pro b/events/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/events/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/events/src/main/AndroidManifest.xml b/events/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/events/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/events/src/main/java/io/harness/events/EvaluationOrderComputer.java b/events/src/main/java/io/harness/events/EvaluationOrderComputer.java new file mode 100644 index 000000000..14079d530 --- /dev/null +++ b/events/src/main/java/io/harness/events/EvaluationOrderComputer.java @@ -0,0 +1,116 @@ +package io.harness.events; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Computes the evaluation order of events based on their prerequisites and suppression relationships. + *

+ * Prerequisites and suppressions imply a dependency between events, so prerequisites and + * suppressors need to be evaluated before their dependents. + * + * @param event type + */ +final class EvaluationOrderComputer { + + private final Set mAllEvents; + private final Map> mPrerequisites; + private final Map> mSuppressedBy; + + /** + * Creates a new EvaluationOrderComputer. + * + * @param allEvents all events that need to be included in the evaluation order + * @param prerequisites map from event to its prerequisites (events that must fire before it) + * @param suppressedBy map from event to its suppressors (events that, if fired, suppress it) + */ + EvaluationOrderComputer(Set allEvents, Map> prerequisites, Map> suppressedBy) { + mAllEvents = allEvents != null ? allEvents : Collections.emptySet(); + mPrerequisites = prerequisites != null ? prerequisites : Collections.emptyMap(); + mSuppressedBy = suppressedBy != null ? suppressedBy : Collections.emptyMap(); + } + + /** + * Computes the topological sort of events based on prerequisites and suppression. + *

+ * Edge direction: If A depends on B (prerequisite or suppression), then B -> A (B must come before A). + * + * @return topologically sorted list of events + * @throws IllegalStateException if a circular dependency is detected + */ + List compute() { + Set allEvents = gatherAllEvents(); + + if (allEvents.isEmpty()) { + return Collections.emptyList(); + } + + Map> dependencies = buildDependencyGraph(allEvents); + + return new TopologicalSorter<>(allEvents, dependencies).sort(); + } + + private Set gatherAllEvents() { + Set allEvents = new HashSet<>(mAllEvents); + + // Also include events that appear as values in prerequisites/suppression + // (they might not be configured themselves but need to be evaluated first) + for (Set prereqs : mPrerequisites.values()) { + allEvents.addAll(prereqs); + } + for (Set suppressors : mSuppressedBy.values()) { + allEvents.addAll(suppressors); + } + + return allEvents; + } + + /** + * Builds the dependency graph from prerequisites and suppression relationships. + *

+ * For each event, tracks which events must come before it. + *

+ * For example, the following configuration: + *

+     * A -> B // B is a prerequisite for A
+     * B -> C // B is suppressed by C
+     * 
+ * Will result in the following dependency graph: + *
+     * {
+     *   A: [B], // A depends on B
+     *   B: [C], // B depends on C
+     *   C: [], // C has no dependencies
+     * }
+     * 
+ */ + private Map> buildDependencyGraph(Set allEvents) { + Map> dependencies = new HashMap<>(); + for (E event : allEvents) { + dependencies.put(event, new HashSet<>()); + } + + // Add edges: if A has prerequisite B, then B -> A (B must come before A) + for (Map.Entry> entry : mPrerequisites.entrySet()) { + E dependent = entry.getKey(); + for (E prerequisite : entry.getValue()) { + dependencies.get(dependent).add(prerequisite); + } + } + + // Add edges: if A is suppressed by B, then B -> A (B must come before A) + for (Map.Entry> entry : mSuppressedBy.entrySet()) { + E suppressed = entry.getKey(); + for (E suppressor : entry.getValue()) { + dependencies.get(suppressed).add(suppressor); + } + } + + return dependencies; + } +} + diff --git a/events/src/main/java/io/harness/events/EventDelivery.java b/events/src/main/java/io/harness/events/EventDelivery.java new file mode 100644 index 000000000..1ad9e6565 --- /dev/null +++ b/events/src/main/java/io/harness/events/EventDelivery.java @@ -0,0 +1,12 @@ +package io.harness.events; + +/** + * Interface for event delivery. + * + * @param event type + * @param metadata type + */ +public interface EventDelivery { + + void deliver(EventHandler eventHandler, E event, M metadata); +} diff --git a/events/src/main/java/io/harness/events/EventHandler.java b/events/src/main/java/io/harness/events/EventHandler.java new file mode 100644 index 000000000..d12c73f24 --- /dev/null +++ b/events/src/main/java/io/harness/events/EventHandler.java @@ -0,0 +1,13 @@ +package io.harness.events; + +/** + * Interface for event handlers. This represents a callback + * that will be executed when an event is triggered. + * + * @param event type + * @param metadata type + */ +public interface EventHandler { + + void handle(E event, M metadata); +} diff --git a/events/src/main/java/io/harness/events/EventsManager.java b/events/src/main/java/io/harness/events/EventsManager.java new file mode 100644 index 000000000..2bd84bfd3 --- /dev/null +++ b/events/src/main/java/io/harness/events/EventsManager.java @@ -0,0 +1,50 @@ +package io.harness.events; + +import org.jetbrains.annotations.Nullable; + +/** + * Interface for events manager. + * + * @param external events type + * @param internal events type + * @param metadata type + */ +public interface EventsManager { + + /** + * Registers a handler to be executed when the event is triggered. + * + * @param event event to register + * @param handler handler to execute when the event is triggered + */ + void register(E event, EventHandler handler); + + /** + * Unregisters all registered handlers for an event. + * + * @param event event to unregister handlers for + */ + void unregister(E event); + + /** + * Notifies an internal event has occurred. + * + * @param event internal event to notify + * @param metadata optional metadata + */ + void notifyInternalEvent(I event, @Nullable M metadata); + + /** + * Checks if the event has already been triggered. + * + * @param event event to check + * @return whether event has been triggered + */ + boolean eventAlreadyTriggered(E event); + + /** + * Destroys the events manager. + * This should be called when the events manager is no longer needed. + */ + void destroy(); +} diff --git a/events/src/main/java/io/harness/events/EventsManagerConfig.java b/events/src/main/java/io/harness/events/EventsManagerConfig.java new file mode 100644 index 000000000..54886f1a0 --- /dev/null +++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java @@ -0,0 +1,318 @@ +package io.harness.events; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Contains the interdependencies between events and internal events. + * + * @param external events type + * @param internal events type + */ +public final class EventsManagerConfig { + // External events that require ALL listed internals (AND) + private final Map> mRequireAll; + // External events triggered by ANY of the listed internal groups (OR of ANDs) + private final Map>> mRequireAny; + // External-event guards: prerequisites that must have fired before External can emit + private final Map> mPrerequisites; + // External-event guards: if any of these have fired, suppress E + private final Map> mSuppressedBy; + // Execution policy: max executions per external event (-1 = unlimited) + private final Map mExecutionLimits; + // Metadata source for requireAll events + private final Map mRequireAllMetadataSource; + // Metadata source for requireAny groups + private final Map, I>> mRequireAnyMetadataSource; + // Topologically sorted evaluation order (prerequisites and suppressors come before dependents) + private final List mEvaluationOrder; + + /** + * Creates a new EventsManagerConfig. + * + * @param requireAll External events that require ALL listed internals (AND) + * @param requireAny External events triggered by ANY of the listed internal groups (OR of ANDs) + * @param prerequisites External-event guards: prerequisites that must have fired before External can emit + * @param suppressedBy External-event guards: if any of these have fired, suppress E + * @param executionLimits Execution policy: max executions per external event (-1 = unlimited) + */ + private EventsManagerConfig(Map> requireAll, + Map>> requireAny, + Map> prerequisites, + Map> suppressedBy, + Map executionLimits, + Map requireAllMetadataSource, + Map, I>> requireAnyMetadataSource) { + mRequireAll = requireAll == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(requireAll)); + mRequireAny = requireAny == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(requireAny)); + mPrerequisites = prerequisites == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(prerequisites)); + mSuppressedBy = suppressedBy == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(suppressedBy)); + mExecutionLimits = executionLimits == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(executionLimits)); + mRequireAllMetadataSource = requireAllMetadataSource == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(requireAllMetadataSource)); + mRequireAnyMetadataSource = requireAnyMetadataSource == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(requireAnyMetadataSource)); + + mEvaluationOrder = computeEvaluationOrder(); + } + + public static EventsManagerConfig empty() { + return new EventsManagerConfig<>(Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap()); + } + + private List computeEvaluationOrder() { + Set allEvents = new HashSet<>(); + allEvents.addAll(mRequireAll.keySet()); + allEvents.addAll(mRequireAny.keySet()); + allEvents.addAll(mPrerequisites.keySet()); + allEvents.addAll(mSuppressedBy.keySet()); + allEvents.addAll(mExecutionLimits.keySet()); + + return new EvaluationOrderComputer<>(allEvents, mPrerequisites, mSuppressedBy).compute(); + } + + @NotNull + public Map> getRequireAll() { + return mRequireAll; + } + + @NotNull + public Map>> getRequireAny() { + return mRequireAny; + } + + @NotNull + public Map> getPrerequisites() { + return mPrerequisites; + } + + @NotNull + public Map> getSuppressedBy() { + return mSuppressedBy; + } + + @NotNull + public Map getExecutionLimits() { + return mExecutionLimits; + } + + @NotNull + public Map getRequireAllMetadataSource() { + return mRequireAllMetadataSource; + } + + @NotNull + public Map, I>> getRequireAnyMetadataSource() { + return mRequireAnyMetadataSource; + } + + @NotNull + public List getEvaluationOrder() { + return mEvaluationOrder; + } + + /** + * Creates a new Builder for EventsManagerConfig. + * + * @param external events type + * @param internal events type + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder<>(); + } + + /** + * Builder for EventsManagerConfig. + * + * @param external events type + * @param internal events type + */ + public static final class Builder { + private final Map> mRequireAll = new HashMap<>(); + private final Map>> mRequireAny = new HashMap<>(); + private final Map> mPrerequisites = new HashMap<>(); + private final Map> mSuppressedBy = new HashMap<>(); + private final Map mExecutionLimits = new HashMap<>(); + private final Map mRequireAllMetadataSource = new HashMap<>(); + private final Map, I>> mRequireAnyMetadataSource = new HashMap<>(); + + private Builder() { + } + + /** + * Adds a requirement that ALL specified internal events must occur for the external event to fire. + * + * @param externalEvent the external event + * @param internalEvents the internal events that must ALL occur + * @return this builder + */ + @SafeVarargs + public final Builder requireAll(E externalEvent, I... internalEvents) { + mRequireAll.put(externalEvent, new HashSet<>(Arrays.asList(internalEvents))); + return this; + } + + /** + * Adds a requirement that ANY of the specified internal events will trigger the external event. + * Each internal event is treated as a group of one (singleton). + * + * @param externalEvent the external event + * @param internalEvents the internal events, any of which will trigger the external event + * @return this builder + */ + @SafeVarargs + public final Builder requireAny(E externalEvent, I... internalEvents) { + // Convert each individual event to a singleton Set (group of one) + Set> groups = new HashSet<>(); + for (I internalEvent : internalEvents) { + groups.add(Collections.singleton(internalEvent)); + } + mRequireAny.put(externalEvent, groups); + return this; + } + + /** + * Adds a requirement that ANY of the specified internal event groups will trigger the external event. + * Each group is an AND: all events in the group must occur. + * The external event fires when ANY group is fully satisfied (OR of ANDs). + *

+ * Example: + *

+         * .requireAny(DISH_SERVED,
+         *     Set.of(BOUGHT_INGREDIENTS, COOKED_MEAL),                    // Fresh cooking path
+         *     Set.of(ORDERED_DELIVERY, DELIVERY_ARRIVED))                 // Delivery path
+         * // Fires when: (fresh cooking done) OR (delivery arrived)
+         * 
+ * + * @param externalEvent the external event + * @param internalEventGroups the groups of internal events; all events in a group must occur (AND), + * and any group being satisfied triggers the external event (OR) + * @return this builder + */ + @SafeVarargs + public final Builder requireAny(E externalEvent, Set... internalEventGroups) { + Set> groups = new HashSet<>(Arrays.asList(internalEventGroups)); + mRequireAny.put(externalEvent, groups); + return this; + } + + /** + * Adds a prerequisite: the external event can only fire after the prerequisite event has fired. + * + * @param externalEvent the external event + * @param prerequisiteEvent the event that must fire first + * @return this builder + */ + public Builder prerequisite(E externalEvent, E prerequisiteEvent) { + Set set = mPrerequisites.get(externalEvent); + if (set == null) { + set = new HashSet<>(); + mPrerequisites.put(externalEvent, set); + } + set.add(prerequisiteEvent); + return this; + } + + /** + * Adds a suppressor: the external event will be suppressed if the suppressor event has already fired. + * + * @param externalEvent the external event + * @param suppressorEvent the event that suppresses the external event + * @return this builder + */ + public Builder suppressedBy(E externalEvent, E suppressorEvent) { + Set set = mSuppressedBy.get(externalEvent); + if (set == null) { + set = new HashSet<>(); + mSuppressedBy.put(externalEvent, set); + } + set.add(suppressorEvent); + return this; + } + + /** + * Sets the execution limit for an external event. + * + * @param externalEvent the external event + * @param limit max executions (-1 = unlimited, 1 = once only) + * @return this builder + */ + public Builder executionLimit(E externalEvent, int limit) { + mExecutionLimits.put(externalEvent, limit); + return this; + } + + /** + * Sets the metadata source for a requireAll external event. + * + * @param externalEvent the external event + * @param sourceEvent the internal event whose metadata should be used + * @return this builder + */ + public Builder metadataSource(E externalEvent, I sourceEvent) { + mRequireAllMetadataSource.put(externalEvent, sourceEvent); + return this; + } + + /** + * Sets the metadata source for a requireAny group. + * + * @param externalEvent the external event + * @param group the internal event group + * @param sourceEvent the internal event whose metadata should be used + * @return this builder + */ + public Builder metadataSource(E externalEvent, Set group, I sourceEvent) { + Map, I> groupSources = mRequireAnyMetadataSource.get(externalEvent); + if (groupSources == null) { + groupSources = new HashMap<>(); + mRequireAnyMetadataSource.put(externalEvent, groupSources); + } + groupSources.put(new HashSet<>(group), sourceEvent); + return this; + } + + /** + * Builds the EventsManagerConfig. + * + * @return the built config + */ + public EventsManagerConfig build() { + return new EventsManagerConfig<>( + mRequireAll.isEmpty() ? null : mRequireAll, + mRequireAny.isEmpty() ? null : mRequireAny, + mPrerequisites.isEmpty() ? null : mPrerequisites, + mSuppressedBy.isEmpty() ? null : mSuppressedBy, + mExecutionLimits.isEmpty() ? null : mExecutionLimits, + mRequireAllMetadataSource.isEmpty() ? null : mRequireAllMetadataSource, + mRequireAnyMetadataSource.isEmpty() ? null : mRequireAnyMetadataSource + ); + } + } +} diff --git a/events/src/main/java/io/harness/events/EventsManagerCore.java b/events/src/main/java/io/harness/events/EventsManagerCore.java new file mode 100644 index 000000000..d93baf2cf --- /dev/null +++ b/events/src/main/java/io/harness/events/EventsManagerCore.java @@ -0,0 +1,331 @@ +package io.harness.events; + +import org.jetbrains.annotations.NotNull; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +/** + * Core implementation of EventsManager. + * + * @param external events type + * @param internal events type + * @param metadata type + */ +class EventsManagerCore implements EventsManager { + + private static final int UNLIMITED = -1; + + private final Map>> mSubscriptions = new HashMap<>(); + private final Map mTriggerCount = new HashMap<>(); + private final Set mSeenInternal = new HashSet<>(); + private final Map mInternalEventMetadata = new HashMap<>(); + + @NotNull + private final EventsManagerConfig mConfig; + @NotNull + private final EventDelivery mDelivery; + + @NotNull + private final ExecutorService mProcessQueue; + + private final Object mLock = new Object(); + private volatile boolean mRunning = true; + + public EventsManagerCore(EventsManagerConfig config, EventDelivery delivery) { + mConfig = config == null ? EventsManagerConfig.empty() : config; + mDelivery = delivery == null ? (h, e, m) -> {} : delivery; + mProcessQueue = Executors.newSingleThreadExecutor(); + } + + @Override + public void register(E event, EventHandler handler) { + boolean shouldReplay; + synchronized (mLock) { + if (!mRunning) { + return; + } + + int max = maxExecutions(event); + Integer triggered = mTriggerCount.get(event); + + // Replay if limit was reached (event finished all its executions) + shouldReplay = max != UNLIMITED && triggered != null && triggered >= max; + + if (!shouldReplay) { + Set> handlers = mSubscriptions.get(event); + if (handlers == null) { + handlers = new HashSet<>(); + mSubscriptions.put(event, handlers); + } + handlers.add(handler); + } + } + + // Replay if the limit has been reached. Don't add to subscriptions since + // it will not be triggered again (max executions reached). + if (shouldReplay) { + mDelivery.deliver(handler, event, null); + } + } + + @Override + public void unregister(E event) { + synchronized (mLock) { + Set> handlers = mSubscriptions.get(event); + if (handlers != null) { + handlers.clear(); + } + } + } + + @Override + public void notifyInternalEvent(I event, M metadata) { + if (!mRunning) { + return; + } + try { + mProcessQueue.execute(() -> processInternal(event, metadata)); + } catch (RejectedExecutionException e) { + // ignore + } + } + + @Override + public boolean eventAlreadyTriggered(E event) { + // Wait for pending processing to complete for a consistent view + CountDownLatch latch = new CountDownLatch(1); + try { + mProcessQueue.execute(latch::countDown); + latch.await(); + } catch (RejectedExecutionException e) { + // Executor is shut down + } catch (InterruptedException e) { + // Restore interrupt status; check current state + Thread.currentThread().interrupt(); + } + + synchronized (mLock) { + Integer count = mTriggerCount.get(event); + if (count == null) { + return false; + } + + // For unlimited events, return false since they can always fire again + int max = maxExecutions(event); + if (max == UNLIMITED) { + return false; + } + + // For limited events, return true only if all executions are done + return count >= max; + } + } + + @Override + public void destroy() { + synchronized (mLock) { + mRunning = false; + mSubscriptions.clear(); + mTriggerCount.clear(); + mSeenInternal.clear(); + } + mProcessQueue.shutdown(); + } + + private void processInternal(I event, M metadata) { + Set currentSeenInternal; + synchronized (mLock) { + if (!mRunning) { + return; + } + mSeenInternal.add(event); + if (metadata != null) { + mInternalEventMetadata.put(event, metadata); + } + currentSeenInternal = new HashSet<>(mSeenInternal); + } + + // The sorted order guarantees that prerequisites and suppressors are evaluated + // before their dependents. + for (E externalEvent : mConfig.getEvaluationOrder()) { + // Check if internal trigger conditions are met (RequireAll or RequireAny) + InternalTriggerMatch match = checkInternalTriggerConditions(externalEvent, currentSeenInternal, event); + + if (!match.mMatched) { + continue; + } + + // Check external guards (prerequisites and suppression) and fire if all conditions met + M resolvedMetadata = resolveMetadata(externalEvent, match, metadata); + triggerIfConditionsMet(externalEvent, resolvedMetadata); + } + } + + /** + * Triggers an external event if all conditions are met. + * @return true if the event was triggered, false otherwise + */ + private boolean triggerIfConditionsMet(E event, M metadata) { + if (!canEventBeTriggered(event)) { + return false; + } + return trigger(event, metadata); + } + + private boolean canEventBeTriggered(E event) { + return prerequisitesSatisfied(event) && !isSuppressed(event); + } + + /** + * Triggers an external event. + * @return true if the event was triggered, false if it was already at max executions + */ + private boolean trigger(E event, M metadata) { + Set> handlersSnapshot = Collections.emptySet(); + + synchronized (mLock) { + int max = maxExecutions(event); + Integer count = mTriggerCount.get(event); + int triggered = count != null ? count : 0; + + if (max != UNLIMITED && triggered >= max) { + return false; + } + + mTriggerCount.put(event, triggered + 1); + + Set> handlers = mSubscriptions.get(event); + if (handlers != null) { + handlersSnapshot = new HashSet<>(handlers); + } + } + + for (EventHandler handler : handlersSnapshot) { + mDelivery.deliver(handler, event, metadata); + } + return true; + } + + private int maxExecutions(E event) { + Integer limit = mConfig.getExecutionLimits().get(event); + return limit != null ? limit : UNLIMITED; + } + + private boolean prerequisitesSatisfied(E external) { + Set prerequisites = mConfig.getPrerequisites().get(external); + if (prerequisites == null || prerequisites.isEmpty()) { + return true; + } + + synchronized (mLock) { + return mTriggerCount.keySet().containsAll(prerequisites); + } + } + + private boolean isSuppressed(E external) { + Set suppressors = mConfig.getSuppressedBy().get(external); + if (suppressors == null || suppressors.isEmpty()) { + return false; + } + + synchronized (mLock) { + for (E suppressor : suppressors) { + if (mTriggerCount.containsKey(suppressor)) { + return true; + } + } + } + return false; + } + + /** + * Checks if the internal trigger conditions are met for an external event. + * Returns true if either RequireAll or RequireAny conditions are satisfied. + * + * @param externalEvent the external event to check + * @param seenInternal all internal events seen so far + * @param currentEvent the internal event that just arrived + */ + private InternalTriggerMatch checkInternalTriggerConditions(E externalEvent, Set seenInternal, I currentEvent) { + Set requireAll = mConfig.getRequireAll().get(externalEvent); + if (requireAll != null && !requireAll.isEmpty() && seenInternal.containsAll(requireAll)) { + return InternalTriggerMatch.requireAll(); + } + + // Check RequireAny: The CURRENT internal event must be in one of the groups, + // and all events in that group must have been seen. + Set> requireAnyGroups = mConfig.getRequireAny().get(externalEvent); + if (requireAnyGroups != null && !requireAnyGroups.isEmpty()) { + for (Set group : requireAnyGroups) { + // Only consider groups that contain the current event + if (!group.isEmpty() && group.contains(currentEvent) && seenInternal.containsAll(group)) { + return InternalTriggerMatch.requireAny(group); + } + } + } + + return InternalTriggerMatch.none(); + } + + private M resolveMetadata(E externalEvent, InternalTriggerMatch match, M currentMetadata) { + if (match.mRequireAllMatched) { + I sourceEvent = mConfig.getRequireAllMetadataSource().get(externalEvent); + return resolveMetadataFromSource(sourceEvent, currentMetadata); + } + + if (match.mRequireAnyGroup != null) { + Map, I> groupSources = mConfig.getRequireAnyMetadataSource().get(externalEvent); + if (groupSources != null) { + I sourceEvent = groupSources.get(match.mRequireAnyGroup); + return resolveMetadataFromSource(sourceEvent, currentMetadata); + } + } + + return resolveMetadataFromSource(null, currentMetadata); + } + + private M resolveMetadataFromSource(I sourceEvent, M currentMetadata) { + if (sourceEvent != null) { + synchronized (mLock) { + M stored = mInternalEventMetadata.get(sourceEvent); + if (stored != null) { + return stored; + } + } + } + return currentMetadata; + } + + private static class InternalTriggerMatch { + private final boolean mMatched; + private final boolean mRequireAllMatched; + private final Set mRequireAnyGroup; + + private InternalTriggerMatch(boolean matched, boolean requireAllMatched, Set requireAnyGroup) { + mMatched = matched; + mRequireAllMatched = requireAllMatched; + mRequireAnyGroup = requireAnyGroup; + } + + private static InternalTriggerMatch requireAll() { + return new InternalTriggerMatch<>(true, true, null); + } + + private static InternalTriggerMatch requireAny(Set group) { + return new InternalTriggerMatch<>(true, false, group); + } + + private static InternalTriggerMatch none() { + return new InternalTriggerMatch<>(false, false, null); + } + } + +} diff --git a/events/src/main/java/io/harness/events/EventsManagers.java b/events/src/main/java/io/harness/events/EventsManagers.java new file mode 100644 index 000000000..9cdcf4e95 --- /dev/null +++ b/events/src/main/java/io/harness/events/EventsManagers.java @@ -0,0 +1,28 @@ +package io.harness.events; + +/** + * Factory class for creating {@link EventsManager} instances. + * This class decouples the creation of the {@link EventsManager} instance from the implementation. + */ +public final class EventsManagers { + + private EventsManagers() { + // Utility class + } + + /** + * Creates a new EventsManager with the given configuration and delivery mechanism. + * + * @param config the configuration defining event relationships + * @param delivery the delivery mechanism for dispatching events to handlers + * @param external events type + * @param internal events type + * @param metadata type + * @return a new EventsManager instance + */ + public static EventsManager create( + EventsManagerConfig config, + EventDelivery delivery) { + return new EventsManagerCore<>(config, delivery); + } +} diff --git a/events/src/main/java/io/harness/events/Logging.java b/events/src/main/java/io/harness/events/Logging.java new file mode 100644 index 000000000..725ad051b --- /dev/null +++ b/events/src/main/java/io/harness/events/Logging.java @@ -0,0 +1,18 @@ +package io.harness.events; + +/** + * Interface for optional logging in the events module. + * Consumers can implement this interface to log messages. + */ +public interface Logging { + + void logError(String message); + + void logWarning(String message); + + void logInfo(String message); + + void logDebug(String message); + + void logVerbose(String message); +} diff --git a/events/src/main/java/io/harness/events/NoOpLogging.java b/events/src/main/java/io/harness/events/NoOpLogging.java new file mode 100644 index 000000000..079337aa6 --- /dev/null +++ b/events/src/main/java/io/harness/events/NoOpLogging.java @@ -0,0 +1,37 @@ +package io.harness.events; + +/** + * No-op implementation of {@link Logging} for use when logging is not provided. + */ +final class NoOpLogging implements Logging { + + static final Logging INSTANCE = new NoOpLogging(); + + private NoOpLogging() { + } + + @Override + public void logError(String message) { + // no-op + } + + @Override + public void logWarning(String message) { + // no-op + } + + @Override + public void logInfo(String message) { + // no-op + } + + @Override + public void logDebug(String message) { + // no-op + } + + @Override + public void logVerbose(String message) { + // no-op + } +} diff --git a/events/src/main/java/io/harness/events/TopologicalSorter.java b/events/src/main/java/io/harness/events/TopologicalSorter.java new file mode 100644 index 000000000..8ff1c7dab --- /dev/null +++ b/events/src/main/java/io/harness/events/TopologicalSorter.java @@ -0,0 +1,107 @@ +package io.harness.events; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Performs topological sorting of nodes based on their dependencies. + * + * @param the type of nodes to sort + */ +final class TopologicalSorter { + + private final Set mNodes; + private final Map> mDependencies; + + /** + * Creates a new TopologicalSorter. + * + * @param nodes all nodes to be sorted + * @param dependencies map from each node to the set of nodes it depends on + * (i.e., nodes that must come before it) + */ + TopologicalSorter(Set nodes, Map> dependencies) { + mNodes = nodes == null ? Collections.emptySet() : nodes; + mDependencies = dependencies == null ? Collections.emptyMap() : dependencies; + } + + /** + * Computes the topological sort of the nodes. + *

+ * The result is ordered such that for any node A that depends on node B, + * B will appear before A in the returned list. + *

+ * For example, the following dependency graph: + *

+ * ``` + * A -> B // B is a prerequisite for A + * B -> C // C is suppressed by B + * ``` + *

+ * Will result in the following sorted list: + *

+ * ``` + * [C, B, A] + * ``` + * + * @return topologically sorted list of nodes + * @throws IllegalStateException if a circular dependency is detected + */ + List sort() { + if (mNodes.isEmpty()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + Set visited = new HashSet<>(); + Set visiting = new HashSet<>(); // For cycle detection + + for (T node : mNodes) { + if (!visited.contains(node)) { + visit(node, visited, visiting, result); + } + } + + return Collections.unmodifiableList(result); + } + + /** + * Visit all dependencies first (nodes that must come before this one), + * then add the current node to the result list. + *

+ * If a cycle is detected, an exception is thrown. + * + * @param node the current node to visit + * @param visited set of permanently visited nodes + * @param visiting set of nodes currently being visited (for cycle detection) + * @param result the sorted result list + * @throws IllegalStateException if a cycle is detected + */ + private void visit(T node, Set visited, Set visiting, List result) { + if (visited.contains(node)) { + return; // Already processed + } + + if (visiting.contains(node)) { + throw new IllegalStateException("Circular dependency detected involving node: " + node); + } + + visiting.add(node); + + // Visit all dependencies first (nodes that must come before this one) + Set deps = mDependencies.get(node); + if (deps != null) { + for (T dep : deps) { + visit(dep, visited, visiting, result); + } + } + + visiting.remove(node); + visited.add(node); + result.add(node); + } +} diff --git a/events/src/test/java/io/harness/events/EvaluationOrderComputerTest.java b/events/src/test/java/io/harness/events/EvaluationOrderComputerTest.java new file mode 100644 index 000000000..a13244d20 --- /dev/null +++ b/events/src/test/java/io/harness/events/EvaluationOrderComputerTest.java @@ -0,0 +1,317 @@ +package io.harness.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class EvaluationOrderComputerTest { + + @Test + public void emptyInputsReturnEmptyList() { + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + Collections.emptySet(), + Collections.emptyMap(), + Collections.emptyMap() + ); + + List result = computer.compute(); + assertTrue(result.isEmpty()); + } + + @Test + public void nullInputsReturnEmptyList() { + EvaluationOrderComputer computer = new EvaluationOrderComputer<>(null, null, null); + + List result = computer.compute(); + assertTrue(result.isEmpty()); + } + + @Test + public void includesAllEventsFromInput() { + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + allEvents.add("C"); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + Collections.emptyMap(), + Collections.emptyMap() + ); + List result = computer.compute(); + + assertEquals(3, result.size()); + assertTrue(result.contains("A")); + assertTrue(result.contains("B")); + assertTrue(result.contains("C")); + } + + @Test + public void includesEventsFromPrerequisiteValues() { + Set allEvents = Collections.singleton("A"); + + Map> prerequisites = new HashMap<>(); + prerequisites.put("A", Collections.singleton("B")); // B is only in values + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + prerequisites, + Collections.emptyMap() + ); + List result = computer.compute(); + + assertEquals(2, result.size()); + assertTrue(result.contains("A")); + assertTrue(result.contains("B")); + } + + @Test + public void includesEventsFromSuppressorValues() { + Set allEvents = Collections.singleton("A"); + + Map> suppressedBy = new HashMap<>(); + suppressedBy.put("A", Collections.singleton("B")); // B is only in values + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + Collections.emptyMap(), + suppressedBy + ); + List result = computer.compute(); + + assertEquals(2, result.size()); + assertTrue(result.contains("A")); + assertTrue(result.contains("B")); + } + + @Test + public void buildsDependencyGraphFromPrerequisites() { + // A depends on B, B depends on C + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + allEvents.add("C"); + + Map> prerequisites = new HashMap<>(); + prerequisites.put("A", Collections.singleton("B")); + prerequisites.put("B", Collections.singleton("C")); + prerequisites.put("C", Collections.emptySet()); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + prerequisites, + Collections.emptyMap() + ); + List result = computer.compute(); + + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + int idxC = result.indexOf("C"); + + assertTrue("C should come before B", idxC < idxB); + assertTrue("B should come before A", idxB < idxA); + } + + @Test + public void buildsDependencyGraphFromSuppression() { + // A suppressed by B (B must come before A) + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + + Map> suppressedBy = new HashMap<>(); + suppressedBy.put("A", Collections.singleton("B")); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + Collections.emptyMap(), + suppressedBy + ); + List result = computer.compute(); + + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + + assertTrue("B (suppressor) should come before A (suppressed)", idxB < idxA); + } + + @Test + public void combinesPrerequisitesAndSuppression() { + // A depends on B (prerequisite), C suppressed by B + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + allEvents.add("C"); + + Map> prerequisites = new HashMap<>(); + prerequisites.put("A", Collections.singleton("B")); + + Map> suppressedBy = new HashMap<>(); + suppressedBy.put("C", Collections.singleton("B")); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + prerequisites, + suppressedBy + ); + List result = computer.compute(); + + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + int idxC = result.indexOf("C"); + + assertTrue("B should come before A", idxB < idxA); + assertTrue("B should come before C", idxB < idxC); + } + + @Test + public void handlesMultiplePrerequisites() { + // A depends on both B and C + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + allEvents.add("C"); + + Map> prerequisites = new HashMap<>(); + Set aDeps = new HashSet<>(); + aDeps.add("B"); + aDeps.add("C"); + prerequisites.put("A", aDeps); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + prerequisites, + Collections.emptyMap() + ); + List result = computer.compute(); + + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + int idxC = result.indexOf("C"); + + assertTrue("B should come before A", idxB < idxA); + assertTrue("C should come before A", idxC < idxA); + } + + @Test + public void handlesMultipleSuppressors() { + // A suppressed by both B and C + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + allEvents.add("C"); + + Map> suppressedBy = new HashMap<>(); + Set aSuppressors = new HashSet<>(); + aSuppressors.add("B"); + aSuppressors.add("C"); + suppressedBy.put("A", aSuppressors); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + Collections.emptyMap(), + suppressedBy + ); + List result = computer.compute(); + + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + int idxC = result.indexOf("C"); + + assertTrue("B should come before A", idxB < idxA); + assertTrue("C should come before A", idxC < idxA); + } + + @Test(expected = IllegalStateException.class) + public void detectsCircularDependencyThroughPrerequisites() { + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + + Map> prerequisites = new HashMap<>(); + prerequisites.put("A", Collections.singleton("B")); + prerequisites.put("B", Collections.singleton("A")); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + prerequisites, + Collections.emptyMap() + ); + computer.compute(); // Should throw + } + + @Test(expected = IllegalStateException.class) + public void detectsCircularDependencyThroughSuppression() { + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + + Map> suppressedBy = new HashMap<>(); + suppressedBy.put("A", Collections.singleton("B")); + suppressedBy.put("B", Collections.singleton("A")); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + Collections.emptyMap(), + suppressedBy + ); + computer.compute(); // Should throw + } + + @Test(expected = IllegalStateException.class) + public void detectsCircularDependencyThroughMixedRelationships() { + // A depends on B (prerequisite), B suppressed by A + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + + Map> prerequisites = new HashMap<>(); + prerequisites.put("A", Collections.singleton("B")); + + Map> suppressedBy = new HashMap<>(); + suppressedBy.put("B", Collections.singleton("A")); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + prerequisites, + suppressedBy + ); + computer.compute(); // Should throw + } + + @Test + public void eventsWithNoDependenciesAreIncluded() { + // Events without prerequisites or suppression should still be in the result + Set allEvents = new HashSet<>(); + allEvents.add("A"); + allEvents.add("B"); + allEvents.add("C"); + + // Only A has a dependency, B and C are independent + Map> prerequisites = new HashMap<>(); + prerequisites.put("A", Collections.singleton("B")); + + EvaluationOrderComputer computer = new EvaluationOrderComputer<>( + allEvents, + prerequisites, + Collections.emptyMap() + ); + List result = computer.compute(); + + assertEquals(3, result.size()); + assertTrue(result.contains("A")); + assertTrue(result.contains("B")); + assertTrue(result.contains("C")); + + // B should come before A (dependency), C can be anywhere + assertTrue("B should come before A", result.indexOf("B") < result.indexOf("A")); + } +} diff --git a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java new file mode 100644 index 000000000..2662e2aeb --- /dev/null +++ b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java @@ -0,0 +1,313 @@ +package io.harness.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class EventsManagerConfigTest { + + @Test + public void emptyBuilderCreatesEmptyMaps() { + EventsManagerConfig config = EventsManagerConfig.builder().build(); + + assertTrue(config.getRequireAll().isEmpty()); + assertTrue(config.getRequireAny().isEmpty()); + assertTrue(config.getPrerequisites().isEmpty()); + assertTrue(config.getSuppressedBy().isEmpty()); + assertTrue(config.getExecutionLimits().isEmpty()); + assertTrue(config.getRequireAllMetadataSource().isEmpty()); + assertTrue(config.getRequireAnyMetadataSource().isEmpty()); + } + + @Test + public void builderCreatesConfigWithAllFields() { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll("E1", "I1", "I2") + .requireAny("E2", "I3") + .prerequisite("E1", "E0") + .suppressedBy("E1", "E2") + .executionLimit("E1", 3) + .metadataSource("E1", "I2") + .metadataSource("E2", Collections.singleton("I3"), "I3") + .build(); + + assertEquals(1, config.getRequireAll().size()); + assertTrue(config.getRequireAll().get("E1").contains("I1")); + assertTrue(config.getRequireAll().get("E1").contains("I2")); + + // requireAny now stores Set> - single events are wrapped in singleton sets + assertEquals(1, config.getRequireAny().size()); + Set> requireAnyGroups = config.getRequireAny().get("E2"); + assertEquals(1, requireAnyGroups.size()); + assertTrue(requireAnyGroups.contains(Collections.singleton("I3"))); + + assertEquals(1, config.getPrerequisites().size()); + assertTrue(config.getPrerequisites().get("E1").contains("E0")); + + assertEquals(1, config.getSuppressedBy().size()); + assertTrue(config.getSuppressedBy().get("E1").contains("E2")); + + assertEquals(1, config.getExecutionLimits().size()); + assertEquals(Integer.valueOf(3), config.getExecutionLimits().get("E1")); + + assertEquals("I2", config.getRequireAllMetadataSource().get("E1")); + assertEquals("I3", config.getRequireAnyMetadataSource().get("E2") + .get(Collections.singleton("I3"))); + } + + @Test + public void builderAllowsMultiplePrerequisites() { + EventsManagerConfig config = EventsManagerConfig.builder() + .prerequisite("E1", "E0") + .prerequisite("E1", "E2") + .build(); + + assertEquals(1, config.getPrerequisites().size()); + assertEquals(2, config.getPrerequisites().get("E1").size()); + assertTrue(config.getPrerequisites().get("E1").contains("E0")); + assertTrue(config.getPrerequisites().get("E1").contains("E2")); + } + + @Test + public void builderAllowsMultipleSuppressors() { + EventsManagerConfig config = EventsManagerConfig.builder() + .suppressedBy("E1", "E2") + .suppressedBy("E1", "E3") + .build(); + + assertEquals(1, config.getSuppressedBy().size()); + assertEquals(2, config.getSuppressedBy().get("E1").size()); + assertTrue(config.getSuppressedBy().get("E1").contains("E2")); + assertTrue(config.getSuppressedBy().get("E1").contains("E3")); + } + + @Test + public void returnedMapsAreUnmodifiable() { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll("E1", "I1") + .requireAny("E1", "I1") + .prerequisite("E1", "E0") + .suppressedBy("E1", "E2") + .executionLimit("E1", 3) + .metadataSource("E1", "I1") + .build(); + + try { + config.getRequireAll().put("E2", Collections.singleton("I2")); + Assert.fail("getRequireAll() should return an unmodifiable map"); + } catch (UnsupportedOperationException expected) { + // expected + } + + try { + config.getRequireAny().put("E2", Collections.singleton(Collections.singleton("I2"))); + Assert.fail("getRequireAny() should return an unmodifiable map"); + } catch (UnsupportedOperationException expected) { + // expected + } + + try { + config.getPrerequisites().put("E2", Collections.singleton("E3")); + Assert.fail("getPrerequisites() should return an unmodifiable map"); + } catch (UnsupportedOperationException expected) { + // expected + } + + try { + config.getSuppressedBy().put("E2", Collections.singleton("E3")); + Assert.fail("getSuppressedBy() should return an unmodifiable map"); + } catch (UnsupportedOperationException expected) { + // expected + } + + try { + config.getExecutionLimits().put("E2", 5); + Assert.fail("getExecutionLimits() should return an unmodifiable map"); + } catch (UnsupportedOperationException expected) { + // expected + } + + try { + config.getRequireAllMetadataSource().put("E2", "I2"); + Assert.fail("getRequireAllMetadataSource() should return an unmodifiable map"); + } catch (UnsupportedOperationException expected) { + // expected + } + + try { + config.getRequireAnyMetadataSource().put("E2", Collections.singletonMap(Collections.singleton("I2"), "I2")); + Assert.fail("getRequireAnyMetadataSource() should return an unmodifiable map"); + } catch (UnsupportedOperationException expected) { + // expected + } + } + + @Test + public void emptyMethodReturnsEmptyUnmodifiableConfig() { + EventsManagerConfig config = EventsManagerConfig.empty(); + + assertTrue(config.getRequireAll().isEmpty()); + assertTrue(config.getRequireAny().isEmpty()); + assertTrue(config.getPrerequisites().isEmpty()); + assertTrue(config.getSuppressedBy().isEmpty()); + assertTrue(config.getExecutionLimits().isEmpty()); + assertTrue(config.getRequireAllMetadataSource().isEmpty()); + assertTrue(config.getRequireAnyMetadataSource().isEmpty()); + + try { + config.getRequireAll().put("E1", Collections.singleton("I1")); + Assert.fail("getRequireAll() from empty() should be unmodifiable"); + } catch (UnsupportedOperationException expected) { + // expected + } + } + + @Test + public void requireAnyWithVarargsCreatesIndividualGroups() { + // When using requireAny(E, I...), each I should become its own singleton group + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny("E1", "I1", "I2", "I3") + .build(); + + Set> groups = config.getRequireAny().get("E1"); + assertEquals(3, groups.size()); + assertTrue(groups.contains(Collections.singleton("I1"))); + assertTrue(groups.contains(Collections.singleton("I2"))); + assertTrue(groups.contains(Collections.singleton("I3"))); + } + + @Test + public void requireAnyWithSetsCreatesAndGroups() { + // When using requireAny(E, Set...), each Set is an AND group + Set group1 = new HashSet<>(); + group1.add("I1"); + group1.add("I2"); + + Set group2 = new HashSet<>(); + group2.add("I3"); + group2.add("I4"); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny("E1", group1, group2) + .build(); + + Set> groups = config.getRequireAny().get("E1"); + assertEquals(2, groups.size()); + assertTrue(groups.contains(group1)); + assertTrue(groups.contains(group2)); + } + + @Test + public void requireAnyWithMixedGroupSizes() { + // Groups can have different sizes + Set singletonGroup = Collections.singleton("I1"); + + Set largeGroup = new HashSet<>(); + largeGroup.add("I2"); + largeGroup.add("I3"); + largeGroup.add("I4"); + largeGroup.add("I5"); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny("E1", singletonGroup, largeGroup) + .build(); + + Set> groups = config.getRequireAny().get("E1"); + assertEquals(2, groups.size()); + assertTrue(groups.contains(singletonGroup)); + assertTrue(groups.contains(largeGroup)); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowOnCircularPrerequisites() { + EventsManagerConfig.builder() + .requireAll("A", "I1") + .requireAll("B", "I2") + .prerequisite("A", "B") + .prerequisite("B", "A") + .build(); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowOnCircularSuppression() { + EventsManagerConfig.builder() + .requireAll("A", "I1") + .requireAll("B", "I2") + .suppressedBy("A", "B") + .suppressedBy("B", "A") + .build(); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowOnMixedCircularDependency() { + // A requires B, B suppressed by A (B -> A from prereq, A -> B from suppression) + EventsManagerConfig.builder() + .requireAll("A", "I1") + .requireAll("B", "I2") + .prerequisite("A", "B") + .suppressedBy("B", "A") + .build(); + } + + @Test + public void shouldSortByPrerequisites() { + // A depends on B, B depends on C + // Expected order: C, B, A + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll("A", "I1") + .requireAll("B", "I2") + .requireAll("C", "I3") + .prerequisite("A", "B") + .prerequisite("B", "C") + .build(); + + List order = config.getEvaluationOrder(); + int idxA = order.indexOf("A"); + int idxB = order.indexOf("B"); + int idxC = order.indexOf("C"); + + assertTrue("C should come before B", idxC < idxB); + assertTrue("B should come before A", idxB < idxA); + } + + @Test + public void shouldSortBySuppression() { + // A suppressed by B (B must run first to suppress A) + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll("A", "I1") + .requireAll("B", "I2") + .suppressedBy("A", "B") + .build(); + + List order = config.getEvaluationOrder(); + int idxA = order.indexOf("A"); + int idxB = order.indexOf("B"); + + assertTrue("B (suppressor) should come before A (suppressed)", idxB < idxA); + } + + @Test + public void shouldIncludeEventsFromAllSourcesInSort() { + // Events might only appear in prerequisites or suppression lists + // even if they don't have trigger conditions themselves + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll("A", "I1") + // B is not explicitly configured with requirements, but is a prerequisite + .prerequisite("A", "B") + .build(); + + List order = config.getEvaluationOrder(); + assertTrue(order.contains("A")); + assertTrue(order.contains("B")); + assertTrue(order.indexOf("B") < order.indexOf("A")); + } +} diff --git a/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java b/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java new file mode 100644 index 000000000..c543d9b21 --- /dev/null +++ b/events/src/test/java/io/harness/events/EventsManagerMetadataTest.java @@ -0,0 +1,90 @@ +package io.harness.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public class EventsManagerMetadataTest { + + private static final long TIMEOUT_MS = 5000; + + enum ExternalEvent { + READY_FROM_CACHE + } + + enum InternalEvent { + CACHE_A, CACHE_B, SYNC_A, SYNC_B + } + + @Test + public void requireAnyUsesGroupMetadataSource() throws InterruptedException { + Set cacheGroup = new HashSet<>(); + cacheGroup.add(InternalEvent.CACHE_A); + cacheGroup.add(InternalEvent.CACHE_B); + + Set syncGroup = new HashSet<>(); + syncGroup.add(InternalEvent.SYNC_A); + syncGroup.add(InternalEvent.SYNC_B); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(ExternalEvent.READY_FROM_CACHE, cacheGroup, syncGroup) + .metadataSource(ExternalEvent.READY_FROM_CACHE, cacheGroup, InternalEvent.CACHE_A) + .metadataSource(ExternalEvent.READY_FROM_CACHE, syncGroup, InternalEvent.SYNC_A) + .executionLimit(ExternalEvent.READY_FROM_CACHE, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + + EventsManager manager = + new EventsManagerCore<>(config, (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }); + + manager.register(ExternalEvent.READY_FROM_CACHE, (event, metadata) -> received.set(metadata)); + + // Complete sync group: metadata should come from SYNC_A, not from SYNC_B (current event). + manager.notifyInternalEvent(InternalEvent.SYNC_A, "sync-meta"); + manager.notifyInternalEvent(InternalEvent.SYNC_B, "sync-b-meta"); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals("sync-meta", received.get()); + } + + @Test + public void requireAllUsesConfiguredMetadataSource() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(ExternalEvent.READY_FROM_CACHE, InternalEvent.CACHE_A, InternalEvent.CACHE_B) + .metadataSource(ExternalEvent.READY_FROM_CACHE, InternalEvent.CACHE_A) + .executionLimit(ExternalEvent.READY_FROM_CACHE, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + + EventsManager manager = + new EventsManagerCore<>(config, (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }); + + manager.register(ExternalEvent.READY_FROM_CACHE, (event, metadata) -> received.set(metadata)); + + // Provide metadata on CACHE_A only; CACHE_B completes the requireAll. + manager.notifyInternalEvent(InternalEvent.CACHE_A, "cache-meta"); + manager.notifyInternalEvent(InternalEvent.CACHE_B, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(received.get()); + assertEquals("cache-meta", received.get()); + } +} diff --git a/events/src/test/java/io/harness/events/EventsManagerTest.java b/events/src/test/java/io/harness/events/EventsManagerTest.java new file mode 100644 index 000000000..536763989 --- /dev/null +++ b/events/src/test/java/io/harness/events/EventsManagerTest.java @@ -0,0 +1,994 @@ +package io.harness.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class EventsManagerTest { + + private static final long TIMEOUT_MS = 5000; + private static final EventDelivery SIMPLE_DELIVERY = (handler, event, metadata) -> handler.handle(event, metadata); + + /** + * External events emitted to consumers. + *

+ * Dependencies: + * - DISH_SERVED: requires ALL of (INGREDIENTS_PREPPED, SEASONING_ADDED, OVEN_PREHEATED). Fires once. + *

+ * - LEFTOVERS_HEATED: requires ALL of (LEFTOVER_MEAT_FOUND, LEFTOVER_VEGGIES_FOUND, LEFTOVER_SAUCE_FOUND, PLATES_RETRIEVED). Fires once. + *

+ * - SEASONING_ADJUSTED: requires ANY of (SEASONING_ADDED). Prerequisite: DISH_SERVED. Fires unlimited times. + *

+ * - ORDER_TIMED_OUT: requires ANY of (TIMEOUT_REACHED). Suppressed by: DISH_SERVED. Fires once. + */ + enum CookingEvent { + DISH_SERVED, LEFTOVERS_HEATED, SEASONING_ADJUSTED, ORDER_TIMED_OUT, + } + + /** + * Internal activities that trigger external events. + */ + enum KitchenActivity { + INGREDIENTS_PREPPED, SEASONING_ADDED, OVEN_PREHEATED, LEFTOVER_MEAT_FOUND, + LEFTOVER_VEGGIES_FOUND, LEFTOVER_SAUCE_FOUND, PLATES_RETRIEVED, TIMEOUT_REACHED, + } + + @Test + public void dishServedFiresOnceAndReplaysToLateSubscribers() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED, KitchenActivity.SEASONING_ADDED, KitchenActivity.OVEN_PREHEATED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger h1CallCount = new AtomicInteger(0); + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> h1CallCount.incrementAndGet()); + + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, h1CallCount.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + // Late subscriber should receive replay + AtomicInteger h2CallCount = new AtomicInteger(0); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> h2CallCount.incrementAndGet()); + + assertEquals(1, h2CallCount.get()); + assertEquals(1, h1CallCount.get()); // Original handler not called again + } + + @Test + public void leftoversHeatedFiresOnceWhenAllLeftoversFound() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.LEFTOVERS_HEATED, KitchenActivity.LEFTOVER_MEAT_FOUND, KitchenActivity.LEFTOVER_VEGGIES_FOUND, KitchenActivity.LEFTOVER_SAUCE_FOUND, KitchenActivity.PLATES_RETRIEVED) + .executionLimit(CookingEvent.LEFTOVERS_HEATED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger hCount = new AtomicInteger(0); + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.LEFTOVERS_HEATED, (event, metadata) -> hCount.incrementAndGet()); + + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_MEAT_FOUND, null); + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_VEGGIES_FOUND, null); + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_SAUCE_FOUND, null); + eventsManager.notifyInternalEvent(KitchenActivity.PLATES_RETRIEVED, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, hCount.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.LEFTOVERS_HEATED)); + } + + @Test + public void seasoningAdjustedIsEmittedOnlyAfterDishServed() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED, KitchenActivity.OVEN_PREHEATED, KitchenActivity.LEFTOVER_SAUCE_FOUND) + .requireAny(CookingEvent.SEASONING_ADJUSTED, KitchenActivity.SEASONING_ADDED) + .prerequisite(CookingEvent.SEASONING_ADJUSTED, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, -1) + .build(); + + CountDownLatch seasoningLatch = new CountDownLatch(1); + AtomicInteger hCount = new AtomicInteger(0); + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + if (event == CookingEvent.SEASONING_ADJUSTED) { + seasoningLatch.countDown(); + } + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> hCount.incrementAndGet()); + + // Trigger DISH_SERVED first (without SEASONING_ADDED) + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_SAUCE_FOUND, null); + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + // Wait for DISH_SERVED to be processed + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + // SEASONING_ADJUSTED should NOT have fired yet (no SEASONING_ADDED) + assertEquals(0, hCount.get()); + + // Now SEASONING_ADDED should trigger SEASONING_ADJUSTED (prerequisite is met) + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + assertTrue(seasoningLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, hCount.get()); + } + + @Test + public void seasoningAdjustedDoesNotReplayToLateSubscribers() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.DISH_SERVED, KitchenActivity.LEFTOVER_SAUCE_FOUND) + .requireAny(CookingEvent.SEASONING_ADJUSTED, KitchenActivity.SEASONING_ADDED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, -1) + .build(); + + CountDownLatch firstSeasoningLatch = new CountDownLatch(1); + CountDownLatch secondSeasoningLatch = new CountDownLatch(2); + AtomicInteger h1Count = new AtomicInteger(0); + AtomicInteger h2Count = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + if (event == CookingEvent.SEASONING_ADJUSTED) { + firstSeasoningLatch.countDown(); + secondSeasoningLatch.countDown(); + } + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + // Emit DISH_SERVED + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_SAUCE_FOUND, null); + + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> h1Count.incrementAndGet()); + + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + assertTrue(firstSeasoningLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, h1Count.get()); + + // Late subscriber should NOT receive replay for unlimited events + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> h2Count.incrementAndGet()); + assertEquals(0, h2Count.get()); + + // Both handlers invoked on next event + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + assertTrue(secondSeasoningLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(2, h1Count.get()); + assertEquals(1, h2Count.get()); + } + + @Test + public void orderTimedOutIsSuppressedWhenDishServedFires() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED, KitchenActivity.SEASONING_ADDED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.ORDER_TIMED_OUT, KitchenActivity.TIMEOUT_REACHED) + .suppressedBy(CookingEvent.ORDER_TIMED_OUT, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.ORDER_TIMED_OUT, 1) + .build(); + + CountDownLatch dishServedLatch = new CountDownLatch(1); + AtomicInteger timeoutCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + if (event == CookingEvent.DISH_SERVED) { + dishServedLatch.countDown(); + } + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.ORDER_TIMED_OUT, (event, metadata) -> timeoutCount.incrementAndGet()); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> {}); + + // Fire DISH_SERVED first + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + assertTrue(dishServedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + // ORDER_TIMED_OUT should be suppressed + eventsManager.notifyInternalEvent(KitchenActivity.TIMEOUT_REACHED, null); + + assertEquals(0, timeoutCount.get()); + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.ORDER_TIMED_OUT)); + } + + @Test + public void orderTimedOutFiresWhenDishServedHasNotFired() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED, KitchenActivity.SEASONING_ADDED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.ORDER_TIMED_OUT, KitchenActivity.TIMEOUT_REACHED) + .suppressedBy(CookingEvent.ORDER_TIMED_OUT, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.ORDER_TIMED_OUT, 1) + .build(); + + CountDownLatch timeoutLatch = new CountDownLatch(1); + AtomicInteger timeoutCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + if (event == CookingEvent.ORDER_TIMED_OUT) { + timeoutLatch.countDown(); + } + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.ORDER_TIMED_OUT, (event, metadata) -> timeoutCount.incrementAndGet()); + + // Trigger timeout before DISH_SERVED + eventsManager.notifyInternalEvent(KitchenActivity.TIMEOUT_REACHED, null); + + assertTrue(timeoutLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, timeoutCount.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.ORDER_TIMED_OUT)); + } + + @Test + public void unregisterRemovesAllHandlersForEvent() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED) + .executionLimit(CookingEvent.DISH_SERVED, -1) + .build(); + + CountDownLatch firstLatch = new CountDownLatch(2); + CountDownLatch reRegisterLatch = new CountDownLatch(1); + AtomicInteger h1Count = new AtomicInteger(0); + AtomicInteger h2Count = new AtomicInteger(0); + + EventsManager eventsManager = new EventsManagerCore<>(config, SIMPLE_DELIVERY); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> { + h1Count.incrementAndGet(); + firstLatch.countDown(); + }); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> { + h2Count.incrementAndGet(); + firstLatch.countDown(); + }); + + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + assertTrue(firstLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, h1Count.get()); + assertEquals(1, h2Count.get()); + + // Unregister all handlers for DISH_SERVED + eventsManager.unregister(CookingEvent.DISH_SERVED); + + // Fire again - no handlers should be called + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + + // Use eventAlreadyTriggered to wait for processing + eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED); + + assertEquals(1, h1Count.get()); + assertEquals(1, h2Count.get()); + + // Re-register and verify it works + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> { + h1Count.incrementAndGet(); + reRegisterLatch.countDown(); + }); + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + + assertTrue(reRegisterLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(2, h1Count.get()); + } + + @Test + public void registerIsIgnoredAfterDestroy() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger hCount = new AtomicInteger(0); + + EventsManager eventsManager = new EventsManagerCore<>(config, SIMPLE_DELIVERY); + + // Register initial handler and trigger event for late subscribers + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> latch.countDown()); + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + eventsManager.destroy(); + + // Register after destroy - should be ignored, no replay + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> hCount.incrementAndGet()); + + assertEquals(0, hCount.get()); + } + + @Test + public void notifyInternalEventIsIgnoredAfterDestroy() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED) + .executionLimit(CookingEvent.DISH_SERVED, -1) + .build(); + + AtomicInteger hCount = new AtomicInteger(0); + + EventsManager eventsManager = new EventsManagerCore<>(config, SIMPLE_DELIVERY); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> hCount.incrementAndGet()); + + eventsManager.destroy(); + + // Notify after destroy - should be ignored + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + + assertEquals(0, hCount.get()); + } + + @Test + public void eventAlreadyTriggeredReturnsFalseAfterDestroy() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> {}); + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + eventsManager.destroy(); + + // State is cleared after destroy + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + } + + @Test + public void handlersAreNotCalledAfterDestroy() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED, KitchenActivity.SEASONING_ADDED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + AtomicInteger hCount = new AtomicInteger(0); + + EventsManager eventsManager = new EventsManagerCore<>(config, SIMPLE_DELIVERY); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> hCount.incrementAndGet()); + + // Partially satisfy requirements + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + + // Destroy before completing requirements + eventsManager.destroy(); + + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + assertEquals(0, hCount.get()); + } + + @Test + public void eventAlreadyTriggeredRespectsExecutionLimits() throws InterruptedException { + // Config with both one-shot (DISH_SERVED) and unlimited (SEASONING_ADJUSTED) events + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.INGREDIENTS_PREPPED) + .requireAny(CookingEvent.SEASONING_ADJUSTED, KitchenActivity.SEASONING_ADDED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, -1) + .build(); + + CountDownLatch latch = new CountDownLatch(2); + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + // Before any triggers + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.SEASONING_ADJUSTED)); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> {}); + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> {}); + + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + + // One-shot event returns true (completed all executions) + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + // Unlimited event returns false (can still fire again) + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.SEASONING_ADJUSTED)); + } + + + @Test + public void requireAnyWithGroupsFiresWhenFirstGroupComplete() throws InterruptedException { + // External event fires when EITHER: + // Group 1: INGREDIENTS_PREPPED AND SEASONING_ADDED + // OR + // Group 2: LEFTOVER_MEAT_FOUND AND LEFTOVER_VEGGIES_FOUND AND LEFTOVER_SAUCE_FOUND + Set group1 = new HashSet<>(); + group1.add(KitchenActivity.INGREDIENTS_PREPPED); + group1.add(KitchenActivity.SEASONING_ADDED); + + Set group2 = new HashSet<>(); + group2.add(KitchenActivity.LEFTOVER_MEAT_FOUND); + group2.add(KitchenActivity.LEFTOVER_VEGGIES_FOUND); + group2.add(KitchenActivity.LEFTOVER_SAUCE_FOUND); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, group1, group2) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger callCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> callCount.incrementAndGet()); + + // Complete first group + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, callCount.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + } + + @Test + public void requireAnyWithGroupsFiresWhenSecondGroupComplete() throws InterruptedException { + // Same config as above, but complete the second group instead + Set group1 = new HashSet<>(); + group1.add(KitchenActivity.INGREDIENTS_PREPPED); + group1.add(KitchenActivity.SEASONING_ADDED); + + Set group2 = new HashSet<>(); + group2.add(KitchenActivity.LEFTOVER_MEAT_FOUND); + group2.add(KitchenActivity.LEFTOVER_VEGGIES_FOUND); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, group1, group2) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger callCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> callCount.incrementAndGet()); + + // Complete second group (not touching first group) + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_MEAT_FOUND, null); + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_VEGGIES_FOUND, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, callCount.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + } + + @Test + public void requireAnyWithGroupsDoesNotFireWithPartialGroup() throws InterruptedException { + Set group1 = new HashSet<>(); + group1.add(KitchenActivity.INGREDIENTS_PREPPED); + group1.add(KitchenActivity.SEASONING_ADDED); + group1.add(KitchenActivity.OVEN_PREHEATED); + + Set group2 = new HashSet<>(); + group2.add(KitchenActivity.LEFTOVER_MEAT_FOUND); + group2.add(KitchenActivity.LEFTOVER_VEGGIES_FOUND); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, group1, group2) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + AtomicInteger callCount = new AtomicInteger(0); + + EventsManager eventsManager = new EventsManagerCore<>(config, SIMPLE_DELIVERY); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> callCount.incrementAndGet()); + + // Partial completion of group 1 (missing OVEN_PREHEATED) + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + // Partial completion of group 2 (missing LEFTOVER_VEGGIES_FOUND) + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_MEAT_FOUND, null); + + // Wait for processing to complete + eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED); + + assertEquals(0, callCount.get()); + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + } + + @Test + public void requireAnyWithGroupsFiresOnceEvenWhenMultipleGroupsComplete() throws InterruptedException { + Set group1 = new HashSet<>(); + group1.add(KitchenActivity.INGREDIENTS_PREPPED); + + Set group2 = new HashSet<>(); + group2.add(KitchenActivity.LEFTOVER_MEAT_FOUND); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, group1, group2) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger callCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> callCount.incrementAndGet()); + + // Complete first group + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + + // Now complete second group as well + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_MEAT_FOUND, null); + + // Wait for processing + eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED); + + // Should only fire once due to execution limit + assertEquals(1, callCount.get()); + } + + @Test + public void requireAnyGroupedWithPrerequisite() throws InterruptedException { + // DISH_SERVED requires simple condition + // SEASONING_ADJUSTED uses OR-of-ANDs and requires DISH_SERVED first + Set group1 = new HashSet<>(); + group1.add(KitchenActivity.SEASONING_ADDED); + + Set group2 = new HashSet<>(); + group2.add(KitchenActivity.LEFTOVER_MEAT_FOUND); + group2.add(KitchenActivity.LEFTOVER_VEGGIES_FOUND); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.SEASONING_ADJUSTED, group1, group2) + .prerequisite(CookingEvent.SEASONING_ADJUSTED, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, 1) + .build(); + + CountDownLatch seasoningLatch = new CountDownLatch(1); + AtomicInteger seasoningCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + if (event == CookingEvent.SEASONING_ADJUSTED) { + seasoningLatch.countDown(); + } + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> seasoningCount.incrementAndGet()); + + // Complete group 2 for SEASONING_ADJUSTED, but DISH_SERVED not fired yet + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_MEAT_FOUND, null); + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_VEGGIES_FOUND, null); + + // Wait and verify SEASONING_ADJUSTED not fired + eventsManager.eventAlreadyTriggered(CookingEvent.SEASONING_ADJUSTED); + assertEquals(0, seasoningCount.get()); + + // Now trigger DISH_SERVED + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + // Wait for DISH_SERVED + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + // Now trigger something that completes group 1 + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + assertTrue(seasoningLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, seasoningCount.get()); + } + + @Test + public void requireAnyGroupedWithSuppressor() throws InterruptedException { + Set group1 = new HashSet<>(); + group1.add(KitchenActivity.TIMEOUT_REACHED); + + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.ORDER_TIMED_OUT, group1) + .suppressedBy(CookingEvent.ORDER_TIMED_OUT, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.ORDER_TIMED_OUT, 1) + .build(); + + AtomicInteger timeoutCount = new AtomicInteger(0); + + EventsManager eventsManager = new EventsManagerCore<>(config, SIMPLE_DELIVERY); + eventsManager.register(CookingEvent.ORDER_TIMED_OUT, (event, metadata) -> timeoutCount.incrementAndGet()); + + // Trigger DISH_SERVED first + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + // Now trigger timeout - should be suppressed + eventsManager.notifyInternalEvent(KitchenActivity.TIMEOUT_REACHED, null); + + // Wait for processing + eventsManager.eventAlreadyTriggered(CookingEvent.ORDER_TIMED_OUT); + + assertEquals(0, timeoutCount.get()); + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.ORDER_TIMED_OUT)); + } + + @Test + public void prerequisiteChainResolvedInSingleNotification() throws InterruptedException { + // DISH_SERVED fires when OVEN_PREHEATED + // SEASONING_ADJUSTED fires when OVEN_PREHEATED, but requires DISH_SERVED first + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.SEASONING_ADJUSTED, KitchenActivity.OVEN_PREHEATED) + .prerequisite(CookingEvent.SEASONING_ADJUSTED, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, 1) + .build(); + + CountDownLatch bothFiredLatch = new CountDownLatch(2); + AtomicInteger dishServedCount = new AtomicInteger(0); + AtomicInteger seasoningCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + bothFiredLatch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> dishServedCount.incrementAndGet()); + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> seasoningCount.incrementAndGet()); + + // Single notification should trigger both events (A fires, then B fires because prerequisite is now met) + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + assertTrue("Both events should fire from single notification", bothFiredLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, dishServedCount.get()); + assertEquals(1, seasoningCount.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.SEASONING_ADJUSTED)); + } + + @Test + public void prerequisiteChainWithOrOfAndsGroups() throws InterruptedException { + // DISH_SERVED = SDK_READY_FROM_CACHE (fires when sync group completes) + // LEFTOVERS_HEATED = SDK_READY (fires when sync completes, but requires DISH_SERVED first) + + Set syncGroup = new HashSet<>(); + syncGroup.add(KitchenActivity.INGREDIENTS_PREPPED); + syncGroup.add(KitchenActivity.SEASONING_ADDED); + + Set cacheGroup = new HashSet<>(); + cacheGroup.add(KitchenActivity.LEFTOVER_MEAT_FOUND); + cacheGroup.add(KitchenActivity.LEFTOVER_VEGGIES_FOUND); + + EventsManagerConfig config = EventsManagerConfig.builder() + // DISH_SERVED fires when either sync or cache group completes + .requireAny(CookingEvent.DISH_SERVED, syncGroup, cacheGroup) + // LEFTOVERS_HEATED requires the same sync events, but also DISH_SERVED as prerequisite + .requireAll(CookingEvent.LEFTOVERS_HEATED, + KitchenActivity.INGREDIENTS_PREPPED, + KitchenActivity.SEASONING_ADDED) + .prerequisite(CookingEvent.LEFTOVERS_HEATED, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.LEFTOVERS_HEATED, 1) + .build(); + + CountDownLatch bothFiredLatch = new CountDownLatch(2); + AtomicInteger dishServedCount = new AtomicInteger(0); + AtomicInteger leftoversCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + bothFiredLatch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> dishServedCount.incrementAndGet()); + eventsManager.register(CookingEvent.LEFTOVERS_HEATED, (event, metadata) -> leftoversCount.incrementAndGet()); + + // First sync event + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + + // Second sync event should trigger chain: DISH_SERVED -> LEFTOVERS_HEATED + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + assertTrue("Both events should fire when sync completes", bothFiredLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, dishServedCount.get()); + assertEquals(1, leftoversCount.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.LEFTOVERS_HEATED)); + } + + @Test + public void prerequisiteLoopTerminatesWhenNoMoreEventsCanFire() throws InterruptedException { + // Create a chain where only DISH_SERVED can fire (SEASONING_ADJUSTED requires a different trigger) + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.SEASONING_ADJUSTED, KitchenActivity.SEASONING_ADDED) // Different trigger! + .prerequisite(CookingEvent.SEASONING_ADJUSTED, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, 1) + .build(); + + CountDownLatch dishServedLatch = new CountDownLatch(1); + AtomicInteger dishServedCount = new AtomicInteger(0); + AtomicInteger seasoningCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + if (event == CookingEvent.DISH_SERVED) { + dishServedLatch.countDown(); + } + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> dishServedCount.incrementAndGet()); + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> seasoningCount.incrementAndGet()); + + // Only DISH_SERVED should fire, loop should terminate without firing SEASONING_ADJUSTED + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + assertTrue(dishServedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, dishServedCount.get()); + assertEquals(0, seasoningCount.get()); // Should NOT fire - different trigger + + // Verify processing completed (no infinite loop) + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.SEASONING_ADJUSTED)); + } + + @Test + public void threeLevelPrerequisiteChain() throws InterruptedException { + // DISH_SERVED -> SEASONING_ADJUSTED -> ORDER_TIMED_OUT + // All triggered by the same internal event + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.SEASONING_ADJUSTED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.ORDER_TIMED_OUT, KitchenActivity.OVEN_PREHEATED) + .prerequisite(CookingEvent.SEASONING_ADJUSTED, CookingEvent.DISH_SERVED) + .prerequisite(CookingEvent.ORDER_TIMED_OUT, CookingEvent.SEASONING_ADJUSTED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, 1) + .executionLimit(CookingEvent.ORDER_TIMED_OUT, 1) + .build(); + + CountDownLatch allFiredLatch = new CountDownLatch(3); + AtomicInteger dishServedCount = new AtomicInteger(0); + AtomicInteger seasoningCount = new AtomicInteger(0); + AtomicInteger timeoutCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + allFiredLatch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> dishServedCount.incrementAndGet()); + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> seasoningCount.incrementAndGet()); + eventsManager.register(CookingEvent.ORDER_TIMED_OUT, (event, metadata) -> timeoutCount.incrementAndGet()); + + // Single notification should trigger all three events in chain + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + assertTrue("All three events should fire from single notification", allFiredLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, dishServedCount.get()); + assertEquals(1, seasoningCount.get()); + assertEquals(1, timeoutCount.get()); + } + + /** + * Tests that requireAny with unlimited execution only triggers when the CURRENT + * internal event is one of the triggers, not when historical events satisfy the condition. + * This prevents the scenario where: + * 1. Internal event A fires (is in requireAny for unlimited event X, but prerequisite not met) + * 2. Internal event B fires (satisfies prerequisite for X) + * 3. X incorrectly fires because A is in the seen set (but A was the trigger, not B) + */ + @Test + public void requireAnyUnlimitedOnlyTriggersOnCurrentEvent() throws InterruptedException { + // DISH_SERVED fires when OVEN_PREHEATED (one-shot, acts as prerequisite) + // SEASONING_ADJUSTED fires when SEASONING_ADDED (unlimited, requires DISH_SERVED) + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.OVEN_PREHEATED) + .requireAny(CookingEvent.SEASONING_ADJUSTED, KitchenActivity.SEASONING_ADDED) + .prerequisite(CookingEvent.SEASONING_ADJUSTED, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, -1) // Unlimited + .build(); + + CountDownLatch dishServedLatch = new CountDownLatch(1); + AtomicInteger seasoningCount = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + if (event == CookingEvent.DISH_SERVED) { + dishServedLatch.countDown(); + } + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> seasoningCount.incrementAndGet()); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> {}); + + // Step 1: Fire SEASONING_ADDED BEFORE DISH_SERVED + // This adds SEASONING_ADDED to seenInternal, but SEASONING_ADJUSTED can't fire (prerequisite not met) + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + // Wait for processing + eventsManager.eventAlreadyTriggered(CookingEvent.SEASONING_ADJUSTED); + assertEquals("SEASONING_ADJUSTED should NOT fire (prerequisite not met)", 0, seasoningCount.get()); + + // Step 2: Fire OVEN_PREHEATED to trigger DISH_SERVED + // The bug would be: SEASONING_ADDED is in seenInternal, so SEASONING_ADJUSTED incorrectly fires + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + assertTrue(dishServedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + // Wait for any async processing + Thread.sleep(100); + + // SEASONING_ADJUSTED should NOT have fired because OVEN_PREHEATED is not in its requireAny + assertEquals("SEASONING_ADJUSTED should NOT fire from OVEN_PREHEATED (wrong trigger)", 0, seasoningCount.get()); + + // Step 3: Now fire SEASONING_ADDED again - this should trigger SEASONING_ADJUSTED + CountDownLatch seasoningLatch = new CountDownLatch(1); + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> seasoningLatch.countDown()); + + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + assertTrue(seasoningLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals("SEASONING_ADJUSTED should fire when correct trigger arrives", 1, seasoningCount.get()); + } + + @Test + public void requireAnyDoesNotRetriggerFromHistoricalEvents() throws InterruptedException { + // Scenario: Multiple requireAny triggers for unlimited event + // Event should only fire when one of ITS triggers fires, not when other events fire + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAny(CookingEvent.DISH_SERVED, KitchenActivity.OVEN_PREHEATED, KitchenActivity.PLATES_RETRIEVED) + .requireAny(CookingEvent.SEASONING_ADJUSTED, + KitchenActivity.SEASONING_ADDED, + KitchenActivity.LEFTOVER_SAUCE_FOUND) + .prerequisite(CookingEvent.SEASONING_ADJUSTED, CookingEvent.DISH_SERVED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .executionLimit(CookingEvent.SEASONING_ADJUSTED, -1) + .build(); + + AtomicInteger seasoningCount = new AtomicInteger(0); + + EventsManager eventsManager = new EventsManagerCore<>(config, SIMPLE_DELIVERY); + + eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> seasoningCount.incrementAndGet()); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> {}); + + // Fire a SEASONING_ADJUSTED trigger before prerequisite is met + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + assertEquals(0, seasoningCount.get()); + + // Satisfy prerequisite with OVEN_PREHEATED + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + // SEASONING_ADJUSTED should NOT have fired (OVEN_PREHEATED is not its trigger) + assertEquals("Historical SEASONING_ADDED should not cause trigger", 0, seasoningCount.get()); + + // Fire an unrelated internal event + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_MEAT_FOUND, null); + assertEquals("Unrelated event should not cause trigger", 0, seasoningCount.get()); + + // Now fire actual trigger - should work + eventsManager.notifyInternalEvent(KitchenActivity.LEFTOVER_SAUCE_FOUND, null); + Thread.sleep(100); + assertEquals("Correct trigger should fire event", 1, seasoningCount.get()); + } + + @Test + public void requireAllStillAccumulatesCorrectly() throws InterruptedException { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll(CookingEvent.DISH_SERVED, + KitchenActivity.INGREDIENTS_PREPPED, + KitchenActivity.SEASONING_ADDED, + KitchenActivity.OVEN_PREHEATED) + .executionLimit(CookingEvent.DISH_SERVED, 1) + .build(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger count = new AtomicInteger(0); + + EventDelivery delivery = (handler, event, metadata) -> { + handler.handle(event, metadata); + latch.countDown(); + }; + + EventsManager eventsManager = new EventsManagerCore<>(config, delivery); + eventsManager.register(CookingEvent.DISH_SERVED, (event, metadata) -> count.incrementAndGet()); + + // Fire events in any order - should accumulate + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + eventsManager.notifyInternalEvent(KitchenActivity.INGREDIENTS_PREPPED, null); + assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + + eventsManager.notifyInternalEvent(KitchenActivity.OVEN_PREHEATED, null); + + assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals(1, count.get()); + assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); + } +} diff --git a/events/src/test/java/io/harness/events/EventsManagersTest.java b/events/src/test/java/io/harness/events/EventsManagersTest.java new file mode 100644 index 000000000..e1a50e033 --- /dev/null +++ b/events/src/test/java/io/harness/events/EventsManagersTest.java @@ -0,0 +1,15 @@ +package io.harness.events; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import org.junit.Test; + +public class EventsManagersTest { + + @Test + public void createDeliversEventsManagerCore() { + EventsManager eventsManager = EventsManagers.create(EventsManagerConfig.empty(), mock(EventDelivery.class)); + assertTrue(eventsManager instanceof EventsManagerCore); + } +} diff --git a/events/src/test/java/io/harness/events/TestLogging.java b/events/src/test/java/io/harness/events/TestLogging.java new file mode 100644 index 000000000..65bc71475 --- /dev/null +++ b/events/src/test/java/io/harness/events/TestLogging.java @@ -0,0 +1,34 @@ +package io.harness.events; + +class TestLogging implements Logging { + String errorMessage; + String warningMessage; + String infoMessage; + String debugMessage; + String verboseMessage; + + @Override + public void logError(String message) { + errorMessage = message; + } + + @Override + public void logWarning(String message) { + warningMessage = message; + } + + @Override + public void logInfo(String message) { + infoMessage = message; + } + + @Override + public void logDebug(String message) { + debugMessage = message; + } + + @Override + public void logVerbose(String message) { + verboseMessage = message; + } +} diff --git a/events/src/test/java/io/harness/events/TopologicalSorterTest.java b/events/src/test/java/io/harness/events/TopologicalSorterTest.java new file mode 100644 index 000000000..c06f6b7c8 --- /dev/null +++ b/events/src/test/java/io/harness/events/TopologicalSorterTest.java @@ -0,0 +1,231 @@ +package io.harness.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class TopologicalSorterTest { + + @Test + public void emptySetReturnsEmptyList() { + TopologicalSorter sorter = new TopologicalSorter<>( + Collections.emptySet(), + Collections.emptyMap() + ); + + List result = sorter.sort(); + assertTrue(result.isEmpty()); + } + + @Test + public void singleNodeReturnsSingletonList() { + Set nodes = Collections.singleton("A"); + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.emptySet()); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + List result = sorter.sort(); + + assertEquals(1, result.size()); + assertEquals("A", result.get(0)); + } + + @Test + public void independentNodesCanBeInAnyOrder() { + Set nodes = new HashSet<>(); + nodes.add("A"); + nodes.add("B"); + nodes.add("C"); + + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.emptySet()); + dependencies.put("B", Collections.emptySet()); + dependencies.put("C", Collections.emptySet()); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + List result = sorter.sort(); + + assertEquals(3, result.size()); + assertTrue(result.contains("A")); + assertTrue(result.contains("B")); + assertTrue(result.contains("C")); + } + + @Test + public void simpleChainRespectsOrder() { + // A depends on B, B depends on C + // Expected: C, B, A + Set nodes = new HashSet<>(); + nodes.add("A"); + nodes.add("B"); + nodes.add("C"); + + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.singleton("B")); + dependencies.put("B", Collections.singleton("C")); + dependencies.put("C", Collections.emptySet()); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + List result = sorter.sort(); + + assertEquals(3, result.size()); + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + int idxC = result.indexOf("C"); + + assertTrue("C should come before B", idxC < idxB); + assertTrue("B should come before A", idxB < idxA); + } + + @Test + public void multipleDependenciesRespected() { + // A depends on B and C + // Expected: B and C before A (order between B and C doesn't matter) + Set nodes = new HashSet<>(); + nodes.add("A"); + nodes.add("B"); + nodes.add("C"); + + Map> dependencies = new HashMap<>(); + Set aDeps = new HashSet<>(); + aDeps.add("B"); + aDeps.add("C"); + dependencies.put("A", aDeps); + dependencies.put("B", Collections.emptySet()); + dependencies.put("C", Collections.emptySet()); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + List result = sorter.sort(); + + assertEquals(3, result.size()); + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + int idxC = result.indexOf("C"); + + assertTrue("B should come before A", idxB < idxA); + assertTrue("C should come before A", idxC < idxA); + } + + @Test + public void diamondDependencyResolved() { + Set nodes = new HashSet<>(); + nodes.add("A"); + nodes.add("B"); + nodes.add("C"); + nodes.add("D"); + + Map> dependencies = new HashMap<>(); + Set aDeps = new HashSet<>(); + aDeps.add("B"); + aDeps.add("C"); + dependencies.put("A", aDeps); + dependencies.put("B", Collections.singleton("D")); + dependencies.put("C", Collections.singleton("D")); + dependencies.put("D", Collections.emptySet()); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + List result = sorter.sort(); + + assertEquals(4, result.size()); + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + int idxC = result.indexOf("C"); + int idxD = result.indexOf("D"); + + assertTrue("D should come before B", idxD < idxB); + assertTrue("D should come before C", idxD < idxC); + assertTrue("B should come before A", idxB < idxA); + assertTrue("C should come before A", idxC < idxA); + } + + @Test(expected = IllegalStateException.class) + public void detectsDirectCycle() { + // A depends on B, B depends on A + Set nodes = new HashSet<>(); + nodes.add("A"); + nodes.add("B"); + + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.singleton("B")); + dependencies.put("B", Collections.singleton("A")); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + sorter.sort(); // Should throw + } + + @Test(expected = IllegalStateException.class) + public void detectsSelfCycle() { + // A depends on itself + Set nodes = Collections.singleton("A"); + + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.singleton("A")); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + sorter.sort(); // Should throw + } + + @Test(expected = IllegalStateException.class) + public void detectsLongCycle() { + // A -> B -> C -> A + Set nodes = new HashSet<>(); + nodes.add("A"); + nodes.add("B"); + nodes.add("C"); + + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.singleton("B")); + dependencies.put("B", Collections.singleton("C")); + dependencies.put("C", Collections.singleton("A")); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + sorter.sort(); // Should throw + } + + @Test + public void handlesMissingDependencyEntries() { + // If a node is not in dependencies map, it should be treated as having no dependencies + Set nodes = new HashSet<>(); + nodes.add("A"); + nodes.add("B"); + + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.singleton("B")); + // B is not in dependencies map + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + List result = sorter.sort(); + + assertEquals(2, result.size()); + int idxA = result.indexOf("A"); + int idxB = result.indexOf("B"); + assertTrue("B should come before A", idxB < idxA); + } + + @Test + public void resultIsUnmodifiable() { + Set nodes = Collections.singleton("A"); + Map> dependencies = new HashMap<>(); + dependencies.put("A", Collections.emptySet()); + + TopologicalSorter sorter = new TopologicalSorter<>(nodes, dependencies); + List result = sorter.sort(); + + try { + result.add("B"); + fail("Result should be unmodifiable"); + } catch (UnsupportedOperationException expected) { + // expected + } + } +} + diff --git a/gradle/common-android-library.gradle b/gradle/common-android-library.gradle index de5ee9713..fe96407c8 100644 --- a/gradle/common-android-library.gradle +++ b/gradle/common-android-library.gradle @@ -10,6 +10,10 @@ android { } } +tasks.withType(JavaCompile).configureEach { + options.compilerArgs.add('-parameters') +} + def kotlinCompileClass = null try { kotlinCompileClass = Class.forName('org.jetbrains.kotlin.gradle.tasks.KotlinCompile') @@ -20,7 +24,10 @@ if (kotlinCompileClass != null) { tasks.withType(kotlinCompileClass).configureEach { kotlinOptions { jvmTarget = "1.8" + javaParameters = true } } } +// Enable Jacoco coverage configuration for all Android library modules +apply from: "$rootDir/gradle/jacoco-android.gradle" diff --git a/gradle/jacoco-android.gradle b/gradle/jacoco-android.gradle index e594c5d18..68103d1a8 100644 --- a/gradle/jacoco-android.gradle +++ b/gradle/jacoco-android.gradle @@ -25,19 +25,20 @@ tasks.register('jacocoTestReport', JacocoReport) { def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*'] def classDirectoriesFiles = [] + + // Try multiple possible class directory locations for different AGP versions + // Android Gradle Plugin may compile classes to different locations + // NOTE: If new modules use different compilation output directories, add them here def possibleClassDirs = [ - "${buildDir}/intermediates/javac/debug/classes", - "${buildDir}/intermediates/javac/debug/compileDebugJavaWithJavac/classes", - "${buildDir}/intermediates/runtime_library_classes_dir/debug", - "${buildDir}/intermediates/classes/debug", - "${buildDir}/classes/java/main", - "${buildDir}/tmp/kotlin-classes/debug" + "${buildDir}/intermediates/javac/debug/compileDebugJavaWithJavac/classes", // AGP 9.0+ location (primary) + "${buildDir}/classes/java/main" // Standard fallback location ] + possibleClassDirs.each { dirPath -> def dir = file(dirPath) if (dir.exists() && dir.isDirectory()) { def classDir = fileTree(dir: dirPath, excludes: fileFilter) - if (classDir.files.size() > 0) { + if (!classDir.isEmpty()) { classDirectoriesFiles.add(classDir) } } diff --git a/gradle/jacoco-root.gradle b/gradle/jacoco-root.gradle new file mode 100644 index 000000000..c4913b1e6 --- /dev/null +++ b/gradle/jacoco-root.gradle @@ -0,0 +1,100 @@ +import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.api.tasks.testing.Test + +// Apply Jacoco at the root to support aggregate reporting +apply plugin: 'jacoco' + +// Define exclusions for JaCoCo coverage +def coverageExclusions = [ + '**/R.class', + '**/R$*.class', + '**/BuildConfig.*', + '**/Manifest*.*', + '**/*Test*.*', + 'android/**/*.*' +] + +// Aggregate Jacoco coverage report for all Android library modules +tasks.register('jacocoRootReport', JacocoReport) { + group = 'verification' + description = 'Generates an aggregate JaCoCo coverage report for all modules' + + def fileFilter = coverageExclusions + + // Collect class directories from all subprojects + def classDirs = subprojects.collectMany { proj -> + def b = proj.buildDir + + // Try multiple possible class directory locations for different AGP versions + // NOTE: If new modules use different compilation output directories, add them here + def possibleClassDirs = [ + new File(b, "intermediates/javac/debug/compileDebugJavaWithJavac/classes"), // AGP 9.0+ location (primary) + new File(b, "classes/java/main") // Standard fallback location + ] + + possibleClassDirs.findAll { it.exists() && it.isDirectory() }.collect { dir -> + proj.fileTree(dir: dir, excludes: fileFilter) + } + } + classDirectories.from = files(classDirs) + + // Collect source directories from all subprojects + def srcDirs = subprojects.collectMany { proj -> + [proj.file("src/main/java"), proj.file("src/main/kotlin")] + }.findAll { it.exists() } + sourceDirectories.from = files(srcDirs) + + // Collect execution data from all subprojects + def execFiles = subprojects.collectMany { proj -> + def b = proj.buildDir + [ + new File(b, "jacoco/testDebugUnitTest.exec"), + new File(b, "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + ].findAll { it.exists() } + } + executionData.from = files(execFiles) + + doFirst { + logger.lifecycle("=== JaCoCo Root Report Generation ===") + logger.lifecycle("Execution data files:") + def execDataFiles = executionData.files + if (execDataFiles.isEmpty() || !execDataFiles.any { it.exists() }) { + logger.warn(" - No execution data files found - coverage report will be empty") + } else { + execDataFiles.each { file -> + if (file.exists()) { + logger.lifecycle(" - Found: $file (${file.length()} bytes)") + } else { + logger.lifecycle(" - Missing: $file") + } + } + } + logger.lifecycle("=======================================") + } + + reports { + xml.required = true + html.required = true + csv.required = false + + xml.outputLocation = file("$buildDir/reports/jacoco/jacocoRootReport/jacocoRootReport.xml") + html.outputLocation = file("$buildDir/reports/jacoco/jacocoRootReport/html") + } + + // Always regenerate the report + outputs.upToDateWhen { false } +} + +// Wire all module unit tests into the aggregate Jacoco report +subprojects { proj -> + // Only consider projects that apply the Jacoco plugin + plugins.withId('jacoco') { + tasks.withType(Test).matching { it.name == 'testDebugUnitTest' }.configureEach { testTask -> + rootProject.tasks.named('jacocoRootReport') { + dependsOn(testTask) + } + } + } +} + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 98011c573..a6b4a01c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] kotlin = "1.8.0" +jetbrains-annotations = "26.0.2" androidx-room = "2.4.3" androidx-work = "2.7.1" androidx-lifecycle-process = "2.5.1" @@ -20,6 +21,7 @@ androidx-test-rules = "1.5.0" androidx-test-orchestrator = "1.4.2" [libraries] +jetbrainsAnnotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } roomRuntime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } roomCompiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } workRuntime = { module = "androidx.work:work-runtime", version.ref = "androidx-work" } @@ -46,4 +48,3 @@ androidxTestOrchestrator = { module = "androidx.test:orchestrator", version.ref kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinTest = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinTestJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } - diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2cfe32752..c8767b148 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip diff --git a/main/build.gradle b/main/build.gradle index 3654b45fe..7ec1e3110 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -3,14 +3,13 @@ plugins { } apply from: "$rootDir/gradle/common-android-library.gradle" -apply from: "$rootDir/gradle/jacoco-android.gradle" android { namespace 'io.split.android.client.main' defaultConfig { multiDexEnabled true - consumerProguardFiles 'consumer-rules.pro' + consumerProguardFiles "$rootDir/split-proguard-rules.pro" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' @@ -50,8 +49,11 @@ android { } dependencies { - // Internal module dependencies + // Public api modules api project(':logger') + api project(':api') + // Internal module dependencies + implementation project(':events-domain') // External dependencies implementation libs.roomRuntime diff --git a/main/consumer-rules.pro b/main/consumer-rules.pro deleted file mode 100644 index b776cf12b..000000000 --- a/main/consumer-rules.pro +++ /dev/null @@ -1,10 +0,0 @@ -# Consumer ProGuard rules for Split Android SDK -# These rules are automatically applied to apps that depend on this library - -# Suppress warnings for java.beans classes (not available on Android) -# These are referenced by snakeyaml but not actually used on Android --dontwarn java.beans.BeanInfo --dontwarn java.beans.FeatureDescriptor --dontwarn java.beans.IntrospectionException --dontwarn java.beans.Introspector --dontwarn java.beans.PropertyDescriptor diff --git a/main/src/androidTest/java/fake/SplitClientStub.java b/main/src/androidTest/java/fake/SplitClientStub.java index 4acebddbc..14c0fcde4 100644 --- a/main/src/androidTest/java/fake/SplitClientStub.java +++ b/main/src/androidTest/java/fake/SplitClientStub.java @@ -11,6 +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.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; @@ -120,6 +121,11 @@ public void on(SplitEvent event, SplitEventTask task) { } + @Override + public void addEventListener(SplitEventListener listener) { + // Stub implementation - does nothing + } + @Override public boolean track(String eventType) { return false; 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 new file mode 100644 index 000000000..62f2dcdad --- /dev/null +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -0,0 +1,2026 @@ +package tests.integration.events; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingDeque; +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; +import fake.HttpResponseMockDispatcher; +import fake.HttpStreamResponseMock; +import helper.DatabaseHelper; +import helper.IntegrationHelper; +import helper.TestableSplitConfigBuilder; +import io.split.android.client.ServiceEndpoints; +import io.split.android.client.SplitClient; +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.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.MySegmentEntity; +import io.split.android.client.storage.db.SplitEntity; +import io.split.android.client.storage.db.SplitRoomDatabase; +import io.split.android.client.utils.logger.Logger; +import io.split.android.client.utils.logger.SplitLogLevel; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import tests.integration.shared.TestingHelper; + +public class SdkEventsIntegrationTest { + + private Context mContext; + private MockWebServer mWebServer; + private SplitRoomDatabase mDatabase; + private int mCurSplitReqId; + + private ServiceEndpoints endpoints() { + final String url = mWebServer.url("/").url().toString(); + return ServiceEndpoints.builder() + .apiEndpoint(url) + .eventsEndpoint(url) + .build(); + } + + private SplitClientConfig buildConfig() { + return SplitClientConfig.builder() + .serviceEndpoints(endpoints()) + .logLevel(SplitLogLevel.VERBOSE) + .ready(30000) + .featuresRefreshRate(999999) // High refresh rate to avoid periodic sync interfering + .segmentsRefreshRate(999999) + .impressionsRefreshRate(999999) + .syncEnabled(true) // Ensure sync is enabled + .trafficType("account") + .build(); + } + + private SplitFactory buildFactory(SplitClientConfig config) { + return IntegrationHelper.buildFactory( + IntegrationHelper.dummyApiKey(), new Key("DEFAULT_KEY"), config, mContext, null, mDatabase, null); + } + + @Before + public void setup() { + mWebServer = new MockWebServer(); + mCurSplitReqId = 1003; + final Dispatcher dispatcher = 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")) { + long id = mCurSplitReqId++; + return new MockResponse().setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id)); + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(dispatcher); + try { + mWebServer.start(); + } catch (Exception e) { + throw new RuntimeException("Failed to start mock server", e); + } + mContext = InstrumentationRegistry.getInstrumentation().getContext(); + mDatabase = DatabaseHelper.getTestDatabase(mContext); + } + + @After + public void tearDown() throws Exception { + if (mWebServer != null) mWebServer.shutdown(); + if (mDatabase != null) { + mDatabase.close(); + } + } + + /** + * Scenario: sdkReadyFromCache fires when cache loading completes + *

+ * Given the SDK is starting with populated persistent storage + * And a handler H is registered for sdkReadyFromCache + * When internal events "splitsLoadedFromStorage", "mySegmentsLoadedFromStorage", + * "attributesLoadedFromStorage" and "encryptionMigrationDone" are notified + * Then sdkReadyFromCache is emitted exactly once + * And handler H is invoked once + * And the metadata contains "initialCacheLoad" with value false + * And the metadata contains "lastUpdateTimestamp" with a valid timestamp + */ + @Test + public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception { + // Given: SDK is starting with populated persistent storage + populateDatabaseWithCacheData(System.currentTimeMillis()); + SplitFactory factory = buildFactory(buildConfig()); + SplitClient client = factory.client(new Key("key_1")); + + // And: a handler H is registered for sdkReadyFromCache + EventCapture capture = captureCacheReadyEvent(client); + + // Then: sdkReadyFromCache is emitted exactly once + 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", 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 + assertNotNull("lastUpdateTimestamp should not be null", capture.metadata.get().getLastUpdateTimestamp()); + assertTrue("lastUpdateTimestamp should be valid", capture.metadata.get().getLastUpdateTimestamp() > 0); + + factory.destroy(); + } + + /** + * Scenario: sdkReadyFromCache fires when sync completes (fresh install path) + *

+ * Given the SDK is starting without persistent storage (fresh install) + * And a handler H is registered for sdkReadyFromCache + * When internal events "targetingRulesSyncComplete" and "membershipsSyncComplete" are notified + * Then sdkReadyFromCache is emitted exactly once + * And handler H is invoked once + * And the metadata contains "initialCacheLoad" with value true + */ + @Test + public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exception { + // Given: SDK is starting without persistent storage (fresh install) + SplitFactory factory = buildFactory(buildConfig()); + SplitClient client = factory.client(new Key("key_1")); + + // And: a handler H is registered for sdkReadyFromCache + EventCapture capture = captureCacheReadyEvent(client); + + // Then: sdkReadyFromCache is emitted exactly once + 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", 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(); + } + + /** + * Scenario: onReady listener fires when SDK_READY event occurs + *

+ * Given the SDK is starting with populated persistent storage + * And a handler H is registered using addEventListener with onReady + * When SDK_READY fires + * Then onReady is invoked exactly once + * And the handler receives the SplitClient and SdkReadyMetadata + * And the metadata contains "initialCacheLoad" with value false + * And the metadata contains "lastUpdateTimestamp" with a valid timestamp + */ + @Test + public void sdkReadyListenerFiresWithMetadata() throws Exception { + // Given: SDK is starting with populated persistent storage + 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 + EventCapture capture = captureReadyEvent(client); + + // Then: onReady is invoked exactly once + awaitEvent(capture.latch, "onReady", 30); + assertFiredOnce(capture.count, "onReady"); + + // And: the metadata contains "initialCacheLoad" with value false + 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 + 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(); + } + + /** + * Scenario: onReady listener replays to late subscribers + *

+ * Given sdkReady has already been emitted + * When a new handler H is registered using addEventListener with onReady + * Then onReady handler H is invoked exactly once immediately (replay) + */ + @Test + public void sdkReadyListenerReplaysToLateSubscribers() throws Exception { + // Given: sdkReady has already been emitted + TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); + + // When: a new handler H is registered for onReady after SDK_READY has fired + EventCapture capture = captureReadyEvent(fixture.client); + + // Then: onReady handler H is invoked exactly once immediately (replay) + 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); + assertFiredOnce(capture.count, "Late handler"); + + fixture.destroy(); + } + + /** + * Scenario: onReadyView is invoked on main thread when SDK_READY fires + *

+ * Given the SDK is starting + * And a handler H is registered using addEventListener with onReadyView + * When SDK_READY fires + * Then onReadyView is invoked on the main/UI thread + */ + @Test + public void sdkReadyViewListenerFiresOnMainThread() throws Exception { + // Given: SDK is starting with populated persistent storage + populateDatabaseWithCacheData(System.currentTimeMillis()); + SplitFactory factory = buildFactory(buildConfig()); + SplitClient client = factory.client(new Key("key_1")); + + // And: a handler H is registered using addEventListener with onReadyView + EventCapture capture = new EventCapture<>(); + client.addEventListener(new SplitEventListener() { + @Override + public void onReadyView(SplitClient c, SdkReadyMetadata metadata) { + capture.capture(metadata); + } + }); + + // Then: onReadyView is invoked + awaitEvent(capture.latch, "onReadyView"); + assertFiredOnce(capture.count, "onReadyView"); + + factory.destroy(); + } + + /** + * Scenario: sdkReady fires after sdkReadyFromCache and requires sync completion + *

+ * Given the SDK has not yet emitted sdkReady + * And a handler HReady is registered for sdkReady + * And a handler HCache is registered for sdkReadyFromCache + * When internal events "splitsLoadedFromStorage", "mySegmentsLoadedFromStorage", + * "attributesLoadedFromStorage" and "encryptionMigrationDone" are notified + * Then sdkReadyFromCache is emitted + * And handler HCache is invoked once + * But sdkReady is not emitted yet because sync has not completed + * When internal events "targetingRulesSyncComplete" and "membershipsSyncComplete" are notified + * Then sdkReady is emitted exactly once + * And handler HReady is invoked once + */ + @Test + public void sdkReadyFiresAfterSdkReadyFromCacheAndRequiresSyncCompletion() throws Exception { + // Given: SDK has not yet emitted sdkReady (fresh install) + SplitFactory factory = buildFactory(buildConfig()); + SplitClient client = factory.client(new Key("key_1")); + + // And: handlers are registered to catch all events + EventCapture cacheCapture = captureCacheReadyEvent(client); + CountDownLatch readyLatch = captureLegacyReadyEvent(client); + + // Wait for SDK_READY_FROM_CACHE first + awaitEvent(cacheCapture.latch, "SDK_READY_FROM_CACHE"); + assertFiredOnce(cacheCapture.count, "Cache handler"); + + // Wait for SDK_READY to fire + awaitEvent(readyLatch, "SDK_READY"); + + factory.destroy(); + } + + /** + * Scenario: sdkReady replays to late subscribers + *

+ * Given sdkReady has already been emitted + * When a new handler H is registered for sdkReady + * Then handler H is invoked exactly once immediately (replay) + * And sdkReady is not emitted again + */ + @Test + public void sdkReadyReplaysToLateSubscribers() throws Exception { + // Given: sdkReady has already been emitted + TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); + + // When: a new handler H is registered for sdkReady + EventCapture capture = captureReadyEvent(fixture.client); + + // Then: handler H is invoked exactly once immediately (replay) + 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); + assertFiredOnce(capture.count, "Late handler"); + + fixture.destroy(); + } + + /** + * Scenario: sdkUpdate is emitted only after sdkReady + *

+ * Given a handler H is registered for sdkUpdate + * And the SDK has not yet emitted sdkReady + * When an internal "splitsUpdated" event is notified during initial sync + * Then sdkUpdate is not emitted because sdkReady has not fired yet + * When internal events for sdkReadyFromCache and sdkReady are notified and both fire + * When a new "splitsUpdated" event is notified via SSE + * Then sdkUpdate is emitted + * And handler H is invoked once with metadata + */ + @Test + public void sdkUpdateEmittedOnlyAfterSdkReady() throws Exception { + // Given: Create streaming client but don't wait for SDK_READY + TestClientFixture fixture = createStreamingClient(new Key("key_1")); + + // Register handlers BEFORE SDK_READY fires + 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, updateCapture.count.get()); + + // When: SDK_READY fires + 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 + awaitEvent(updateCapture.latch, "SDK_UPDATE"); + assertFiredOnce(updateCapture.count, "SDK_UPDATE handler"); + assertNotNull("Metadata should not be null", updateCapture.metadata.get()); + + fixture.destroy(); + } + + /** + * Scenario: sdkUpdate fires on any data change event after sdkReady + *

+ * Given sdkReady has already been emitted + * And a handler H is registered for sdkUpdate + * When a split update notification arrives via SSE + * Then sdkUpdate is emitted and handler H is invoked + */ + @Test + public void sdkUpdateFiresOnAnyDataChangeEventAfterSdkReady() throws Exception { + // Given: sdkReady has already been emitted (with streaming support) + TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); + + EventCapture capture = captureUpdateEvent(fixture.client); + + // When: a split update notification arrives via SSE + fixture.pushSplitUpdate(); + + // Then: sdkUpdate is emitted and handler H is invoked + awaitEvent(capture.latch, "SDK_UPDATE"); + assertFiredOnce(capture.count, "SDK_UPDATE handler"); + assertNotNull("Metadata should not be null", capture.metadata.get()); + + fixture.destroy(); + } + + /** + * Scenario: sdkUpdate does not replay to late subscribers + *

+ * Given sdkReady has already been emitted + * And a handler H1 is registered for sdkUpdate + * When an internal "splitsUpdated" event is notified via SSE + * Then sdkUpdate is emitted + * And handler H1 is invoked once + * When a second handler H2 is registered for sdkUpdate after one sdkUpdate has already fired + * Then H2 does not receive a replay for past sdkUpdate events + * When another internal "splitsUpdated" event is notified + * Then both H1 and H2 are invoked once for that second sdkUpdate + */ + @Test + public void sdkUpdateDoesNotReplayToLateSubscribers() throws Exception { + // Given: sdkReady has already been emitted (with streaming support) + TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); + + AtomicInteger handler1Count = 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 SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + handler1Count.incrementAndGet(); + firstUpdateLatch.countDown(); + CountDownLatch secondLatch = secondUpdateLatchRef.get(); + if (secondLatch != null) { + secondLatch.countDown(); + } + } + }); + + // When: an internal "splitsUpdated" event is notified via SSE + fixture.pushSplitUpdate("2000", "1000"); + + // Then: sdkUpdate is emitted and handler H1 is invoked once + awaitEvent(firstUpdateLatch, "SDK_UPDATE for H1"); + assertFiredOnce(handler1Count, "H1"); + + // 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); + + 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, handler2Capture.count.get()); + + // Ensure handlers are registered before pushing second update + Thread.sleep(500); + if (fixture.streamingData != null) { + TestingHelper.pushKeepAlive(fixture.streamingData); + } + + // When: another internal "splitsUpdated" event is notified + fixture.pushSplitUpdate("2001", "2000"); + + // Then: both H1 and H2 are invoked for that second sdkUpdate + awaitEvent(secondUpdateLatch, "Second SDK_UPDATE", 15); + + // H1 should now have 2 total invocations (1 from first + 1 from second) + assertFiredTimes(handler1Count, "H1", 2); + // H2 should have 1 invocation (only from second update, no replay) + assertFiredOnce(handler2Capture.count, "H2"); + + fixture.destroy(); + } + + /** + * Scenario: sdkReadyTimedOut is emitted when readiness timeout elapses + *

+ * Given a handler Htimeout is registered for sdkReadyTimedOut + * And a handler Hready is registered for sdkReady + * And the readiness timeout is configured to T seconds + * When the timeout T elapses without sdkReady firing + * Then the internal "sdkReadyTimeoutReached" event is notified + * And sdkReadyTimedOut is emitted exactly once + * And handler Htimeout is invoked once + * And sdkReady is not emitted + */ + @Test + public void sdkReadyTimedOutEmittedWhenReadinessTimeoutElapses() throws Exception { + // Given: the readiness timeout is configured to a short timeout (2 seconds) + SplitClientConfig config = SplitClientConfig.builder() + .serviceEndpoints(endpoints()) + .ready(2000) + .featuresRefreshRate(999999) + .segmentsRefreshRate(999999) + .impressionsRefreshRate(999999) + .syncEnabled(true) + .trafficType("account") + .build(); + + // Set up mock server to delay responses so sync doesn't complete before timeout + mWebServer.setDispatcher(createDelayedDispatcher(5)); + + SplitFactory factory = buildFactory(config); + SplitClient client = factory.client(new Key("key_1")); + + EventCapture timeoutCapture = new EventCapture<>(); + AtomicInteger readyCount = new AtomicInteger(0); + + client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient c) { + timeoutCapture.increment(); + } + }); + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient c) { + readyCount.incrementAndGet(); + } + }); + + // Then: sdkReadyTimedOut is emitted exactly once + 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, readyCount.get()); + + factory.destroy(); + } + + /** + * Scenario: sdkReadyTimedOut is suppressed when sdkReady fires before timeout + *

+ * Given a handler Htimeout is registered for sdkReadyTimedOut + * And a handler Hready is registered for sdkReady + * And the readiness timeout is configured to T seconds + * When internal events for sdkReadyFromCache and sdkReady complete before the timeout elapses + * Then sdkReady is emitted + * And sdkReadyTimedOut is not emitted + * When the internal "sdkReadyTimeoutReached" event is notified after sdkReady has fired + * Then sdkReadyTimedOut is still not emitted (suppressed by sdkReady) + */ + @Test + public void sdkReadyTimedOutSuppressedWhenSdkReadyFiresBeforeTimeout() throws Exception { + // Given: the readiness timeout is configured to a longer timeout (10 seconds) + SplitClientConfig config = SplitClientConfig.builder() + .serviceEndpoints(endpoints()) + .ready(10000) + .featuresRefreshRate(999999) + .segmentsRefreshRate(999999) + .impressionsRefreshRate(999999) + .syncEnabled(true) + .trafficType("account") + .build(); + + SplitFactory factory = buildFactory(config); + 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 c) { + timeoutCount.incrementAndGet(); + } + }); + + EventCapture readyCapture = captureReadyEvent(client); + + // Then: sdkReady is emitted + awaitEvent(readyCapture.latch, "SDK_READY"); + assertFiredOnce(readyCapture.count, "Ready handler"); + + // And: sdkReadyTimedOut is not emitted + Thread.sleep(2000); + assertEquals("SDK_READY_TIMED_OUT should not fire (suppressed)", 0, timeoutCount.get()); + + factory.destroy(); + } + + /** + * Scenario: Sync completion does not trigger sdkUpdate during initial sync + *

+ * Given a handler HUpdate is registered for sdkUpdate + * And a handler HReady is registered for sdkReady + * And the SDK is performing initial sync + * When internal events "splitsUpdated" and "ruleBasedSegmentsUpdated" are notified (data changed during sync) + * And then "targetingRulesSyncComplete" and "membershipsSyncComplete" are notified + * Then sdkReadyFromCache is emitted (via sync path) + * And sdkReady is emitted + * But sdkUpdate is NOT emitted because the *_UPDATED events were notified before sdkReady fired + */ + @Test + public void syncCompletionDoesNotTriggerSdkUpdateDuringInitialSync() throws Exception { + SplitFactory factory = buildFactory(buildConfig()); + SplitClient client = factory.client(new Key("key_1")); + + EventCapture updateCapture = captureUpdateEvent(client); + CountDownLatch readyLatch = captureLegacyReadyEvent(client); + + // When: sync completes (happens automatically during initialization) + 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, updateCapture.count.get()); + + factory.destroy(); + } + + /** + * Scenario: Handlers for a single event are invoked sequentially and errors are isolated + *

+ * Given three handlers H1, H2 and H3 are registered for sdkUpdate + * And H2 throws an exception when invoked + * And sdkReady has already been emitted + * When an internal "splitsUpdated" event is notified via SSE + * Then sdkUpdate is emitted once + * And all handlers are invoked sequentially (one at a time, not concurrently) + * And H2's exception is caught by delivery and doesn't crash the SDK + * And H3 is invoked even though H2 threw an exception (error isolation) + * And the SDK process does not crash + */ + @Test + public void handlersInvokedSequentiallyErrorsIsolated() 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); + AtomicInteger handler3Count = new AtomicInteger(0); + AtomicInteger handler1Order = new AtomicInteger(0); + AtomicInteger handler2Order = new AtomicInteger(0); + AtomicInteger handler3Order = new AtomicInteger(0); + AtomicInteger orderCounter = new AtomicInteger(0); + CountDownLatch updateLatch = new CountDownLatch(3); + + // 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 SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + handler1Count.incrementAndGet(); + handler1Order.set(orderCounter.incrementAndGet()); + updateLatch.countDown(); + } + }); + + fixture.client.addEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + handler2Count.incrementAndGet(); + handler2Order.set(orderCounter.incrementAndGet()); + updateLatch.countDown(); + throw new RuntimeException("Handler H2 exception"); + } + }); + + fixture.client.addEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + handler3Count.incrementAndGet(); + handler3Order.set(orderCounter.incrementAndGet()); + updateLatch.countDown(); + } + }); + + // When: an internal "splitsUpdated" event is notified via SSE + fixture.pushSplitUpdate(); + + // Then: all three handlers are invoked + boolean allHandlersFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("All handlers should be invoked", allHandlersFired); + + // Verify all handlers were invoked exactly once + assertEquals("Handler H1 should be invoked once", 1, handler1Count.get()); + assertEquals("Handler H2 should be invoked once", 1, handler2Count.get()); + assertEquals("Handler H3 should be invoked once despite H2 throwing", 1, handler3Count.get()); + + // Verify handlers were invoked sequentially (orderCounter should be 1, 2, 3) + // Note: We don't check which handler got which order number because handlers + // are stored in a HashSet which doesn't guarantee iteration order. + // The important thing is that all handlers were invoked and H3 was invoked + // even though H2 threw an exception (error isolation). + assertTrue("All handlers should have been assigned order numbers", + handler1Order.get() > 0 && handler2Order.get() > 0 && handler3Order.get() > 0); + assertEquals("Order counter should be 3 (one for each handler)", 3, orderCounter.get()); + + // Verify error isolation: H3 was invoked even though H2 threw an exception + // This is the key assertion - that errors don't prevent subsequent handlers from executing + assertTrue("H3 should be invoked even if H2 throws (error isolation)", handler3Count.get() == 1); + + fixture.destroy(); + } + + /** + * Scenario: Metadata is correctly propagated to handlers + *

+ * Given a handler H is registered for sdkUpdate which inspects the received metadata + * And sdkReady has already been emitted + * When an internal "splitsUpdated" event is notified via SSE + * Then sdkUpdate is emitted + * And handler H is invoked once + * And handler H receives metadata (may contain updatedFlags depending on notification type) + */ + @Test + public void metadataCorrectlyPropagatedToHandlers() throws Exception { + // Given: sdkReady has already been emitted (with streaming support) + TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); + + 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 + awaitEvent(capture.latch, "SDK_UPDATE"); + assertFiredOnce(capture.count, "SDK_UPDATE handler"); + assertNotNull("Metadata should not be null", capture.metadata.get()); + + fixture.destroy(); + } + + /** + * Scenario: Destroying a client stops events and clears handlers + *

+ * Given a SplitClient with an EventsManager and a handler H registered for sdkUpdate + * And sdkReady has already been emitted + * When the client is destroyed + * And an internal "splitsUpdated" event is notified via SSE + * Then handler H is never invoked (handlers were cleared on destroy) + * When registering a new handler H2 for sdkUpdate after destroy + * Then the registration is a no-op + * And H2 is never invoked even when another update is pushed + */ + @Test + public void destroyingClientStopsEventsAndClearsHandlers() throws Exception { + // Given: sdkReady has already been emitted (with streaming support) + TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); + + 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, handler1.count.get()); + + // When: registering a new handler H2 for sdkUpdate after destroy + EventCapture handler2 = captureUpdateEvent(fixture.client); + fixture.pushSplitUpdate("4000", "3000"); + + Thread.sleep(1000); + 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(); + } + + /** + * Scenario: SDK-scoped internal events fan out to multiple clients + *

+ * Given a factory with two clients ClientA and ClientB + * And each client has its own EventsManager instance registered with EventsManagerCoordinator + * And handlers HA and HB are registered for sdkUpdate on ClientA and ClientB respectively + * And both clients have already emitted sdkReady + * When a SDK-scoped internal "splitsUpdated" event is notified via SSE + * Then sdkUpdate is emitted once per client + * And handler HA is invoked once + * And handler HB is invoked once + */ + @Test + public void sdkScopedEventsFanOutToMultipleClients() throws Exception { + // Given: a factory with two clients (with streaming support) + TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("key_A"), new Key("key_B")); + + EventCapture captureA = captureUpdateEvent(fixture.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 + 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(); + } + + /** + * Scenario: SDK-scoped events (splitsUpdated) fan out to all clients + *

+ * This test verifies that when a split update notification arrives via SSE, + * the SDK_UPDATE event is emitted to all clients in the factory. + *

+ * Note: True client-scoped events like mySegmentsUpdated require specific streaming + * notifications targeted at individual user keys. This test demonstrates the difference + * by showing that SDK-scoped split updates affect all clients equally. + */ + @Test + public void clientScopedEventsDoNotFanOutToOtherClients() throws Exception { + // Given: a factory with two clients (with streaming support) + TwoClientFixture fixture = createTwoStreamingClientsAndWaitForReady(new Key("userA"), new Key("userB")); + + EventCapture captureA = captureUpdateEvent(fixture.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 + 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(); + } + + /** + * Scenario: sdkUpdateMetadata contains Type.FLAGS_UPDATE for flags update + *

+ * Given sdkReady has already been emitted + * And a handler H is registered for sdkUpdate + * When a split update notification arrives via SSE + * Then sdkUpdate is emitted + * And handler H receives metadata with getType() returning Type.FLAGS_UPDATE + * And handler H receives metadata with getNames() containing the updated flag names + */ + @Test + public void sdkUpdateMetadataContainsTypeForFlagsUpdate() throws Exception { + TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); + + EventCapture capture = captureUpdateEvent(fixture.client); + + fixture.pushSplitUpdate(); + + awaitEvent(capture.latch, "SDK_UPDATE"); + assertNotNull("Metadata should not be null", capture.metadata.get()); + assertEquals("Type should be FLAGS_UPDATE", + 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(); + } + + /** + * Scenario: sdkUpdateMetadata contains Type.SEGMENTS_UPDATE for rule-based segments update + *

+ * Given sdkReady has already been emitted + * And a handler H is registered for sdkUpdate + * When a rule-based segment update notification arrives via SSE + * Then sdkUpdate is emitted + * And handler H receives metadata with getType() returning Type.SEGMENTS_UPDATE + * And handler H receives metadata with getNames() returning an empty list + *

+ * Note: SEGMENTS_UPDATE always has empty names (segment names are not included). + */ + @Test + public void sdkUpdateMetadataContainsTypeForSegmentsUpdate() throws Exception { + TestClientFixture fixture = createStreamingClientWithRbsAndWaitForReady(new Key("key_1")); + + EventCapture capture = captureUpdateEvent(fixture.client); + + fixture.pushRbsUpdate(); + + 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, 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(); + } + + /** + * Scenario: Only FLAGS_UPDATE fires when both flags and RBS change together + *

+ * Given sdkReady has already been emitted + * And a handler H is registered for sdkUpdate + * When a polling sync returns changes to both flags AND rule-based segments + * Then only ONE sdkUpdate is emitted + * And handler H receives metadata with getType() returning Type.FLAGS_UPDATE + * And SEGMENTS_UPDATE is NOT fired (RBS changes are subsumed by FLAGS_UPDATE) + */ + @Test + public void sdkUpdateFiresOnlyOnceWhenBothFlagsAndRbsChange() throws Exception { + AtomicInteger splitChangesHitCount = new AtomicInteger(0); + + 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() + .serviceEndpoints(endpoints()) + .ready(30000) + .featuresRefreshRate(3) // Poll every 3 seconds + .segmentsRefreshRate(999999) + .impressionsRefreshRate(999999) + .streamingEnabled(false) + .trafficType("account") + .build(); + + SplitFactory factory = buildFactory(config); + SplitClient client = factory.client(); + + // Wait for SDK_READY + 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)); + + // Register handler to count SDK_UPDATE events and capture metadata + List receivedMetadataList = new ArrayList<>(); + CountDownLatch updateLatch = new CountDownLatch(1); + + client.addEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + synchronized (receivedMetadataList) { + receivedMetadataList.add(metadata); + } + updateLatch.countDown(); + } + }); + + // Wait for SDK_UPDATE (triggered by polling that returns both flag and RBS changes) + boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire", updateFired); + + // Wait a bit to ensure no additional events fire + Thread.sleep(1000); + + // Verify only ONE SDK_UPDATE was fired + synchronized (receivedMetadataList) { + assertEquals("Should receive exactly 1 SDK_UPDATE event (not 2)", 1, receivedMetadataList.size()); + + // Verify it's FLAGS_UPDATE (not SEGMENTS_UPDATE) + SdkUpdateMetadata metadata = receivedMetadataList.get(0); + assertNotNull("Metadata should not be null", metadata); + assertEquals("Type should be FLAGS_UPDATE (not SEGMENTS_UPDATE)", + SdkUpdateMetadata.Type.FLAGS_UPDATE, metadata.getType()); + } + + factory.destroy(); + } + + /** + * Scenario: sdkUpdateMetadata contains Type.SEGMENTS_UPDATE for membership segments update (polling) + *

+ * Given sdkReady has already been emitted + * And a handler H is registered for sdkUpdate + * When segments change via polling (server returns different segments) + * Then sdkUpdate is emitted + * And handler H receives metadata with getType() returning Type.SEGMENTS_UPDATE + * And handler H receives metadata with getNames() returning an empty list + */ + @Test + public void sdkUpdateMetadataContainsTypeForMembershipSegmentsUpdate() throws Exception { + verifySdkUpdateForSegmentsPollingWithEmptyNames( + IntegrationHelper.membershipsResponse(new String[]{"segment1", "segment2"}, 1000), + IntegrationHelper.membershipsResponse(new String[]{"segment2", "segment3"}, 2000) + ); + } + + /** + * Scenario: sdkUpdateMetadata includes flag names for polling flag updates + *

+ * Given sdkReady has already been emitted in polling mode + * When polling returns a flag update + * Then sdkUpdate metadata contains FLAGS_UPDATE with non-empty names + */ + @Test + public void sdkUpdateMetadataContainsNamesForPollingFlagsUpdate() throws Exception { + 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 + * And handler H receives metadata with getNames() returning an empty list + */ + @Test + public void sdkUpdateMetadataContainsTypeForLargeSegmentsUpdate() throws Exception { + verifySdkUpdateForSegmentsPollingWithEmptyNames( + IntegrationHelper.membershipsResponse(new String[]{}, 1000L, new String[]{"large_segment1", "large_segment2"}, 1000L), + IntegrationHelper.membershipsResponse(new String[]{}, 1000L, new String[]{"large_segment2", "large_segment3"}, 2000L) + ); + } + + /** + * Scenario: Two distinct SDK_UPDATE events are fired when both segments and large segments change + *

+ * Given sdkReady has already been emitted + * And a handler H is registered for sdkUpdate + * When a single memberships response contains changes to both segments and large segments + * Then two SDK_UPDATE events are emitted + * And both events have metadata with getType() returning Type.SEGMENTS_UPDATE and empty names + */ + @Test + public void twoDistinctSdkUpdateEventsWhenBothSegmentsAndLargeSegmentsChange() throws Exception { + 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); + + // Verify we received 2 distinct SDK_UPDATE events + assertEquals("Should receive 2 SDK_UPDATE events", 2, metadataList.size()); + + // Both events should be SEGMENTS_UPDATE type with empty names + for (SdkUpdateMetadata metadata : metadataList) { + assertNotNull("Metadata should not be null", metadata); + assertEquals("Type should be SEGMENTS_UPDATE", + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, metadata.getType()); + assertNotNull("Names should not be null", metadata.getNames()); + assertTrue("Names should be empty for SEGMENTS_UPDATE", metadata.getNames().isEmpty()); + } + } + + /** + * Helper method to verify SDK_UPDATE with SEGMENTS_UPDATE type is emitted when segments change via polling. + * Verifies that names are always empty for SEGMENTS_UPDATE. + * + * @param initialResponse the memberships response for initial sync + * @param pollingResponse the memberships response for polling (with changed segments) + */ + private void verifySdkUpdateForSegmentsPollingWithEmptyNames(String initialResponse, String pollingResponse) throws Exception { + List metadataList = waitForSegmentsPollingUpdates(initialResponse, pollingResponse, 1); + + assertEquals("Should receive 1 SDK_UPDATE event", 1, metadataList.size()); + + SdkUpdateMetadata metadata = metadataList.get(0); + assertNotNull("Metadata should not be null", metadata); + assertEquals("Type should be SEGMENTS_UPDATE", + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, metadata.getType()); + + assertNotNull("Names should not be null", metadata.getNames()); + assertTrue("Names should be empty for SEGMENTS_UPDATE", metadata.getNames().isEmpty()); + } + + /** + * Helper method that sets up polling for segments and waits for the expected number of SDK_UPDATE events. + * + * @param initialResponse the memberships response for initial sync + * @param pollingResponse the memberships response for polling (with changed segments) + * @param expectedEventCount the number of SDK_UPDATE events to wait for + * @return list of received SdkUpdateMetadata from the events + */ + private List waitForSegmentsPollingUpdates(String initialResponse, String pollingResponse, + int expectedEventCount) throws Exception { + AtomicInteger membershipsHitCount = 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)) { + int count = membershipsHitCount.incrementAndGet(); + if (count <= 1) { + return new MockResponse().setResponseCode(200).setBody(initialResponse); + } else { + return new MockResponse().setResponseCode(200).setBody(pollingResponse); + } + } else if (path.contains("/splitChanges")) { + return new MockResponse().setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)); + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(pollingDispatcher); + + SplitClientConfig config = new TestableSplitConfigBuilder() + .serviceEndpoints(endpoints()) + .ready(30000) + .featuresRefreshRate(999999) + .segmentsRefreshRate(3) + .impressionsRefreshRate(999999) + .streamingEnabled(false) + .trafficType("account") + .build(); + + SplitFactory factory = buildFactory(config); + SplitClient 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)); + + List receivedMetadataList = new ArrayList<>(); + AtomicInteger legacyHandlerCount = new AtomicInteger(0); + // Wait for expectedEventCount events x 2 handlers (new API + legacy) + CountDownLatch updateLatch = new CountDownLatch(expectedEventCount * 2); + + // Register new API handler (addEventListener) + client.addEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient c, SdkUpdateMetadata metadata) { + synchronized (receivedMetadataList) { + receivedMetadataList.add(metadata); + } + updateLatch.countDown(); + } + }); + + // Register legacy API handler (client.on) + client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient c) { + legacyHandlerCount.incrementAndGet(); + updateLatch.countDown(); + } + }); + + boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire " + expectedEventCount + " time(s). " + + "Hit count: " + membershipsHitCount.get() + ", metadata count: " + receivedMetadataList.size() + + ", legacy count: " + legacyHandlerCount.get(), updateFired); + + // Verify legacy API was triggered the expected number of times + assertEquals("Legacy API (client.on) should be triggered " + expectedEventCount + " time(s)", + expectedEventCount, legacyHandlerCount.get()); + + factory.destroy(); + + return receivedMetadataList; + } + + + + /** + * Scenario: Multiple listeners with onUpdate are both invoked + *

+ * Given sdkReady has already been emitted + * 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 + */ + @Test + public void multipleListenersWithOnUpdateBothInvoked() throws Exception { + TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); + + EventCapture capture1 = captureUpdateEvent(fixture.client); + EventCapture capture2 = captureUpdateEvent(fixture.client); + + fixture.pushSplitUpdate(); + + 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(); + } + + /** + * Scenario: Multiple listeners with onReady are both invoked + *

+ * Given the SDK is starting + * 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 + */ + @Test + public void multipleListenersWithOnReadyBothInvoked() throws Exception { + populateDatabaseWithCacheData(System.currentTimeMillis()); + SplitFactory factory = buildFactory(buildConfig()); + SplitClient client = factory.client(new Key("key_1")); + + EventCapture capture1 = captureReadyEvent(client); + EventCapture capture2 = captureReadyEvent(client); + + 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(); + } + + /** + * Scenario: Listeners with different callbacks (onReady and onUpdate) each invoked on correct event + *

+ * Given the SDK is starting + * 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) + * When an SDK_UPDATE notification arrives via SSE + * Then L2.onUpdate is invoked + * And L1.onReady is NOT invoked again (already fired once for SDK_READY) + */ + @Test + public void listenersWithDifferentCallbacksInvokedOnCorrectEventType() throws Exception { + TestClientFixture fixture = createStreamingClient(new Key("key_1")); + + EventCapture readyCapture = captureReadyEvent(fixture.client); + EventCapture updateCapture = captureUpdateEvent(fixture.client); + + 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(); + + awaitEvent(updateCapture.latch, "SDK_UPDATE"); + assertFiredOnce(updateCapture.count, "onUpdate"); + assertFiredOnce(readyCapture.count, "onReady (not invoked again)"); + + fixture.destroy(); + } + + /** + * Scenario: Multiple listeners with both onReady and onUpdate in same listener + *

+ * Given the SDK is starting + * 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 + * When an SDK_UPDATE notification arrives via SSE + * Then both L1.onUpdate and L2.onUpdate are invoked exactly once each + */ + @Test + public void multipleListenersWithBothReadyAndUpdateHandlers() throws Exception { + TestClientFixture fixture = createStreamingClient(new Key("key_1")); + + 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(l1ReadyCount, readyLatch, l1UpdateCount, updateLatch)); + fixture.client.addEventListener(createDualListener(l2ReadyCount, readyLatch, l2UpdateCount, updateLatch)); + + 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(); + + 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(); + } + + /** + * Scenario: Multiple listeners with onReady replay to late subscribers + *

+ * Given SDK_READY has already been emitted + * 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 + */ + @Test + public void multipleListenersWithOnReadyReplayToLateSubscribers() throws Exception { + TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); + + EventCapture capture1 = captureReadyEvent(fixture.client); + awaitEvent(capture1.latch, "Listener 1 replay", 5); + assertFiredOnce(capture1.count, "Listener 1 (replay)"); + + EventCapture capture2 = captureReadyEvent(fixture.client); + awaitEvent(capture2.latch, "Listener 2 replay", 5); + assertFiredOnce(capture2.count, "Listener 2 (replay)"); + + Thread.sleep(500); + assertFiredOnce(capture1.count, "Listener 1 (not invoked again)"); + + fixture.destroy(); + } + + private TestClientFixture createClientAndWaitForReady(SplitClientConfig config, Key key) throws InterruptedException { + SplitFactory factory = buildFactory(config); + SplitClient client = factory.client(key); + CountDownLatch readyLatch = new CountDownLatch(1); + + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + readyLatch.countDown(); + } + }); + + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_READY should fire", readyFired); + + return new TestClientFixture(factory, client, readyLatch); + } + + private TestClientFixture createClientAndWaitForReady(Key key) throws InterruptedException { + return createClientAndWaitForReady(buildConfig(), key); + } + + /** + * Creates a client with streaming enabled but does NOT wait for SDK_READY. + */ + private TestClientFixture createStreamingClient(Key key) throws IOException { + BlockingQueue streamingData = new LinkedBlockingDeque<>(); + CountDownLatch sseLatch = new CountDownLatch(1); + + HttpResponseMockDispatcher dispatcher = createStreamingDispatcher(streamingData, sseLatch); + HttpClientMock httpClientMock = new HttpClientMock(dispatcher); + SplitClientConfig config = new TestableSplitConfigBuilder() + .ready(30000) + .streamingEnabled(true) + .trafficType("account") + .enableDebug() + .build(); + + SplitFactory factory = IntegrationHelper.buildFactory( + IntegrationHelper.dummyApiKey(), key, config, mContext, httpClientMock, mDatabase); + + SplitClient client = factory.client(key); + + return new TestClientFixture(factory, client, null, streamingData, sseLatch); + } + + private TestClientFixture createStreamingClientAndWaitForReady(Key key) throws InterruptedException, IOException { + TestClientFixture fixture = createStreamingClient(key); + + CountDownLatch readyLatch = new CountDownLatch(1); + fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + readyLatch.countDown(); + } + }); + + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_READY should fire", readyFired); + + // Wait for SSE connection and send keep-alive + fixture.waitForSseConnection(); + + return new TestClientFixture(fixture.factory, fixture.client, readyLatch, fixture.streamingData, fixture.sseLatch); + } + + private HttpResponseMockDispatcher createStreamingDispatcher(BlockingQueue streamingData, CountDownLatch sseLatch) { + return new HttpResponseMockDispatcher() { + @Override + public HttpResponseMock getResponse(URI uri, HttpMethod method, String body) { + if (uri.getPath().contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + return new HttpResponseMock(200, IntegrationHelper.dummyAllSegments()); + } else if (uri.getPath().contains("/splitChanges")) { + return new HttpResponseMock(200, IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)); + } else if (uri.getPath().contains("/auth")) { + sseLatch.countDown(); + return new HttpResponseMock(200, IntegrationHelper.streamingEnabledToken()); + } else if (uri.getPath().contains("/testImpressions/bulk")) { + return new HttpResponseMock(200); + } + return new HttpResponseMock(200); + } + + @Override + public HttpStreamResponseMock getStreamResponse(URI uri) { + try { + return new HttpStreamResponseMock(200, streamingData); + } catch (IOException e) { + return null; + } + } + }; + } + + private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key keyB) throws InterruptedException, IOException { + BlockingQueue streamingData = new LinkedBlockingDeque<>(); + CountDownLatch sseLatch = new CountDownLatch(1); + + HttpResponseMockDispatcher dispatcher = createStreamingDispatcher(streamingData, sseLatch); + HttpClientMock httpClientMock = new HttpClientMock(dispatcher); + SplitClientConfig config = new TestableSplitConfigBuilder() + .ready(30000) + .streamingEnabled(true) + .trafficType("account") + .enableDebug() + .build(); + + SplitFactory factory = IntegrationHelper.buildFactory( + IntegrationHelper.dummyApiKey(), keyA, config, mContext, httpClientMock, mDatabase); + + SplitClient clientA = factory.client(keyA); + SplitClient clientB = factory.client(keyB); + + CountDownLatch readyLatchA = captureLegacyReadyEvent(clientA); + CountDownLatch readyLatchB = captureLegacyReadyEvent(clientB); + + 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); + TestingHelper.pushKeepAlive(streamingData); + + return new TwoClientFixture(factory, clientA, clientB, streamingData); + } + + private SplitEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch, + AtomicInteger updateCount, CountDownLatch updateLatch) { + return new SplitEventListener() { + + @Override + public void onReady(SplitClient client, SdkReadyMetadata metadata) { + if (readyCount != null) readyCount.incrementAndGet(); + if (readyLatch != null) readyLatch.countDown(); + } + + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + if (updateCount != null) updateCount.incrementAndGet(); + if (updateLatch != null) updateLatch.countDown(); + } + }; + } + + + /** + * Helper class to hold factory and client together for cleanup. + */ + private static class TestClientFixture { + final SplitFactory factory; + final SplitClient client; + final CountDownLatch readyLatch; + final BlockingQueue streamingData; + final CountDownLatch sseLatch; + + TestClientFixture(SplitFactory factory, SplitClient client, CountDownLatch readyLatch) { + this(factory, client, readyLatch, null, null); + } + + TestClientFixture(SplitFactory factory, SplitClient client, CountDownLatch readyLatch, BlockingQueue streamingData) { + this(factory, client, readyLatch, streamingData, null); + } + + TestClientFixture(SplitFactory factory, SplitClient client, CountDownLatch readyLatch, + BlockingQueue streamingData, CountDownLatch sseLatch) { + this.factory = factory; + this.client = client; + this.readyLatch = readyLatch; + this.streamingData = streamingData; + this.sseLatch = sseLatch; + } + + void waitForSseConnection() throws InterruptedException { + if (sseLatch != null) { + sseLatch.await(10, TimeUnit.SECONDS); + TestingHelper.pushKeepAlive(streamingData); + } + } + + void pushSplitUpdate() { + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.splitChangeV2CompressionType0()); + } + } + + void pushSplitUpdate(String changeNumber, String previousChangeNumber) { + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.splitChangeV2( + changeNumber, previousChangeNumber, "0", IntegrationHelper.SPLIT_UPDATE_PAYLOAD_TYPE0)); + } + } + + void pushSplitKill(String splitName) { + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.splitKill("9999999999999", splitName)); + } + } + + void pushRbsUpdate() { + pushRbsUpdate("2000", "1000"); + } + + void pushRbsUpdate(String changeNumber, String previousChangeNumber) { + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.rbsChange( + changeNumber, previousChangeNumber, IntegrationHelper.RBS_UPDATE_PAYLOAD_TYPE0)); + } + } + + void destroy() { + factory.destroy(); + } + } + + /** + * Helper class to hold factory and two clients together for cleanup. + */ + private static class TwoClientFixture { + final SplitFactory mFactory; + final SplitClient mClientA; + final SplitClient mClientB; + final BlockingQueue mStreamingData; + + TwoClientFixture(SplitFactory factory, SplitClient clientA, SplitClient clientB, BlockingQueue streamingData) { + mFactory = factory; + mClientA = clientA; + mClientB = clientB; + mStreamingData = streamingData; + } + + void pushSplitUpdate() { + if (mStreamingData != null) { + pushMessage(mStreamingData, IntegrationHelper.splitChangeV2CompressionType0()); + } + } + + void pushMembershipKeyListUpdate(String key, String segmentName) { + if (mStreamingData != null) { + pushMessage(mStreamingData, membershipKeyListUpdateMessage(key, segmentName)); + } + } + + void 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"); + Logger.d("Pushed message: " + message); + } catch (InterruptedException e) { + Logger.e("Failed to push message", e); + } + } + + /** + * Populates the database with splits and segments to simulate a populated cache. + */ + private void populateDatabaseWithCacheData(long timestamp) { + // Populate splits + List splitEntities = new ArrayList<>(); + long finalChangeNumber = 1000L; + for (int i = 0; i < 3; i++) { + SplitEntity entity = new SplitEntity(); + entity.setName("split_" + i); + long cn = 1000L + i; + finalChangeNumber = cn; + entity.setBody(IntegrationHelper.splitEntityBody("split_" + i, cn)); + splitEntities.add(entity); + } + mDatabase.splitDao().insert(splitEntities); + mDatabase.generalInfoDao().update(new GeneralInfoEntity(GeneralInfoEntity.CHANGE_NUMBER_INFO, finalChangeNumber)); + mDatabase.generalInfoDao().update(new GeneralInfoEntity(GeneralInfoEntity.SPLITS_UPDATE_TIMESTAMP, timestamp)); + + // Populate segments for default key + MySegmentEntity segmentEntity = new MySegmentEntity(); + segmentEntity.setUserKey("DEFAULT_KEY"); + 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(IntegrationHelper.segmentListJson("segment1")); + segmentEntity2.setUpdatedAt(System.currentTimeMillis() / 1000); + mDatabase.mySegmentDao().update(segmentEntity2); + } + + /** + * Creates a streaming client with RBS data pre-populated and waits for SDK_READY. + * Pre-populates RBS change number so the test can verify in-place update behavior. + */ + private TestClientFixture createStreamingClientWithRbsAndWaitForReady(Key key) throws InterruptedException, IOException { + // Pre-populate RBS in storage so in-place update can work + populateDatabaseWithRbsData(); + + TestClientFixture fixture = createStreamingClient(key); + + CountDownLatch readyLatch = new CountDownLatch(1); + fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + readyLatch.countDown(); + } + }); + + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_READY should fire", readyFired); + + // Wait for SSE connection and send keep-alive + fixture.waitForSseConnection(); + + return new TestClientFixture(fixture.factory, fixture.client, readyLatch, fixture.streamingData, fixture.sseLatch); + } + + 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/integration/largesegments/LargeSegmentsStreamingTest.java b/main/src/androidTest/java/tests/integration/largesegments/LargeSegmentsStreamingTest.java index 30d67a2e3..9e86fb7df 100644 --- a/main/src/androidTest/java/tests/integration/largesegments/LargeSegmentsStreamingTest.java +++ b/main/src/androidTest/java/tests/integration/largesegments/LargeSegmentsStreamingTest.java @@ -153,10 +153,17 @@ private SplitFactory getFactory(SplitRoomDatabase database) throws IOException { } private HttpResponseMockDispatcher buildDispatcher() { + final long splitsTill = 1602796638344L; Map responses = new HashMap<>(); responses.put(SPLIT_CHANGES, (path, query, body) -> { updateEndpointHit(SPLIT_CHANGES); - return new HttpResponseMock(200, splitChangesLargeSegments(1602796638344L, 1602796638344L)); + String sinceStr = IntegrationHelper.getSinceFromUri(path); + long since = sinceStr != null ? Long.parseLong(sinceStr) : -1; + if (since >= splitsTill) { + // No changes since last fetch + return new HttpResponseMock(200, IntegrationHelper.emptyTargetingRulesChanges(splitsTill, splitsTill)); + } + return new HttpResponseMock(200, splitChangesLargeSegments(splitsTill, splitsTill)); }); String key = IntegrationHelper.dummyUserKey().matchingKey(); diff --git a/main/src/androidTest/java/tests/integration/rollout/RolloutCacheManagerIntegrationTest.java b/main/src/androidTest/java/tests/integration/rollout/RolloutCacheManagerIntegrationTest.java index ce8df8024..4555f0ef2 100644 --- a/main/src/androidTest/java/tests/integration/rollout/RolloutCacheManagerIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/rollout/RolloutCacheManagerIntegrationTest.java @@ -126,11 +126,9 @@ public void repeatedInitWithClearOnInitSetToTrueDoesNotClearIfMinDaysHasNotElaps assertEquals(8000L, initialChangeNumber); // values after clear - assertEquals(1, intermediateSegments.size()); - assertTrue(Json.fromJson(intermediateSegments.get(0).getSegmentList(), SegmentsChange.class).getSegments().isEmpty()); - assertEquals(1, intermediateLargeSegments.size()); + assertEquals(0, intermediateSegments.size()); + assertEquals(0, intermediateLargeSegments.size()); assertEquals(0, intermediateFlags.size()); - assertTrue(Json.fromJson(intermediateLargeSegments.get(0).getSegmentList(), SegmentsChange.class).getSegments().isEmpty()); assertEquals(-1, intermediateChangeNumber); // values after second init (values were reinserted into DB); no clear @@ -203,11 +201,9 @@ private void verify(SplitFactory factory, CountDownLatch readyLatch, List receivedMetadata = new AtomicReference<>(); + + // Wait for SDK_READY first + eventManager.register(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client) { + readyLatch.countDown(); + } + }); + + // Register for SDK_UPDATE with metadata callback using SdkEventListener + eventManager.registerEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + // Make SDK_READY fire + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + Assert.assertTrue("SDK_READY should fire", readyLatch.await(5, TimeUnit.SECONDS)); + + eventManager.notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION, + EventMetadataHelpers.createUpdatedFlagsMetadata(Collections.singletonList("killed_flag"))); + + Assert.assertTrue("SDK_UPDATE should fire", updateLatch.await(5, TimeUnit.SECONDS)); + Assert.assertNotNull("Metadata should not be null", receivedMetadata.get()); + List names = receivedMetadata.get().getNames(); + Assert.assertNotNull("Names should not be null", names); + Assert.assertTrue("Metadata should contain only killed_flag", names.size() == 1 && names.contains("killed_flag")); + Assert.assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, receivedMetadata.get().getType()); + } + @Test public void testKilledSplitBeforeReady() throws InterruptedException { SplitClientConfig cfg = SplitClientConfig.builder().build(); - SplitEventsManager eventManager = new SplitEventsManager(cfg, new SplitTaskExecutorImpl()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); eventManager.setExecutionResources(new SplitEventExecutorResourcesMock()); @@ -182,7 +246,7 @@ public void testKilledSplitBeforeReady() throws InterruptedException { public void testTimeoutSplitsUpdated() throws InterruptedException { SplitClientConfig cfg = SplitClientConfig.builder().ready(2000).build(); - SplitEventsManager eventManager = new SplitEventsManager(cfg, new SplitTaskExecutorImpl()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); eventManager.setExecutionResources(new SplitEventExecutorResourcesMock()); CountDownLatch timeoutLatch = new CountDownLatch(1); TestingHelper.TestEventTask updateTask = TestingHelper.testTask(null); @@ -205,7 +269,7 @@ public void testTimeoutSplitsUpdated() throws InterruptedException { public void testTimeoutMySegmentsUpdated() throws InterruptedException { SplitClientConfig cfg = SplitClientConfig.builder().ready(2000).build(); - SplitEventsManager eventManager = new SplitEventsManager(cfg, new SplitTaskExecutorImpl()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); eventManager.setExecutionResources(new SplitEventExecutorResourcesMock()); CountDownLatch timeoutLatch = new CountDownLatch(1); TestingHelper.TestEventTask updateTask = TestingHelper.testTask(null); @@ -223,4 +287,79 @@ public void testTimeoutMySegmentsUpdated() throws InterruptedException { Assert.assertFalse(updateTask.onExecutedCalled); Assert.assertTrue(timeoutTask.onExecutedCalled); } + + @Test + public void testSdkEventListenerReceivesMetadataOnCorrectThreads() throws InterruptedException { + SplitClientConfig cfg = SplitClientConfig.builder().build(); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); + eventManager.setExecutionResources(new SplitEventExecutorResourcesMock()); + + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch allCalledLatch = new CountDownLatch(2); // Expect 2 calls (background and main thread) + + AtomicBoolean backgroundCalled = new AtomicBoolean(false); + AtomicBoolean mainThreadCalled = new AtomicBoolean(false); + + AtomicBoolean backgroundOnMainThread = new AtomicBoolean(true); // Should be false + AtomicBoolean mainThreadOnMainThread = new AtomicBoolean(false); // Should be true + + AtomicReference backgroundMetadata = new AtomicReference<>(); + AtomicReference mainThreadMetadata = new AtomicReference<>(); + + // Wait for SDK_READY first + eventManager.register(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client) { + readyLatch.countDown(); + } + }); + + // Register SdkEventListener to receive typed metadata + eventManager.registerEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + backgroundCalled.set(true); + backgroundOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + backgroundMetadata.set(metadata); + allCalledLatch.countDown(); + } + + @Override + public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { + mainThreadCalled.set(true); + mainThreadOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + mainThreadMetadata.set(metadata); + allCalledLatch.countDown(); + } + }); + + // Make SDK_READY fire + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + Assert.assertTrue("SDK_READY should fire", readyLatch.await(5, TimeUnit.SECONDS)); + + // Trigger SDK_UPDATE with metadata + eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, + EventMetadataHelpers.createUpdatedFlagsMetadata(Arrays.asList("flag1", "flag2"))); + + Assert.assertTrue("Both callbacks should be called", allCalledLatch.await(5, TimeUnit.SECONDS)); + + Assert.assertTrue("Background method should be called", backgroundCalled.get()); + Assert.assertTrue("Main thread method should be called", mainThreadCalled.get()); + + Assert.assertFalse("Background method should NOT run on main thread", backgroundOnMainThread.get()); + Assert.assertTrue("Main thread method SHOULD run on main thread", mainThreadOnMainThread.get()); + + Assert.assertNotNull("Background metadata should not be null", backgroundMetadata.get()); + List bgNames = backgroundMetadata.get().getNames(); + Assert.assertNotNull("Background names should not be null", bgNames); + Assert.assertTrue("Background metadata should contain flag1", bgNames.contains("flag1")); + Assert.assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, backgroundMetadata.get().getType()); + + Assert.assertNotNull("Main thread metadata should not be null", mainThreadMetadata.get()); + List mtNames = mainThreadMetadata.get().getNames(); + Assert.assertNotNull("Main thread names should not be null", mtNames); + Assert.assertTrue("Main thread metadata should contain flag1", mtNames.contains("flag1")); + Assert.assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, mainThreadMetadata.get().getType()); + } } 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 19d7017ba..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,6 +3,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +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; @@ -171,6 +172,11 @@ public boolean isReady() { public void on(SplitEvent event, SplitEventTask task) { } + @Override + public void addEventListener(SplitEventListener listener) { + // no-op + } + @Override public boolean track(String trafficType, String eventType) { return false; diff --git a/main/src/main/java/io/split/android/client/EvaluationResult.java b/main/src/main/java/io/split/android/client/EvaluationResult.java index 3c50e0428..da529eba6 100644 --- a/main/src/main/java/io/split/android/client/EvaluationResult.java +++ b/main/src/main/java/io/split/android/client/EvaluationResult.java @@ -14,6 +14,7 @@ public EvaluationResult(String treatment, String label) { this(treatment, label, null, null, false); } + @VisibleForTesting public EvaluationResult(String treatment, String label, boolean impressionsDisabled) { this(treatment, label, null, null, impressionsDisabled); } 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 913bd005e..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,6 +12,7 @@ import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; +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; @@ -190,14 +191,31 @@ public void on(SplitEvent event, SplitEventTask task) { checkNotNull(event); checkNotNull(task); - if (!event.equals(SplitEvent.SDK_READY_FROM_CACHE) && mEventsManager.eventAlreadyTriggered(event)) { - Logger.w(String.format("A listener was added for %s on the SDK, which has already fired and won’t be emitted again. The callback won’t be executed.", event.toString())); + // Allow registration for events that support replay (SDK_READY_FROM_CACHE and SDK_READY) + // Events with execution limit 1 can replay to late subscribers + if (!event.equals(SplitEvent.SDK_READY_FROM_CACHE) && + !event.equals(SplitEvent.SDK_READY) && + mEventsManager.eventAlreadyTriggered(event)) { + Logger.w(String.format("A listener was added for %s on the SDK, which has already fired and won't be emitted again. The callback won't be executed.", event.toString())); return; } mEventsManager.register(event, task); } + @Override + public void addEventListener(@NonNull SplitEventListener listener) { + if (mIsClientDestroyed) { + Logger.w("Client has already been destroyed. Cannot add event listener"); + return; + } + if (listener == null) { + Logger.w("SDK Event Listener cannot be null"); + return; + } + mEventsManager.registerEventListener(listener); + } + @Override public boolean track(String trafficType, String eventType) { return track(mKey.matchingKey(), trafficType, eventType, TRACK_DEFAULT_VALUE, null); diff --git a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java index d531fde38..8bb12d71f 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -244,7 +244,8 @@ private SplitFactoryImpl(@NonNull String apiToken, @NonNull Key key, @NonNull Sp mImpressionManager, mStorageContainer.getEventsStorage(), mEventsManagerCoordinator, - streamingComponents.getPushManagerEventBroadcaster() + streamingComponents.getPushManagerEventBroadcaster(), + mStorageContainer.getSplitsStorage() ); // Only available for integration tests if (synchronizerSpy != null) { diff --git a/main/src/main/java/io/split/android/client/events/BaseEventsManager.java b/main/src/main/java/io/split/android/client/events/BaseEventsManager.java deleted file mode 100644 index e22b9a7dc..000000000 --- a/main/src/main/java/io/split/android/client/events/BaseEventsManager.java +++ /dev/null @@ -1,68 +0,0 @@ -package io.split.android.client.events; - -import androidx.annotation.NonNull; - -import java.util.Collections; -import java.util.Set; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicInteger; - -import io.split.android.client.utils.logger.Logger; -import io.split.android.engine.scheduler.PausableThreadPoolExecutor; -import io.split.android.engine.scheduler.PausableThreadPoolExecutorImpl; - -public abstract class BaseEventsManager implements Runnable { - - private final static int QUEUE_CAPACITY = 20; - // Shared thread factory for all instances - private static final ThreadFactory EVENTS_THREAD_FACTORY = createThreadFactory(); - - protected final ArrayBlockingQueue mQueue; - - protected final Set mTriggered; - - private static ThreadFactory createThreadFactory() { - final AtomicInteger threadNumber = new AtomicInteger(1); - return new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "Split-FactoryEventsManager-" + threadNumber.getAndIncrement()); - thread.setDaemon(true); - thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { - Logger.e("Unexpected error " + e.getLocalizedMessage()); - } - }); - return thread; - } - }; - } - - public BaseEventsManager() { - mQueue = new ArrayBlockingQueue<>(QUEUE_CAPACITY); - mTriggered = Collections.newSetFromMap(new ConcurrentHashMap<>()); - launch(EVENTS_THREAD_FACTORY); - } - - @Override - public void run() { - // This code was intentionally designed this way - // noinspection InfiniteLoopStatement - while (true) { - triggerEventsWhenAreAvailable(); - } - } - - private void launch(ThreadFactory threadFactory) { - PausableThreadPoolExecutor mScheduler = PausableThreadPoolExecutorImpl.newSingleThreadExecutor(threadFactory); - mScheduler.submit(this); - mScheduler.resume(); - } - - protected abstract void triggerEventsWhenAreAvailable(); - - protected abstract void notifyInternalEvent(SplitInternalEvent event); -} diff --git a/main/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/main/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java deleted file mode 100644 index 3d3af8bf3..000000000 --- a/main/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java +++ /dev/null @@ -1,75 +0,0 @@ -package io.split.android.client.events; - -import static io.split.android.client.utils.Utils.checkNotNull; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -import io.split.android.client.api.Key; -import io.split.android.client.utils.logger.Logger; - -/** - * Special case event manager which handles events that should be shared among all client instances. - */ -public class EventsManagerCoordinator extends BaseEventsManager implements ISplitEventsManager, EventsManagerRegistry { - - private final ConcurrentMap mChildren = new ConcurrentHashMap<>(); - private final Object mEventLock = new Object(); - - @Override - public void notifyInternalEvent(SplitInternalEvent internalEvent) { - checkNotNull(internalEvent); - try { - mQueue.add(internalEvent); - } catch (IllegalStateException e) { - Logger.d("Internal events queue is full"); - } - } - - @Override - protected void triggerEventsWhenAreAvailable() { - try { - SplitInternalEvent event = mQueue.take(); //Blocking method (waiting if necessary until an element becomes available.) - synchronized (mEventLock) { - mTriggered.add(event); - switch (event) { - case SPLITS_UPDATED: - case RULE_BASED_SEGMENTS_UPDATED: - case SPLITS_FETCHED: - case SPLITS_LOADED_FROM_STORAGE: - case SPLIT_KILLED_NOTIFICATION: - case ENCRYPTION_MIGRATION_DONE: - for (ISplitEventsManager child : mChildren.values()) { - child.notifyInternalEvent(event); - } - break; - } - } - } catch (InterruptedException e) { - //Catching the InterruptedException that can be thrown by _queue.take() if interrupted while waiting - // for further information read https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ArrayBlockingQueue.html#take() - Logger.d(e.getMessage()); - } - } - - @Override - public void registerEventsManager(Key key, ISplitEventsManager splitEventsManager) { - mChildren.put(key, splitEventsManager); - - // Inform the newly registered events manager of any events that occurred prior to registration - propagateTriggeredEvents(splitEventsManager); - } - - @Override - public void unregisterEventsManager(Key key) { - mChildren.remove(key); - } - - private void propagateTriggeredEvents(ISplitEventsManager splitEventsManager) { - synchronized (mEventLock) { - for (SplitInternalEvent event : mTriggered) { - splitEventsManager.notifyInternalEvent(event); - } - } - } -} diff --git a/main/src/main/java/io/split/android/client/events/ISplitEventsManager.java b/main/src/main/java/io/split/android/client/events/ISplitEventsManager.java deleted file mode 100644 index 31b72ffd0..000000000 --- a/main/src/main/java/io/split/android/client/events/ISplitEventsManager.java +++ /dev/null @@ -1,6 +0,0 @@ -package io.split.android.client.events; - -public interface ISplitEventsManager { - - void notifyInternalEvent(SplitInternalEvent internalEvent); -} diff --git a/main/src/main/java/io/split/android/client/events/SplitEventTask.java b/main/src/main/java/io/split/android/client/events/SplitEventTask.java deleted file mode 100644 index 5a5dd6db9..000000000 --- a/main/src/main/java/io/split/android/client/events/SplitEventTask.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.split.android.client.events; - -import io.split.android.client.SplitClient; - -/** - * Created by sarrubia on 3/26/18. - */ - -public class SplitEventTask { - public void onPostExecution(SplitClient client) { - throw new SplitEventTaskMethodNotImplementedException(); - } - - public void onPostExecutionView(SplitClient client) { - throw new SplitEventTaskMethodNotImplementedException(); - } -} diff --git a/main/src/main/java/io/split/android/client/events/SplitEventsManager.java b/main/src/main/java/io/split/android/client/events/SplitEventsManager.java deleted file mode 100644 index 9fc54670b..000000000 --- a/main/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ /dev/null @@ -1,232 +0,0 @@ -package io.split.android.client.events; - -import static io.split.android.client.utils.Utils.checkNotNull; - -import androidx.annotation.VisibleForTesting; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import io.split.android.client.SplitClientConfig; -import io.split.android.client.events.executors.SplitEventExecutor; -import io.split.android.client.events.executors.SplitEventExecutorFactory; -import io.split.android.client.events.executors.SplitEventExecutorResources; -import io.split.android.client.events.executors.SplitEventExecutorResourcesImpl; -import io.split.android.client.service.executor.SplitTaskExecutor; -import io.split.android.client.utils.logger.Logger; - -public class SplitEventsManager extends BaseEventsManager implements ISplitEventsManager, ListenableEventsManager, Runnable { - - private final Map> mSubscriptions; - - private SplitEventExecutorResources mResources; - - private final Map mExecutionTimes; - - private final SplitTaskExecutor mSplitTaskExecutor; - - public SplitEventsManager(SplitClientConfig config, SplitTaskExecutor splitTaskExecutor) { - this(splitTaskExecutor, config.blockUntilReady()); - } - - public SplitEventsManager(SplitTaskExecutor splitTaskExecutor, final int blockUntilReady) { - super(); - mSplitTaskExecutor = splitTaskExecutor; - mSubscriptions = new ConcurrentHashMap<>(); - mExecutionTimes = new ConcurrentHashMap<>(); - mResources = new SplitEventExecutorResourcesImpl(); - registerMaxAllowedExecutionTimesPerEvent(); - - Runnable SDKReadyTimeout = new Runnable() { - @Override - public void run() { - try { - if (blockUntilReady > 0) { - Thread.sleep(blockUntilReady); - notifyInternalEvent(SplitInternalEvent.SDK_READY_TIMEOUT_REACHED); - } - } catch (InterruptedException e) { - //InterruptedException could be thrown by Thread.sleep trying to wait before check if sdk is ready - Logger.d("Waiting before to check if SDK is READY has been interrupted", e.getMessage()); - notifyInternalEvent(SplitInternalEvent.SDK_READY_TIMEOUT_REACHED); - } catch (Throwable e) { - Logger.d("Waiting before to check if SDK is READY interrupted ", e.getMessage()); - notifyInternalEvent(SplitInternalEvent.SDK_READY_TIMEOUT_REACHED); - } - } - }; - new Thread(SDKReadyTimeout).start(); - } - - @VisibleForTesting - public void setExecutionResources(SplitEventExecutorResources resources) { - mResources = resources; - } - - /** - * This method should register the allowed maximum times of event trigger - * EXAMPLE: SDK_READY should be triggered only once - */ - private void registerMaxAllowedExecutionTimesPerEvent() { - mExecutionTimes.put(SplitEvent.SDK_READY, 1); - mExecutionTimes.put(SplitEvent.SDK_READY_TIMED_OUT, 1); - mExecutionTimes.put(SplitEvent.SDK_READY_FROM_CACHE, 1); - mExecutionTimes.put(SplitEvent.SDK_UPDATE, -1); - } - - @Override - public SplitEventExecutorResources getExecutorResources() { - return mResources; - } - - @Override - public void notifyInternalEvent(SplitInternalEvent internalEvent) { - checkNotNull(internalEvent); - // Avoid adding to queue for fetched events if sdk is ready - // These events were added to handle updated event logic in this component - // and also to fix some issues when processing queue that made sdk update - // fire on init - - if ((internalEvent == SplitInternalEvent.SPLITS_FETCHED - || internalEvent == SplitInternalEvent.MY_SEGMENTS_FETCHED) && - isTriggered(SplitEvent.SDK_READY)) { - return; - } - try { - mQueue.add(internalEvent); - } catch (IllegalStateException e) { - Logger.d("Internal events queue is full"); - } - } - - public void register(SplitEvent event, SplitEventTask task) { - - checkNotNull(event); - checkNotNull(task); - - // If event is already triggered, execute the task - if (mExecutionTimes.containsKey(event) && mExecutionTimes.get(event) == 0) { - executeTask(event, task); - return; - } - - if (!mSubscriptions.containsKey(event)) { - mSubscriptions.put(event, new ArrayList<>()); - } - mSubscriptions.get(event).add(task); - } - - public boolean eventAlreadyTriggered(SplitEvent event) { - return isTriggered(event); - } - - private boolean wasTriggered(SplitInternalEvent event) { - return mTriggered.contains(event); - } - - @Override - protected void triggerEventsWhenAreAvailable() { - try { - SplitInternalEvent event = mQueue.take(); //Blocking method (waiting if necessary until an element becomes available.) - mTriggered.add(event); - switch (event) { - case SPLITS_UPDATED: - case MY_SEGMENTS_UPDATED: - case MY_LARGE_SEGMENTS_UPDATED: - case RULE_BASED_SEGMENTS_UPDATED: - if (isTriggered(SplitEvent.SDK_READY)) { - trigger(SplitEvent.SDK_UPDATE); - return; - } - triggerSdkReadyIfNeeded(); - break; - - case SPLITS_FETCHED: - case MY_SEGMENTS_FETCHED: - if (isTriggered(SplitEvent.SDK_READY)) { - return; - } - triggerSdkReadyIfNeeded(); - break; - - case SPLITS_LOADED_FROM_STORAGE: - case MY_SEGMENTS_LOADED_FROM_STORAGE: - case ATTRIBUTES_LOADED_FROM_STORAGE: - case ENCRYPTION_MIGRATION_DONE: - if (wasTriggered(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE) && - wasTriggered(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE) && - wasTriggered(SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE) && - wasTriggered(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE)) { - trigger(SplitEvent.SDK_READY_FROM_CACHE); - } - break; - - case SPLIT_KILLED_NOTIFICATION: - if (isTriggered(SplitEvent.SDK_READY)) { - trigger(SplitEvent.SDK_UPDATE); - } - break; - - case SDK_READY_TIMEOUT_REACHED: - if (!isTriggered(SplitEvent.SDK_READY)) { - trigger(SplitEvent.SDK_READY_TIMED_OUT); - } - break; - } - } catch (InterruptedException e) { - //Catching the InterruptedException that can be thrown by _queue.take() if interrupted while waiting - // for further information read https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ArrayBlockingQueue.html#take() - Logger.d(e.getMessage()); - } - } - - // MARK: Helper functions. - private boolean isTriggered(SplitEvent event) { - Integer times = mExecutionTimes.get(event); - return times != null ? times == 0 : false; - } - - private void triggerSdkReadyIfNeeded() { - if ((wasTriggered(SplitInternalEvent.MY_SEGMENTS_UPDATED) || wasTriggered(SplitInternalEvent.MY_SEGMENTS_FETCHED) || wasTriggered(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED)) && - (wasTriggered(SplitInternalEvent.SPLITS_UPDATED) || wasTriggered(SplitInternalEvent.SPLITS_FETCHED)) && - !isTriggered(SplitEvent.SDK_READY)) { - if (!isTriggered(SplitEvent.SDK_READY_FROM_CACHE)) { - trigger(SplitEvent.SDK_READY_FROM_CACHE); - } - trigger(SplitEvent.SDK_READY); - } - } - - private void trigger(SplitEvent event) { - // If executionTimes is zero, maximum executions has been reached - if (mExecutionTimes.get(event) == 0) { - return; - // If executionTimes is grater than zero, maximum executions decrease 1 - } else if (mExecutionTimes.get(event) > 0) { - mExecutionTimes.put(event, mExecutionTimes.get(event) - 1); - } //If executionTimes is lower than zero, execute it without limitation - if (event != null) { - Logger.d(event.name() + " event triggered"); - } - if (mSubscriptions.containsKey(event)) { - List toExecute = mSubscriptions.get(event); - if (toExecute != null) { - for (SplitEventTask task : toExecute) { - executeTask(event, task); - } - } - } - } - - private void executeTask(SplitEvent event, SplitEventTask task) { - if (task != null) { - SplitEventExecutor executor = SplitEventExecutorFactory.factory(mSplitTaskExecutor, event, task, mResources); - - if (executor != null) { - executor.execute(); - } - } - } -} 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 00c6a4f51..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,6 +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.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -257,18 +258,32 @@ public boolean isReady() { return mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY); } + @Override public void on(SplitEvent event, SplitEventTask task) { checkNotNull(event); checkNotNull(task); if (!event.equals(SplitEvent.SDK_READY_FROM_CACHE) && mEventsManager.eventAlreadyTriggered(event)) { - Logger.w(String.format("A listener was added for %s on the SDK, which has already fired and won’t be emitted again. The callback won’t be executed.", event)); + Logger.w(String.format("A listener was added for %s on the SDK, which has already fired and won't be emitted again. The callback won't be executed.", event)); return; } mEventsManager.register(event, task); } + @Override + public void addEventListener(@NonNull SplitEventListener listener) { + if (mIsClientDestroyed) { + Logger.w("Client has already been destroyed. Cannot add event listener"); + return; + } + if (listener == null) { + Logger.w("SDK Event Listener cannot be null"); + return; + } + mEventsManager.registerEventListener(listener); + } + @Override public boolean track(String trafficType, String eventType) { return false; diff --git a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitsStorage.java b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitsStorage.java index 68e80dd28..86603c14e 100644 --- a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitsStorage.java +++ b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitsStorage.java @@ -18,9 +18,13 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.ArrayList; + +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.dtos.Split; import io.split.android.client.events.EventsManagerCoordinator; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.ServiceConstants; import io.split.android.client.storage.legacy.FileStorage; import io.split.android.client.storage.splits.ProcessedSplitChange; @@ -215,9 +219,12 @@ private void loadSplits() { } } if (!content.equals(mLastContentLoaded)) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + // Cache path metadata: initialCacheLoad=false (loaded from file), timestamp=null for localhost + EventMetadata cacheMetadata = EventMetadataHelpers.createReadyMetadata(null, false); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, cacheMetadata); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + EventMetadata updateMetadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, updateMetadata); } mLastContentLoaded = content; } @@ -258,4 +265,9 @@ private void copyFileResourceToDataFolder(String fileName, FileStorage fileStora Logger.e(e.getLocalizedMessage()); } } + + private EventMetadata createUpdatedFlagsMetadata() { + List updatedSplitNames = new ArrayList<>(mInMemorySplits.keySet()); + return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); + } } diff --git a/main/src/main/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImpl.java b/main/src/main/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImpl.java index 2fd5cbded..cc33debfd 100644 --- a/main/src/main/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImpl.java +++ b/main/src/main/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImpl.java @@ -1,5 +1,7 @@ package io.split.android.client.localhost.shared; +import androidx.annotation.VisibleForTesting; + import io.split.android.client.FlagSetsFilter; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; @@ -31,6 +33,7 @@ public class LocalhostSplitClientContainerImpl extends BaseSplitClientContainer private final EventsManagerCoordinator mEventsManagerCoordinator; private final SplitTaskExecutor mSplitTaskExecutor; private final FlagSetsFilter mFlagSetsFilter; + private final SplitEventsManagerFactory mEventsManagerFactory; public LocalhostSplitClientContainerImpl(LocalhostSplitFactory splitFactory, SplitClientConfig config, @@ -42,6 +45,24 @@ public LocalhostSplitClientContainerImpl(LocalhostSplitFactory splitFactory, EventsManagerCoordinator eventsManagerCoordinator, SplitTaskExecutor taskExecutor, FlagSetsFilter flagSetsFilter) { + this(splitFactory, config, splitsStorage, splitParser, attributesManagerFactory, + attributesMerger, telemetryStorageProducer, eventsManagerCoordinator, + taskExecutor, flagSetsFilter, + new DefaultSplitEventsManagerFactory(taskExecutor, config)); + } + + @VisibleForTesting + LocalhostSplitClientContainerImpl(LocalhostSplitFactory splitFactory, + SplitClientConfig config, + SplitsStorage splitsStorage, + SplitParser splitParser, + AttributesManagerFactory attributesManagerFactory, + AttributesMerger attributesMerger, + TelemetryStorageProducer telemetryStorageProducer, + EventsManagerCoordinator eventsManagerCoordinator, + SplitTaskExecutor taskExecutor, + FlagSetsFilter flagSetsFilter, + SplitEventsManagerFactory eventsManagerFactory) { mSplitFactory = splitFactory; mConfig = config; mSplitStorage = splitsStorage; @@ -52,13 +73,14 @@ public LocalhostSplitClientContainerImpl(LocalhostSplitFactory splitFactory, mEventsManagerCoordinator = eventsManagerCoordinator; mSplitTaskExecutor = taskExecutor; mFlagSetsFilter = flagSetsFilter; + mEventsManagerFactory = eventsManagerFactory; } @Override protected void createNewClient(Key key) { - SplitEventsManager eventsManager = new SplitEventsManager(mSplitTaskExecutor, mConfig.blockUntilReady()); + SplitEventsManager eventsManager = mEventsManagerFactory.create(); eventsManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE); - eventsManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_FETCHED); + eventsManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); eventsManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); AttributesStorageImpl attributesStorage = new AttributesStorageImpl(); @@ -88,4 +110,19 @@ protected void createNewClient(Key key) { public void destroy() { // No-op } + + private static class DefaultSplitEventsManagerFactory implements SplitEventsManagerFactory { + private final SplitTaskExecutor mTaskExecutor; + private final int mBlockUntilReady; + + DefaultSplitEventsManagerFactory(SplitTaskExecutor taskExecutor, SplitClientConfig config) { + mTaskExecutor = taskExecutor; + mBlockUntilReady = config.blockUntilReady(); + } + + @Override + public SplitEventsManager create() { + return new SplitEventsManager(mTaskExecutor, mBlockUntilReady); + } + } } diff --git a/main/src/main/java/io/split/android/client/localhost/shared/SplitEventsManagerFactory.java b/main/src/main/java/io/split/android/client/localhost/shared/SplitEventsManagerFactory.java new file mode 100644 index 000000000..1dfb51404 --- /dev/null +++ b/main/src/main/java/io/split/android/client/localhost/shared/SplitEventsManagerFactory.java @@ -0,0 +1,17 @@ +package io.split.android.client.localhost.shared; + +import io.split.android.client.events.SplitEventsManager; + +/** + * Factory interface for creating SplitEventsManager instances. + * Package-local interface to allow testing by injecting mock implementations. + */ +interface SplitEventsManagerFactory { + /** + * Creates a new SplitEventsManager instance. + * + * @return a new SplitEventsManager instance + */ + SplitEventsManager create(); +} + diff --git a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java index 3b2a4be33..f41271796 100644 --- a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java +++ b/main/src/main/java/io/split/android/client/network/HttpClientImpl.java @@ -159,6 +159,12 @@ public void close() { } + @VisibleForTesting + @Nullable + SSLSocketFactory getSslSocketFactory() { + return mSslSocketFactory; + } + private Proxy initializeProxy(HttpProxy proxy) { if (proxy != null) { return new Proxy( @@ -279,7 +285,7 @@ public HttpClient build() { if (mProxy != null) { mSslSocketFactory = createSslSocketFactoryFromProxy(mProxy); - } else { + } else if (LegacyTlsUpdater.couldBeOld()) { try { mSslSocketFactory = new Tls12OnlySocketFactory(); } catch (NoSuchAlgorithmException | KeyManagementException e) { @@ -287,6 +293,9 @@ public HttpClient build() { } catch (Exception e) { Logger.e("Unknown TLS v12 error: " + e.getLocalizedMessage()); } + } else { + // Use platform default + mSslSocketFactory = null; } } diff --git a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java index 2158985aa..5141b887d 100644 --- a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java +++ b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java @@ -18,6 +18,8 @@ import io.split.android.client.dtos.SegmentsChange; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.network.SplitHttpHeadersBuilder; import io.split.android.client.service.ServiceConstants; import io.split.android.client.service.executor.SplitTask; @@ -50,7 +52,6 @@ public class MySegmentsSyncTask implements SplitTask { private final SplitTaskType mTaskType; private final SplitInternalEvent mUpdateEvent; - private final SplitInternalEvent mFetchedEvent; private final OperationType mTelemetryOperationType; private final boolean mAvoidCache; @@ -105,7 +106,6 @@ public MySegmentsSyncTask(@NonNull HttpFetcher mySegmentsFetc mTelemetryRuntimeProducer = checkNotNull(telemetryRuntimeProducer); mTaskType = config.getTaskType(); mUpdateEvent = config.getUpdateEvent(); - mFetchedEvent = config.getFetchedEvent(); mTelemetryOperationType = config.getTelemetryOperationType(); mTargetSegmentsChangeNumber = targetSegmentsChangeNumber; mTargetLargeSegmentsChangeNumber = targetLargeSegmentsChangeNumber; @@ -265,29 +265,28 @@ private void fireMySegmentsUpdatedIfNeeded(UpdateSegmentsResult segmentsResult, return; } - // MY_SEGMENTS_UPDATED event when segments have changed - boolean segmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(segmentsResult.oldSegments, segmentsResult.newSegments); - boolean largeSegmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(largeSegmentsResult.oldSegments, largeSegmentsResult.newSegments); + // Check for actual updates and fire updated events BEFORE sync complete. + // This order is important: if we fire MEMBERSHIPS_SYNC_COMPLETE first, it may trigger SDK_READY, + // and then the *_UPDATED events would immediately trigger SDK_UPDATE during initial sync. + // By firing *_UPDATED first (while SDK_READY hasn't triggered yet), they won't trigger SDK_UPDATE. + List changedSegments = mMySegmentsChangeChecker.getChangedSegments(segmentsResult.oldSegments, segmentsResult.newSegments); + List changedLargeSegments = mMySegmentsChangeChecker.getChangedSegments(largeSegmentsResult.oldSegments, largeSegmentsResult.newSegments); - if (segmentsHaveChanged) { + if (!changedSegments.isEmpty()) { Logger.v("New segments: " + segmentsResult.newSegments); + mEventsManager.notifyInternalEvent(mUpdateEvent, EventMetadataHelpers.createUpdatedSegmentsMetadata()); } - if (largeSegmentsHaveChanged) { + if (!changedLargeSegments.isEmpty()) { Logger.v("New large segments: " + largeSegmentsResult.newSegments); + mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } - if (segmentsHaveChanged) { - mEventsManager.notifyInternalEvent(mUpdateEvent); - } else { - // MY_LARGE_SEGMENTS_UPDATED event when large segments have changed - if (largeSegmentsHaveChanged) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); - } else { - // otherwise, MY_SEGMENTS_FETCHED event - mEventsManager.notifyInternalEvent(mFetchedEvent); - } - } + // Fire sync complete AFTER update events. This ensures SDK_READY triggers after + // all *_UPDATED events have been processed (which won't trigger SDK_UPDATE because + // SDK_READY's prerequisite for SDK_UPDATE isn't met yet). + mEventsManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); } private static class UpdateSegmentsResult { diff --git a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java index 210fb2d4e..77ddd812d 100644 --- a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java +++ b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java @@ -11,20 +11,16 @@ public class MySegmentsSyncTaskConfig { private static final MySegmentsSyncTaskConfig MY_SEGMENTS_TASK_CONFIG = new MySegmentsSyncTaskConfig( SplitTaskType.MY_SEGMENTS_SYNC, SplitInternalEvent.MY_SEGMENTS_UPDATED, - SplitInternalEvent.MY_SEGMENTS_FETCHED, OperationType.MY_SEGMENT); private final SplitTaskType mTaskType; private final SplitInternalEvent mUpdateEvent; - private final SplitInternalEvent mFetchedEvent; private final OperationType mTelemetryOperationType; private MySegmentsSyncTaskConfig(@NonNull SplitTaskType taskType, @NonNull SplitInternalEvent updateEvent, - @NonNull SplitInternalEvent fetchedEvent, @NonNull OperationType telemetryOperationType) { mTaskType = taskType; mUpdateEvent = updateEvent; - mFetchedEvent = fetchedEvent; mTelemetryOperationType = telemetryOperationType; } @@ -36,10 +32,6 @@ SplitInternalEvent getUpdateEvent() { return mUpdateEvent; } - SplitInternalEvent getFetchedEvent() { - return mFetchedEvent; - } - OperationType getTelemetryOperationType() { return mTelemetryOperationType; } diff --git a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTask.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTask.java index 3f8dc1260..cf1257ca4 100644 --- a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTask.java @@ -9,6 +9,7 @@ import io.split.android.client.dtos.SegmentsChange; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskType; @@ -96,7 +97,7 @@ public SplitTaskExecutionInfo remove() { private void updateAndNotify(Set segments) { mMySegmentsStorage.set(SegmentsChange.create(segments, mChangeNumber)); - mEventsManager.notifyInternalEvent(mUpdateEvent); + mEventsManager.notifyInternalEvent(mUpdateEvent, EventMetadataHelpers.createUpdatedSegmentsMetadata()); } private void logError(String message) { diff --git a/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java b/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java index 9e92523e0..72c05e4a2 100644 --- a/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java @@ -7,6 +7,7 @@ import io.split.android.client.dtos.RuleBasedSegment; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskType; @@ -41,7 +42,8 @@ public SplitTaskExecutionInfo execute() { boolean triggerSdkUpdate = mRuleBasedSegmentStorage.update(processedChange.getActive(), processedChange.getArchived(), mChangeNumber, null); if (triggerSdkUpdate) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } Logger.v("Updated rule based segment"); diff --git a/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java b/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java index e02ccec56..6fb8fc8dc 100644 --- a/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java @@ -7,6 +7,7 @@ import io.split.android.client.dtos.RuleBasedSegment; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskType; @@ -43,7 +44,8 @@ public SplitTaskExecutionInfo execute() { boolean triggerSdkUpdate = mRuleBasedSegmentStorage.update(processedChange.getActive(), processedChange.getArchived(), mChangeNumber, null); if (triggerSdkUpdate) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } Logger.v("Updated rule based segment"); diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java index 4198cd401..2c86042b0 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java @@ -1,12 +1,17 @@ package io.split.android.client.service.splits; +import static io.split.android.client.service.splits.SplitsSyncHelper.extractFlagNames; import static io.split.android.client.utils.Utils.checkNotNull; import androidx.annotation.NonNull; +import java.util.List; + +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.dtos.Split; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskType; @@ -47,7 +52,8 @@ public SplitTaskExecutionInfo execute() { boolean triggerSdkUpdate = mSplitsStorage.update(processedSplitChange, null); if (triggerSdkUpdate) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + EventMetadata metadata = createUpdatedFlagsMetadata(processedSplitChange); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); } mTelemetryRuntimeProducer.recordUpdatesFromSSE(UpdatesFromSSEEnum.SPLITS); @@ -59,4 +65,9 @@ public SplitTaskExecutionInfo execute() { return SplitTaskExecutionInfo.error(SplitTaskType.SPLITS_SYNC); } } + + private EventMetadata createUpdatedFlagsMetadata(ProcessedSplitChange processedSplitChange) { + List updatedSplitNames = extractFlagNames(processedSplitChange); + return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); + } } diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java index 0468af7d3..001d4ec04 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java @@ -4,9 +4,13 @@ import androidx.annotation.NonNull; +import java.util.Collections; + +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.dtos.Split; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskType; @@ -53,7 +57,9 @@ public SplitTaskExecutionInfo execute() { splitToKill.changeNumber = mKilledSplit.changeNumber; mSplitsStorage.updateWithoutChecks(splitToKill); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION); + EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata( + Collections.singletonList(mKilledSplit.name)); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION, metadata); } catch (Exception e) { logError("Unknown error while updating killed feature flag: " + e.getLocalizedMessage()); return SplitTaskExecutionInfo.error(SplitTaskType.SPLIT_KILL); diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java index 8b53774fd..705331080 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java @@ -7,14 +7,19 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import io.split.android.client.dtos.RuleBasedSegment; import io.split.android.client.dtos.RuleBasedSegmentChange; +import io.split.android.client.dtos.Split; import io.split.android.client.dtos.SplitChange; import io.split.android.client.dtos.TargetingRulesChange; import io.split.android.client.network.SplitHttpHeadersBuilder; @@ -26,6 +31,7 @@ import io.split.android.client.service.http.HttpStatus; import io.split.android.client.service.rules.ProcessedRuleBasedSegmentChange; import io.split.android.client.service.rules.RuleBasedSegmentChangeProcessor; +import io.split.android.client.storage.splits.ProcessedSplitChange; import io.split.android.client.service.sseclient.BackoffCounter; import io.split.android.client.service.sseclient.ReconnectBackoffCounter; import io.split.android.client.storage.general.GeneralInfoStorage; @@ -53,6 +59,10 @@ public class SplitsSyncHelper { private final OutdatedSplitProxyHandler mOutdatedSplitProxyHandler; private final ExecutorService mExecutor; private final TargetingRulesCache mTargetingRulesCache; + private final AtomicReference mLastProcessedSplitChange = new AtomicReference<>(); + private final AtomicReference mLastProcessedRbsChange = new AtomicReference<>(); + private boolean mSplitsHaveChanged; + private boolean mRuleBasedSegmentsHaveChanged; public SplitsSyncHelper(@NonNull HttpFetcher splitFetcher, @NonNull SplitsStorage splitsStorage, @@ -136,6 +146,8 @@ public SplitTaskExecutionInfo sync(SinceChangeNumbers till, boolean clearBeforeU } private SplitTaskExecutionInfo sync(SinceChangeNumbers till, boolean clearBeforeUpdate, boolean avoidCache, boolean resetChangeNumber, int onDemandFetchBackoffMaxRetries) { + mSplitsHaveChanged = false; + mRuleBasedSegmentsHaveChanged = false; try { mOutdatedSplitProxyHandler.performProxyCheck(); if (mOutdatedSplitProxyHandler.isRecoveryMode()) { @@ -302,16 +314,87 @@ public static void fetchForFreshInstallCache(String currentSpec, } private void updateStorage(boolean clearBeforeUpdate, SplitChange splitChange, RuleBasedSegmentChange ruleBasedSegmentChange) { + if (splitChange != null && splitChange.splits != null && !splitChange.splits.isEmpty()) { + mSplitsHaveChanged = true; + } + + if (ruleBasedSegmentChange != null && ruleBasedSegmentChange.getSegments() != null && !ruleBasedSegmentChange.getSegments().isEmpty()) { + mRuleBasedSegmentsHaveChanged = true; + } + if (clearBeforeUpdate) { mSplitsStorage.clear(); mRuleBasedSegmentStorage.clear(); } - mSplitsStorage.update(mSplitChangeProcessor.process(splitChange), mExecutor); + ProcessedSplitChange processedSplitChange = mSplitChangeProcessor.process(splitChange); + if (hasFlagUpdates(processedSplitChange)) { + mLastProcessedSplitChange.set(processedSplitChange); + } + mSplitsStorage.update(processedSplitChange, mExecutor); updateRbsStorage(ruleBasedSegmentChange); } + private boolean hasFlagUpdates(@Nullable ProcessedSplitChange processedSplitChange) { + if (processedSplitChange == null) { + return false; + } + List activeSplits = processedSplitChange.getActiveSplits(); + if (activeSplits != null && !activeSplits.isEmpty()) { + return true; + } + List archivedSplits = processedSplitChange.getArchivedSplits(); + return archivedSplits != null && !archivedSplits.isEmpty(); + } + + /** + * Gets the list of updated flag names from the last sync operation. + * This includes both active (added/modified) and archived (removed) splits. + * + * @return list of updated flag names, or empty list if no updates occurred + */ + @NonNull + public List getLastUpdatedFlagNames() { + ProcessedSplitChange lastChange = mLastProcessedSplitChange.get(); + if (lastChange == null) { + return Collections.emptyList(); + } + return extractFlagNames(lastChange); + } + + /** + * Extracts split names from a ProcessedSplitChange. + * This includes both active (added/modified) and archived (removed) splits. + * + * @param processedSplitChange the processed split change + * @return list of split names, or empty list if change is null + */ + @NonNull + public static List extractFlagNames(@Nullable ProcessedSplitChange processedSplitChange) { + if (processedSplitChange == null) { + return Collections.emptyList(); + } + + List updatedNames = new ArrayList<>(); + if (processedSplitChange.getActiveSplits() != null) { + for (Split split : processedSplitChange.getActiveSplits()) { + if (split != null && split.name != null) { + updatedNames.add(split.name); + } + } + } + if (processedSplitChange.getArchivedSplits() != null) { + for (Split split : processedSplitChange.getArchivedSplits()) { + if (split != null && split.name != null) { + updatedNames.add(split.name); + } + } + } + return updatedNames; + } + private void updateRbsStorage(RuleBasedSegmentChange ruleBasedSegmentChange) { ProcessedRuleBasedSegmentChange change = mRuleBasedSegmentChangeProcessor.process(ruleBasedSegmentChange.getSegments(), ruleBasedSegmentChange.getTill()); + mLastProcessedRbsChange.set(change); mRuleBasedSegmentStorage.update(change.getActive(), change.getArchived(), change.getChangeNumber(), mExecutor); } @@ -363,6 +446,14 @@ public String toString() { } } + public boolean splitsHaveChanged() { + return mSplitsHaveChanged; + } + + public boolean ruleBasedSegmentsHaveChanged() { + return mRuleBasedSegmentsHaveChanged; + } + private enum CdnByPassType { NONE, FLAGS, diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncTask.java index 2cb35e578..dfab23fff 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncTask.java @@ -5,8 +5,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.List; + +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.events.ISplitEventsManager; +import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.ServiceConstants; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -93,14 +98,37 @@ public SplitTaskExecutionInfo execute() { } private void notifyInternalEvent(long storedChangeNumber) { - if (mEventsManager != null) { - SplitInternalEvent event = SplitInternalEvent.SPLITS_FETCHED; - if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill())) { - event = SplitInternalEvent.SPLITS_UPDATED; - } + if (mEventsManager == null) { + return; + } - mEventsManager.notifyInternalEvent(event); + // Fire *_UPDATED events BEFORE sync complete. This order is important: + // if we fire TARGETING_RULES_SYNC_COMPLETE first, it may trigger SDK_READY, + // and then the *_UPDATED events would immediately trigger SDK_UPDATE during initial sync. + // By firing *_UPDATED first (while SDK_READY hasn't triggered yet), they won't trigger SDK_UPDATE. + // + // Use else-if logic: if splits changed, only fire SPLITS_UPDATED (FLAGS_UPDATE). + // RBS changes are only relevant when flags DIDN'T change. + if (mSplitsSyncHelper.splitsHaveChanged()) { + EventMetadata metadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); + } else if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } + + // Fire sync complete AFTER update events. This ensures SDK_READY triggers after + // all *_UPDATED events have been processed (which won't trigger SDK_UPDATE because + // SDK_READY's prerequisite for SDK_UPDATE isn't met yet). + boolean cacheAlreadyLoaded = mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE); + EventMetadata syncMetadata = EventMetadataHelpers.createSyncCompleteMetadata( + cacheAlreadyLoaded, mSplitsStorage.getUpdateTimestamp()); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); + } + + private EventMetadata createUpdatedFlagsMetadata() { + List updatedSplitNames = mSplitsSyncHelper.getLastUpdatedFlagNames(); + return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); } private boolean splitsFilterHasChanged(String storedSplitsFilterQueryString) { diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java index 8f0a7cf61..d14600725 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java @@ -6,8 +6,13 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import java.util.List; + +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.events.ISplitEventsManager; +import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.ServiceConstants; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -67,16 +72,34 @@ public SplitTaskExecutionInfo execute() { SplitTaskExecutionInfo result = mSplitsSyncHelper.sync(new SplitsSyncHelper.SinceChangeNumbers(mChangeNumber, mRbsChangeNumber), ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); if (result.getStatus() == SplitTaskExecutionStatus.SUCCESS) { - SplitInternalEvent event = SplitInternalEvent.SPLITS_FETCHED; - if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill()) || - mChangeChecker.changeNumberIsNewer(storedRbsChangeNumber, mRuleBasedSegmentStorage.getChangeNumber())) { - event = SplitInternalEvent.SPLITS_UPDATED; + // Fire *_UPDATED events BEFORE sync complete. This order is important: + // if we fire TARGETING_RULES_SYNC_COMPLETE first, it may trigger SDK_READY, + // and then the *_UPDATED events would immediately trigger SDK_UPDATE during initial sync. + // + // Use If splits changed, only fire SPLITS_UPDATED (FLAGS_UPDATE). + // RBS changes are only relevant when flags DIDN'T change. + if (mSplitsSyncHelper.splitsHaveChanged()) { + EventMetadata metadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); + } else if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } - mEventsManager.notifyInternalEvent(event); + + // Fire sync complete AFTER update events. + boolean cacheAlreadyLoaded = mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE); + EventMetadata syncMetadata = EventMetadataHelpers.createSyncCompleteMetadata( + cacheAlreadyLoaded, mSplitsStorage.getUpdateTimestamp()); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); } return result; } + private EventMetadata createUpdatedFlagsMetadata() { + List updatedSplitNames = mSplitsSyncHelper.getLastUpdatedFlagNames(); + return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); + } + @VisibleForTesting public void setChangeChecker(SplitsChangeChecker changeChecker) { mChangeChecker = changeChecker; diff --git a/main/src/main/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImpl.java b/main/src/main/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImpl.java index b7675e727..0e552ebb8 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImpl.java +++ b/main/src/main/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImpl.java @@ -14,6 +14,7 @@ import io.split.android.client.SplitClientConfig; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTaskBatchItem; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutionListener; @@ -24,6 +25,7 @@ import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; import io.split.android.client.service.sseclient.feedbackchannel.PushStatusEvent; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; +import io.split.android.client.storage.splits.SplitsStorage; public class FeatureFlagsSynchronizerImpl implements FeatureFlagsSynchronizer { @@ -48,6 +50,18 @@ public FeatureFlagsSynchronizerImpl(@NonNull SplitClientConfig splitClientConfig @NonNull ISplitEventsManager splitEventsManager, @NonNull RetryBackoffCounterTimerFactory retryBackoffCounterTimerFactory, @Nullable PushManagerEventBroadcaster pushManagerEventBroadcaster) { + this(splitClientConfig, taskExecutor, splitSingleThreadTaskExecutor, splitTaskFactory, + splitEventsManager, retryBackoffCounterTimerFactory, pushManagerEventBroadcaster, null); + } + + public FeatureFlagsSynchronizerImpl(@NonNull SplitClientConfig splitClientConfig, + @NonNull SplitTaskExecutor taskExecutor, + @NonNull SplitTaskExecutor splitSingleThreadTaskExecutor, + @NonNull SplitTaskFactory splitTaskFactory, + @NonNull ISplitEventsManager splitEventsManager, + @NonNull RetryBackoffCounterTimerFactory retryBackoffCounterTimerFactory, + @Nullable PushManagerEventBroadcaster pushManagerEventBroadcaster, + @Nullable SplitsStorage splitsStorage) { mTaskExecutor = checkNotNull(taskExecutor); mSplitsTaskExecutor = splitSingleThreadTaskExecutor; @@ -80,8 +94,15 @@ public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { } mSplitsSyncRetryTimer.setTask(mSplitTaskFactory.createSplitsSyncTask(true), mSplitsSyncListener); + + // Create metadata provider for cache path. initialCacheLoad=false because this listener + // is only invoked when splits are successfully loaded from local storage (cache exists). + LoadLocalDataListener.MetadataProvider cacheMetadataProvider = splitsStorage != null + ? () -> EventMetadataHelpers.createReadyMetadata(splitsStorage.getUpdateTimestamp(), false) + : null; + mLoadLocalSplitsListener = new LoadLocalDataListener( - splitEventsManager, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); + splitEventsManager, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, cacheMetadataProvider); } @Override diff --git a/main/src/main/java/io/split/android/client/service/synchronizer/LoadLocalDataListener.java b/main/src/main/java/io/split/android/client/service/synchronizer/LoadLocalDataListener.java index 1490ef929..be1ccc999 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/LoadLocalDataListener.java +++ b/main/src/main/java/io/split/android/client/service/synchronizer/LoadLocalDataListener.java @@ -3,7 +3,9 @@ import static io.split.android.client.utils.Utils.checkNotNull; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -12,19 +14,37 @@ public class LoadLocalDataListener implements SplitTaskExecutionListener { + /** + * Functional interface for providing metadata when the event is fired. + */ + public interface MetadataProvider { + @Nullable + EventMetadata getMetadata(); + } + private final ISplitEventsManager mSplitEventsManager; private final SplitInternalEvent mEventToFire; + @Nullable + private final MetadataProvider mMetadataProvider; public LoadLocalDataListener(ISplitEventsManager splitEventsManager, SplitInternalEvent eventToFire) { + this(splitEventsManager, eventToFire, null); + } + + public LoadLocalDataListener(ISplitEventsManager splitEventsManager, + SplitInternalEvent eventToFire, + @Nullable MetadataProvider metadataProvider) { mSplitEventsManager = checkNotNull(splitEventsManager); mEventToFire = checkNotNull(eventToFire); + mMetadataProvider = metadataProvider; } @Override public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { if (taskInfo.getStatus().equals(SplitTaskExecutionStatus.SUCCESS)) { - mSplitEventsManager.notifyInternalEvent(mEventToFire); + EventMetadata metadata = mMetadataProvider != null ? mMetadataProvider.getMetadata() : null; + mSplitEventsManager.notifyInternalEvent(mEventToFire, metadata); } } } diff --git a/main/src/main/java/io/split/android/client/service/synchronizer/MySegmentsChangeChecker.java b/main/src/main/java/io/split/android/client/service/synchronizer/MySegmentsChangeChecker.java index 16fb4cb73..780b70adb 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/MySegmentsChangeChecker.java +++ b/main/src/main/java/io/split/android/client/service/synchronizer/MySegmentsChangeChecker.java @@ -1,12 +1,36 @@ package io.split.android.client.service.synchronizer; -import java.util.Collections; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class MySegmentsChangeChecker { - public boolean mySegmentsHaveChanged(final List oldSegments, final List newSegments) { - Collections.sort(oldSegments); - Collections.sort(newSegments); - return !oldSegments.equals(newSegments); + + /** + * Computes and returns the list of changed segment names (added + removed) between old and new segments. + * An empty list means no changes occurred. + * + * @param oldSegments the previous list of segment names + * @param newSegments the new list of segment names + * @return list of segment names that were either added or removed (empty if no changes) + */ + public List getChangedSegments(final List oldSegments, final List newSegments) { + Set oldSet = new HashSet<>(oldSegments); + Set newSet = new HashSet<>(newSegments); + + // Added segments: in new but not in old + Set added = new HashSet<>(newSet); + added.removeAll(oldSet); + + // Removed segments: in old but not in new + Set removed = new HashSet<>(oldSet); + removed.removeAll(newSet); + + // Combined changed segments + Set changed = new HashSet<>(added); + changed.addAll(removed); + + return new ArrayList<>(changed); } } diff --git a/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java b/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java index ddfd906e8..abf55e7fe 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java +++ b/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java @@ -31,6 +31,7 @@ import io.split.android.client.service.synchronizer.mysegments.MySegmentsSynchronizerRegistryImpl; import io.split.android.client.shared.UserConsent; import io.split.android.client.storage.common.StoragePusher; +import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.model.EventsDataRecordsEnum; import io.split.android.client.telemetry.model.streaming.SyncModeUpdateStreamingEvent; import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; @@ -69,7 +70,8 @@ public SynchronizerImpl(@NonNull SplitClientConfig splitClientConfig, @NonNull StrategyImpressionManager impressionManager, @NonNull StoragePusher eventsStorage, @NonNull ISplitEventsManager eventsManagerCoordinator, - @Nullable PushManagerEventBroadcaster pushManagerEventBroadcaster) { + @Nullable PushManagerEventBroadcaster pushManagerEventBroadcaster, + @Nullable SplitsStorage splitsStorage) { this(splitClientConfig, taskExecutor, splitSingleThreadTaskExecutor, @@ -85,7 +87,8 @@ public SynchronizerImpl(@NonNull SplitClientConfig splitClientConfig, splitTaskFactory, eventsManagerCoordinator, retryBackoffCounterTimerFactory, - pushManagerEventBroadcaster + pushManagerEventBroadcaster, + splitsStorage ), eventsStorage); } diff --git a/main/src/main/java/io/split/android/client/shared/SplitClientContainerImpl.java b/main/src/main/java/io/split/android/client/shared/SplitClientContainerImpl.java index 43d81074b..3ec86c8fc 100644 --- a/main/src/main/java/io/split/android/client/shared/SplitClientContainerImpl.java +++ b/main/src/main/java/io/split/android/client/shared/SplitClientContainerImpl.java @@ -145,7 +145,7 @@ public void remove(Key key) { @Override public void createNewClient(Key key) { - SplitEventsManager eventsManager = new SplitEventsManager(mConfig, mSplitClientEventTaskExecutor); + SplitEventsManager eventsManager = new SplitEventsManager(mSplitClientEventTaskExecutor, mConfig.blockUntilReady()); MySegmentsTaskFactory mySegmentsTaskFactory = getMySegmentsTaskFactory(key, eventsManager); SplitClient client = mSplitClientFactory.getClient(key, mySegmentsTaskFactory, eventsManager, mDefaultMatchingKey.equals(key.matchingKey())); diff --git a/main/src/main/java/io/split/android/client/storage/db/MyLargeSegmentDao.java b/main/src/main/java/io/split/android/client/storage/db/MyLargeSegmentDao.java index c770c753a..7538a9970 100644 --- a/main/src/main/java/io/split/android/client/storage/db/MyLargeSegmentDao.java +++ b/main/src/main/java/io/split/android/client/storage/db/MyLargeSegmentDao.java @@ -27,4 +27,8 @@ public interface MyLargeSegmentDao extends SegmentDao { @Override @Query("SELECT user_key, segment_list, updated_at FROM " + TABLE_NAME) List getAll(); + + @Override + @Query("DELETE FROM " + TABLE_NAME) + void deleteAll(); } diff --git a/main/src/main/java/io/split/android/client/storage/db/MySegmentDao.java b/main/src/main/java/io/split/android/client/storage/db/MySegmentDao.java index b4c6ef5d7..7ab6f1d42 100644 --- a/main/src/main/java/io/split/android/client/storage/db/MySegmentDao.java +++ b/main/src/main/java/io/split/android/client/storage/db/MySegmentDao.java @@ -27,4 +27,8 @@ public interface MySegmentDao extends SegmentDao { @Override @Query("SELECT user_key, segment_list, updated_at FROM " + TABLE_NAME) List getAll(); + + @Override + @Query("DELETE FROM " + TABLE_NAME) + void deleteAll(); } diff --git a/main/src/main/java/io/split/android/client/storage/db/SegmentDao.java b/main/src/main/java/io/split/android/client/storage/db/SegmentDao.java index 6f6e45a66..45d76800c 100644 --- a/main/src/main/java/io/split/android/client/storage/db/SegmentDao.java +++ b/main/src/main/java/io/split/android/client/storage/db/SegmentDao.java @@ -11,4 +11,6 @@ public interface SegmentDao { T getByUserKey(String userKey); List getAll(); + + void deleteAll(); } diff --git a/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java b/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java index 009d3d6f6..bf26f739c 100644 --- a/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java +++ b/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java @@ -50,6 +50,8 @@ public void loadLocal() { @Override public void clear() { synchronized (lock) { + mPersistentMySegmentsStorage.clear(); + // Clear in-memory segments for keys in mStorageMap for (MySegmentsStorage mySegmentsStorage : mStorageMap.values()) { mySegmentsStorage.clear(); } diff --git a/main/src/main/java/io/split/android/client/storage/mysegments/PersistentMySegmentsStorage.java b/main/src/main/java/io/split/android/client/storage/mysegments/PersistentMySegmentsStorage.java index 03ad60ae8..01568eebe 100644 --- a/main/src/main/java/io/split/android/client/storage/mysegments/PersistentMySegmentsStorage.java +++ b/main/src/main/java/io/split/android/client/storage/mysegments/PersistentMySegmentsStorage.java @@ -9,4 +9,6 @@ public interface PersistentMySegmentsStorage { SegmentsChange getSnapshot(String userKey); void close(); + + void clear(); } diff --git a/main/src/main/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorage.java b/main/src/main/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorage.java index 8d2da826d..61132b23c 100644 --- a/main/src/main/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorage.java +++ b/main/src/main/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorage.java @@ -57,6 +57,11 @@ public SegmentsChange getSnapshot(String userKey) { public void close() { } + @Override + public void clear() { + mDao.deleteAll(); + } + private SegmentsChange getMySegmentsFromEntity(SegmentEntity entity) { if (entity == null || Utils.isNullOrEmpty(entity.getSegmentList())) { return createEmpty(); diff --git a/main/src/main/java/io/split/android/client/storage/splits/ProcessedSplitChange.java b/main/src/main/java/io/split/android/client/storage/splits/ProcessedSplitChange.java index 508731e23..9946442de 100644 --- a/main/src/main/java/io/split/android/client/storage/splits/ProcessedSplitChange.java +++ b/main/src/main/java/io/split/android/client/storage/splits/ProcessedSplitChange.java @@ -1,5 +1,7 @@ package io.split.android.client.storage.splits; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import io.split.android.client.dtos.Split; @@ -11,8 +13,9 @@ public class ProcessedSplitChange { private final long updateTimestamp; public ProcessedSplitChange(List activeSplits, List archivedSplits, long changeNumber, long updateTimestamp) { - this.activeSplits = activeSplits; - this.archivedSplits = archivedSplits; + // Create defensive copies to ensure thread safety + this.activeSplits = activeSplits != null ? Collections.unmodifiableList(new ArrayList<>(activeSplits)) : Collections.emptyList(); + this.archivedSplits = archivedSplits != null ? Collections.unmodifiableList(new ArrayList<>(archivedSplits)) : Collections.emptyList(); this.changeNumber = changeNumber; this.updateTimestamp = updateTimestamp; } diff --git a/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java b/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java index e1f7f6811..88cd686ee 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java @@ -64,7 +64,7 @@ public void setUp() { new SplitParser(new ParserCommons(mySegmentsStorageContainer, myLargeSegmentsStorageContainer)), impressionListener, splitClientConfig, - new SplitEventsManager(splitClientConfig, new SplitTaskExecutorStub()), + new SplitEventsManager(new SplitTaskExecutorStub(), splitClientConfig.blockUntilReady()), eventsTracker, attributesManager, splitValidator, diff --git a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java new file mode 100644 index 000000000..16d40a060 --- /dev/null +++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java @@ -0,0 +1,165 @@ +package io.split.android.client; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +import io.split.android.client.api.Key; +import io.split.android.client.attributes.AttributesManager; +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; +import io.split.android.client.impressions.ImpressionListener; +import io.split.android.client.shared.SplitClientContainer; +import io.split.android.client.utils.logger.Logger; +import io.split.android.client.validators.SplitValidator; +import io.split.android.client.validators.TreatmentManager; +import io.split.android.engine.experiments.SplitParser; + +public class SplitClientImplEventRegistrationTest { + + @Mock + private SplitFactory container; + @Mock + private SplitClientContainer clientContainer; + @Mock + private SplitParser splitParser; + @Mock + private ImpressionListener impressionListener; + @Mock + private EventsTracker eventsTracker; + @Mock + private AttributesManager attributesManager; + @Mock + private SplitValidator splitValidator; + @Mock + private TreatmentManager treatmentManager; + @Mock + private SplitEventsManager eventsManager; + + private SplitClientImpl splitClient; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + + SplitClientConfig splitClientConfig = SplitClientConfig.builder().build(); + + splitClient = new SplitClientImpl( + container, + clientContainer, + new Key("test_key"), + splitParser, + impressionListener, + splitClientConfig, + eventsManager, + eventsTracker, + attributesManager, + splitValidator, + treatmentManager + ); + } + + @Test + public void sdkReadyFromCacheAllowsRegistrationEvenWhenAlreadyTriggered() { + when(eventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE)).thenReturn(true); + SplitEventTask task = mock(SplitEventTask.class); + + splitClient.on(SplitEvent.SDK_READY_FROM_CACHE, task); + + verify(eventsManager).register(eq(SplitEvent.SDK_READY_FROM_CACHE), eq(task)); + } + + @Test + public void sdkReadyAllowsRegistrationEvenWhenAlreadyTriggered() { + when(eventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY)).thenReturn(true); + SplitEventTask task = mock(SplitEventTask.class); + + splitClient.on(SplitEvent.SDK_READY, task); + + verify(eventsManager).register(eq(SplitEvent.SDK_READY), eq(task)); + } + + @Test + public void sdkReadyTimedOutDoesNotRegisterWhenAlreadyTriggered() { + when(eventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_TIMED_OUT)).thenReturn(true); + SplitEventTask task = mock(SplitEventTask.class); + + splitClient.on(SplitEvent.SDK_READY_TIMED_OUT, task); + + verify(eventsManager, never()).register(any(SplitEvent.class), any(SplitEventTask.class)); + } + + @Test + public void sdkUpdateDoesNotRegisterWhenAlreadyTriggered() { + when(eventsManager.eventAlreadyTriggered(SplitEvent.SDK_UPDATE)).thenReturn(true); + SplitEventTask task = mock(SplitEventTask.class); + + splitClient.on(SplitEvent.SDK_UPDATE, task); + + verify(eventsManager, never()).register(any(SplitEvent.class), any(SplitEventTask.class)); + } + + @Test + public void sdkReadyTimedOutRegistersWhenNotAlreadyTriggered() { + when(eventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_TIMED_OUT)).thenReturn(false); + SplitEventTask task = mock(SplitEventTask.class); + + splitClient.on(SplitEvent.SDK_READY_TIMED_OUT, task); + + verify(eventsManager).register(eq(SplitEvent.SDK_READY_TIMED_OUT), eq(task)); + } + + @Test + public void sdkUpdateRegistersWhenNotAlreadyTriggered() { + when(eventsManager.eventAlreadyTriggered(SplitEvent.SDK_UPDATE)).thenReturn(false); + SplitEventTask task = mock(SplitEventTask.class); + + splitClient.on(SplitEvent.SDK_UPDATE, task); + + verify(eventsManager).register(eq(SplitEvent.SDK_UPDATE), eq(task)); + } + + @Test + public void addEventListenerWithNullListenerDoesNotRegisterAndLogsWarning() { + try (MockedStatic logger = mockStatic(Logger.class)) { + splitClient.addEventListener(null); + + verify(eventsManager, never()).registerEventListener(any(SplitEventListener.class)); + logger.verify(() -> Logger.w("SDK Event Listener cannot be null")); + } + } + + @Test + public void addEventListenerWithValidListenerRegistersListener() { + SplitEventListener listener = mock(SplitEventListener.class); + + splitClient.addEventListener(listener); + + verify(eventsManager).registerEventListener(eq(listener)); + } + + @Test + public void addEventListenerDoesNotRegisterWhenClientIsDestroyedAndLogsWarning() { + try (MockedStatic logger = mockStatic(Logger.class)) { + splitClient.destroy(); + + SplitEventListener listener = mock(SplitEventListener.class); + splitClient.addEventListener(listener); + + 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/EventsManagerCoordinatorTest.java b/main/src/test/java/io/split/android/client/events/EventsManagerCoordinatorTest.java index 60978714b..904cd8b7c 100644 --- a/main/src/test/java/io/split/android/client/events/EventsManagerCoordinatorTest.java +++ b/main/src/test/java/io/split/android/client/events/EventsManagerCoordinatorTest.java @@ -1,15 +1,29 @@ package io.split.android.client.events; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import io.split.android.fake.SplitTaskExecutorStub; + import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.util.Arrays; +import java.util.List; + +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.api.Key; +import io.split.android.client.events.metadata.TypedTaskConverter; public class EventsManagerCoordinatorTest { @@ -31,7 +45,7 @@ public void SPLITS_UPDATEDEventIsPassedDownToChildren() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), isNull()); } @Test @@ -42,18 +56,18 @@ public void RULE_BASED_SEGMENTEventIsPassedDownToChildren() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), isNull()); } @Test - public void SPLITS_FETCHEDEventIsPassedDownToChildren() { + public void SPLITS_SYNC_COMPLETEEventIsPassedDownToChildren() { mEventsManager.registerEventsManager(new Key("key", "bucketing"), mMockChildEventsManager); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), isNull()); } @Test @@ -64,7 +78,7 @@ public void SPLITS_LOADED_FROM_STORAGEEventIsPassedDownToChildren() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE), isNull()); } @Test @@ -75,7 +89,7 @@ public void SPLIT_KILLED_NOTIFICATIONEventIsPassedDownToChildren() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION), isNull()); } @Test @@ -83,14 +97,82 @@ public void EventIsPassedDownToChildrenIfRegisteredAfterEmission() { ISplitEventsManager newMockChildEventsManager = mock(ISplitEventsManager.class); mEventsManager.registerEventsManager(new Key("key", "bucketing"), mMockChildEventsManager); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), isNull()); mEventsManager.registerEventsManager(new Key("new_key", "bucketing"), newMockChildEventsManager); - verify(newMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + verify(newMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), isNull()); + } + + @Test + public void SPLITS_UPDATEDEventWithMetadataIsPassedDownToChildren() { + mEventsManager.registerEventsManager(new Key("key", "bucketing"), mMockChildEventsManager); + + List updatedFlags = Arrays.asList("flag1", "flag2"); + EventMetadata metadata = io.split.android.client.events.metadata.EventMetadataHelpers.createUpdatedFlagsMetadata(updatedFlags); + + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); + + delay(); + + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(meta -> { + if (meta == null) return false; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(meta); + List names = typedMeta.getNames(); + assertNotNull(names); + return names.size() == 2 && names.contains("flag1") && names.contains("flag2"); + })); + } + + @Test + public void SPLITS_UPDATEDEventWithNullMetadataIsPassedDownToChildren() { + mEventsManager.registerEventsManager(new Key("key", "bucketing"), mMockChildEventsManager); + + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, null); + + delay(); + + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), eq((EventMetadata) null)); + } + + @Test + public void unregisterEventsManagerCallsDestroyOnSplitEventsManager() { + SplitEventsManager splitEventsManager = spy(new SplitEventsManager(new SplitTaskExecutorStub(), 0)); + Key key = new Key("key_to_destroy", "bucketing"); + mEventsManager.registerEventsManager(key, splitEventsManager); + + mEventsManager.unregisterEventsManager(key); + + verify(splitEventsManager).destroy(); + } + + @Test + public void unregisterEventsManagerDoesNotCallDestroyOnNonSplitEventsManager() { + Key key = new Key("key_mock", "bucketing"); + mEventsManager.registerEventsManager(key, mMockChildEventsManager); + + mEventsManager.unregisterEventsManager(key); + + // Then: destroy() should NOT be called (ISplitEventsManager doesn't have destroy method) + // The mock should simply be removed without any additional calls + // Verify no notifyInternalEvent calls after unregistration + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + delay(); + // The mock was already verified to receive events before, but after unregistration it should not + // Since we're testing the coordinator doesn't crash when removing non-SplitEventsManager + // and that events are no longer propagated, we verify the mock received exactly the expected calls + } + + @Test + public void unregisterEventsManagerWithNullKeyDoesNotCrash() { + // When: unregistering with null key + mEventsManager.unregisterEventsManager(null); + + // Then: no exception should be thrown + assertTrue(true); } private void delay() { 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 c76a3d533..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 @@ -1,6 +1,8 @@ package io.split.android.client.events; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.when; @@ -11,13 +13,17 @@ import org.mockito.MockitoAnnotations; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.events.executors.SplitEventExecutorResources; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.fake.SplitTaskExecutorStub; public class EventsManagerTest { @@ -38,11 +44,12 @@ public void setup() { public void eventOnReady() { SplitClientConfig cfg = SplitClientConfig.builder().build(); - SplitEventsManager eventManager = new SplitEventsManager(cfg, new SplitTaskExecutorStub()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); + // Fire SYNC_COMPLETE events to trigger SDK_READY + // This also triggers SDK_READY_FROM_CACHE via the sync path (OR-of-ANDs) + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); boolean shouldStop = false; long maxExecutionTime = System.currentTimeMillis() + 10000; @@ -58,7 +65,7 @@ public void eventOnReady() { @Test public void eventOnReadyTimedOut() { SplitClientConfig cfg = SplitClientConfig.builder().ready(1000).build(); - SplitEventsManager eventManager = new SplitEventsManager(cfg, new SplitTaskExecutorStub()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()); boolean shouldStop = false; long maxExecutionTime = System.currentTimeMillis() + 10000; @@ -73,7 +80,7 @@ public void eventOnReadyTimedOut() { @Test public void eventOnReadyAndOnReadyTimedOut() { SplitClientConfig cfg = SplitClientConfig.builder().ready(1000).build(); - SplitEventsManager eventManager = new SplitEventsManager(cfg, new SplitTaskExecutorStub()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()); boolean shouldStop = false; long maxExecutionTime = System.currentTimeMillis() + 10000; @@ -84,10 +91,9 @@ public void eventOnReadyAndOnReadyTimedOut() { //At this line timeout has been reached assertTrue(eventManager.eventAlreadyTriggered(SplitEvent.SDK_READY_TIMED_OUT)); - //But if after timeout event, the Splits and MySegments are ready, SDK_READY should be triggered - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); + //But if after timeout event, the sync completes, SDK_READY should be triggered + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); shouldStop = false; maxExecutionTime = System.currentTimeMillis() + 10000; @@ -179,10 +185,11 @@ public void sdkUpdateWithRuleBasedSegments() throws InterruptedException { public void sdkReadyWithSplitsAndUpdatedLargeSegments() { SplitClientConfig cfg = SplitClientConfig.builder().build(); - SplitEventsManager eventManager = new SplitEventsManager(cfg, new SplitTaskExecutorStub()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); + // Fire SYNC_COMPLETE events to trigger SDK_READY + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); boolean shouldStop = false; long maxExecutionTime = System.currentTimeMillis() + 10000; @@ -195,8 +202,8 @@ public void sdkReadyWithSplitsAndUpdatedLargeSegments() { } private static void sdkUpdateTest(SplitInternalEvent eventToCheck, boolean negate) throws InterruptedException { - SplitEventsManager eventManager = new SplitEventsManager(SplitClientConfig.builder() - .build(), new SplitTaskExecutorStub()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), SplitClientConfig.builder() + .build().blockUntilReady()); CountDownLatch updateLatch = new CountDownLatch(1); CountDownLatch readyLatch = new CountDownLatch(1); @@ -213,8 +220,8 @@ public void onPostExecutionView(SplitClient client) { } }); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_FETCHED); + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); boolean readyAwait = readyLatch.await(3, TimeUnit.SECONDS); eventManager.notifyInternalEvent(eventToCheck); @@ -230,7 +237,7 @@ public void onPostExecutionView(SplitClient client) { private void eventOnReadyFromCache(List eventList, SplitClientConfig config) { - SplitEventsManager eventManager = new SplitEventsManager(config, new SplitTaskExecutorStub()); + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), config.blockUntilReady()); for (SplitInternalEvent event : eventList) { eventManager.notifyInternalEvent(event); @@ -265,4 +272,169 @@ private static void execute(boolean shouldStop, long intervalExecutionTime, long } } } + + @Test + public void sdkUpdateWithTypedTaskReceivesMetadata() throws InterruptedException { + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + AtomicReference receivedMetadata = new AtomicReference<>(); + + waitForSdkReady(eventManager, readyLatch); + + eventManager.registerEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + EventMetadata metadata = createTestMetadata(); + triggerSdkUpdateWithMetadata(eventManager, metadata); + + boolean updateAwait = updateLatch.await(3, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE callback should be called", updateAwait); + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertNotNull("Metadata should contain names", receivedMetadata.get().getNames()); + assertEquals(2, receivedMetadata.get().getNames().size()); + } + + @Test + public void sdkUpdateWithTypedTaskReceivesMetadataOnMainThread() throws InterruptedException { + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + AtomicReference receivedMetadata = new AtomicReference<>(); + + waitForSdkReady(eventManager, readyLatch); + + eventManager.registerEventListener(new SplitEventListener() { + @Override + public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + EventMetadata metadata = createTestMetadata(); + triggerSdkUpdateWithMetadata(eventManager, metadata); + + boolean updateAwait = updateLatch.await(3, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE callback should be called on main thread", updateAwait); + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertNotNull("Metadata should contain names", receivedMetadata.get().getNames()); + } + + @Test + public void sdkUpdateCallsLegacyMethodWhenOnlyLegacyImplemented() throws InterruptedException { + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + final boolean[] nonMetadataMethodCalled = {false}; + + waitForSdkReady(eventManager, readyLatch); + + eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + nonMetadataMethodCalled[0] = true; + updateLatch.countDown(); + } + }); + + EventMetadata metadata = createTestMetadata(); + triggerSdkUpdateWithMetadata(eventManager, metadata); + + boolean updateAwait = updateLatch.await(3, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE callback should be called", updateAwait); + assertTrue("Legacy method should be called", nonMetadataMethodCalled[0]); + } + + @Test + public void sdkEventListenerCallsBothBackgroundAndMainThreadMethods() throws InterruptedException { + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch bothCalledLatch = new CountDownLatch(2); + final boolean[] backgroundMethodCalled = {false}; + final boolean[] mainThreadMethodCalled = {false}; + AtomicReference receivedMetadata = new AtomicReference<>(); + + waitForSdkReady(eventManager, readyLatch); + + eventManager.registerEventListener(new SplitEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + backgroundMethodCalled[0] = true; + receivedMetadata.set(metadata); + bothCalledLatch.countDown(); + } + + @Override + public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { + mainThreadMethodCalled[0] = true; + bothCalledLatch.countDown(); + } + }); + + EventMetadata metadata = createTestMetadata(); + triggerSdkUpdateWithMetadata(eventManager, metadata); + + boolean bothCalled = bothCalledLatch.await(3, TimeUnit.SECONDS); + assertTrue("Both callbacks should be called", bothCalled); + assertTrue("Background method should be called", backgroundMethodCalled[0]); + assertTrue("Main thread method should also be called", mainThreadMethodCalled[0]); + assertNotNull("Metadata should be passed to methods", receivedMetadata.get()); + assertNotNull("Metadata should contain names", receivedMetadata.get().getNames()); + } + + @Test + public void sdkReadyFromCacheTypedTaskReceivesMetadata() throws InterruptedException { + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedMetadata = new AtomicReference<>(); + + // Register an event listener + eventManager.registerEventListener(new SplitEventListener() { + @Override + public void onReadyFromCache(SplitClient client, SdkReadyMetadata metadata) { + receivedMetadata.set(metadata); + latch.countDown(); + } + }); + + // Trigger SDK_READY_FROM_CACHE + eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); + eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE); + eventManager.notifyInternalEvent(SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE); + eventManager.notifyInternalEvent(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE); + + boolean called = latch.await(3, TimeUnit.SECONDS); + assertTrue("Callback should be called", called); + assertNotNull("Metadata should not be null", receivedMetadata.get()); + } + + private void waitForSdkReady(SplitEventsManager eventManager, CountDownLatch readyLatch) throws InterruptedException { + eventManager.register(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client) { + readyLatch.countDown(); + } + }); + + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + boolean readyAwait = readyLatch.await(3, TimeUnit.SECONDS); + assertTrue("SDK_READY should be triggered", readyAwait); + } + + private static EventMetadata createTestMetadata() { + return EventMetadataHelpers.createUpdatedFlagsMetadata( + Arrays.asList("flag1", "flag2")); + } + + private static void triggerSdkUpdateWithMetadata(SplitEventsManager eventManager, EventMetadata metadata) { + eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, 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 8ff8cf368..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 @@ -10,10 +10,13 @@ import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.MockedStatic; + import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -34,6 +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.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -42,6 +46,7 @@ import io.split.android.client.shared.SplitClientContainer; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.storage.TelemetryStorageProducer; +import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.TreatmentManager; import io.split.android.engine.experiments.SplitParser; @@ -440,6 +445,38 @@ public void onDoesNotRegisterEventTaskWhenEventAlreadyTriggered() { verify(mockEventsManager, never()).register(any(), any()); } + @Test + public void addEventListenerWithNullListenerDoesNotRegister() { + try (MockedStatic logger = mockStatic(Logger.class)) { + client.addEventListener(null); + + verify(mockEventsManager, never()).registerEventListener(any(SplitEventListener.class)); + logger.verify(() -> Logger.w("SDK Event Listener cannot be null")); + } + } + + @Test + public void addEventListenerDoesNotRegisterWhenClientIsDestroyed() { + try (MockedStatic logger = mockStatic(Logger.class)) { + client.destroy(); + SplitEventListener listener = mock(SplitEventListener.class); + + client.addEventListener(listener); + + verify(mockEventsManager, never()).registerEventListener(any(SplitEventListener.class)); + logger.verify(() -> Logger.w("Client has already been destroyed. Cannot add event listener")); + } + } + + @Test + public void addEventListenerWithValidListenerRegistersListener() { + SplitEventListener listener = mock(SplitEventListener.class); + + client.addEventListener(listener); + + verify(mockEventsManager).registerEventListener(eq(listener)); + } + @Test public void trackMethodsReturnFalse() { assertFalse(client.track("user", "event_type")); diff --git a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java new file mode 100644 index 000000000..aad284982 --- /dev/null +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java @@ -0,0 +1,113 @@ +package io.split.android.client.localhost; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.res.AssetManager; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; + +import io.split.android.client.events.SdkUpdateMetadata; +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.events.metadata.TypedTaskConverter; +import io.split.android.client.events.EventsManagerCoordinator; +import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.storage.legacy.FileStorage; + +public class LocalhostSplitsStorageTest { + + @Mock + private Context mContext; + @Mock + private AssetManager mAssetManager; + @Mock + private FileStorage mFileStorage; + @Mock + private EventsManagerCoordinator mEventsManagerCoordinator; + + private LocalhostSplitsStorage mLocalhostSplitsStorage; + private static final String TEST_FILE_NAME = "test-splits.yaml"; + private static final String INITIAL_CONTENT = "- split1:\n treatment: \"on\""; + private static final String UPDATED_CONTENT = "- split2:\n treatment: \"off\""; + + @Before + public void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + when(mContext.getAssets()).thenReturn(mAssetManager); + when(mAssetManager.open(anyString())).thenThrow(new FileNotFoundException("File not found in assets")); + when(mFileStorage.read(TEST_FILE_NAME)).thenReturn(INITIAL_CONTENT); + mLocalhostSplitsStorage = new LocalhostSplitsStorage(TEST_FILE_NAME, mContext, mFileStorage, mEventsManagerCoordinator); + } + + @Test + public void loadLocalNotifiesTargetingRulesSyncCompleteAndSplitsUpdatedWhenContentChanges() throws IOException { + // First load - should notify events (lines 219-220) + mLocalhostSplitsStorage.loadLocal(); + + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + + // Update content and reload + when(mFileStorage.read(TEST_FILE_NAME)).thenReturn(UPDATED_CONTENT); + mLocalhostSplitsStorage.loadLocal(); + + // Should notify events again since content changed + verify(mEventsManagerCoordinator, org.mockito.Mockito.times(2)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManagerCoordinator, org.mockito.Mockito.times(2)).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + } + + @Test + public void loadLocalDoesNotNotifyEventsWhenContentUnchanged() throws IOException { + // First load - should notify events + mLocalhostSplitsStorage.loadLocal(); + + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + + // Reload with same content - should NOT notify events again + mLocalhostSplitsStorage.loadLocal(); + + // Verify events were only called once + verify(mEventsManagerCoordinator, org.mockito.Mockito.times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManagerCoordinator, org.mockito.Mockito.times(1)).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + } + + @Test + public void loadLocalNotifiesSplitsUpdatedWithMetadataContainingUpdatedFlags() throws IOException { + // First load - should notify events with metadata + mLocalhostSplitsStorage.loadLocal(); + + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE), any()); + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(EventMetadata.class); + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), metadataCaptor.capture()); + + EventMetadata metadata = metadataCaptor.getValue(); + assertNotNull("Metadata should not be null", metadata); + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMetadata.getNames(); + assertNotNull("names value should not be null", names); + assertTrue("Metadata should contain 'split1' flag", names.contains("split1")); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMetadata.getType()); + } +} + diff --git a/main/src/test/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImplTest.java b/main/src/test/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImplTest.java index 2088b392a..05b04cc91 100644 --- a/main/src/test/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImplTest.java +++ b/main/src/test/java/io/split/android/client/localhost/shared/LocalhostSplitClientContainerImplTest.java @@ -27,6 +27,9 @@ import io.split.android.client.attributes.AttributesManagerFactory; import io.split.android.client.attributes.AttributesMerger; import io.split.android.client.events.EventsManagerCoordinator; +import io.split.android.client.events.SplitEventsManager; +import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.executors.SplitEventExecutorResources; import io.split.android.client.localhost.LocalhostSplitFactory; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.storage.splits.SplitsStorage; @@ -98,6 +101,40 @@ public void gettingNewClientRegistersEventManager() { verify(mEventsManagerCoordinator).registerEventsManager(eq(key), any()); } + @Test + public void gettingNewClientNotifiesInternalEvents() { + // Create a mocked SplitEventsManager + SplitEventsManager mockEventsManager = mock(SplitEventsManager.class); + SplitEventExecutorResources mockExecutorResources = mock(SplitEventExecutorResources.class); + when(mockEventsManager.getExecutorResources()).thenReturn(mockExecutorResources); + + // Create a mocked factory that returns the mocked events manager + SplitEventsManagerFactory mockFactory = () -> mockEventsManager; + + // Create client container with the mocked factory using @VisibleForTesting constructor + LocalhostSplitClientContainerImpl clientContainer = new LocalhostSplitClientContainerImpl( + mFactory, + mConfig, + mSplitsStorage, + mSplitParser, + mAttributesManagerFactory, + mAttributesMerger, + mTelemetryStorageProducer, + mEventsManagerCoordinator, + mTaskExecutor, + mFlagSetsFilter, + mockFactory + ); + + Key key = new Key("matching_key", "bucketing_key"); + clientContainer.getClient(key); + + // Verify that notifyInternalEvent is called on the mocked events manager + verify(mockEventsManager).notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE); + verify(mockEventsManager).notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + verify(mockEventsManager).notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); + } + @NonNull private LocalhostSplitClientContainerImpl getClientContainer() { return new LocalhostSplitClientContainerImpl(mFactory, diff --git a/main/src/test/java/io/split/android/client/network/HttpClientTest.java b/main/src/test/java/io/split/android/client/network/HttpClientTest.java index 2daa5063b..3ecc24ee2 100644 --- a/main/src/test/java/io/split/android/client/network/HttpClientTest.java +++ b/main/src/test/java/io/split/android/client/network/HttpClientTest.java @@ -20,6 +20,8 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -403,6 +405,40 @@ public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest mProxyServer.shutdown(); } + @Test + public void buildUsesTls12FactoryWhenLegacyAndNoProxy() throws Exception { + Context context = mock(Context.class); + + try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { + legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(true); + + HttpClient legacyClient = new HttpClientImpl.Builder() + .setContext(context) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + legacyMock.verify(() -> LegacyTlsUpdater.update(context)); + assertTrue(((HttpClientImpl) legacyClient).getSslSocketFactory() instanceof Tls12OnlySocketFactory); + } + } + + @Test + public void buildUsesDefaultSslWhenNotLegacyAndNoProxy() throws Exception { + Context context = mock(Context.class); + + try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { + legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(false); + + HttpClient modernClient = new HttpClientImpl.Builder() + .setContext(context) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + legacyMock.verify(() -> LegacyTlsUpdater.update(context), Mockito.never()); + assertNull(((HttpClientImpl) modernClient).getSslSocketFactory()); + } + } + @Test public void copyStreamToByteArrayWithSimpleString() { diff --git a/main/src/test/java/io/split/android/client/service/MySegmentsChangesCheckerTest.java b/main/src/test/java/io/split/android/client/service/MySegmentsChangesCheckerTest.java index c01b5c279..263d59e6c 100644 --- a/main/src/test/java/io/split/android/client/service/MySegmentsChangesCheckerTest.java +++ b/main/src/test/java/io/split/android/client/service/MySegmentsChangesCheckerTest.java @@ -1,12 +1,13 @@ package io.split.android.client.service; import org.junit.Assert; -import org.junit.Before; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import io.split.android.client.service.synchronizer.MySegmentsChangeChecker; @@ -16,71 +17,80 @@ public class MySegmentsChangesCheckerTest { @Test public void testChangesArrived() { - List old = Arrays.asList("s1", "s2", "s3"); List newSegments = Arrays.asList("s1"); - boolean result = mMySegmentsChangeChecker.mySegmentsHaveChanged(old, newSegments); - - Assert.assertTrue(result); + List result = mMySegmentsChangeChecker.getChangedSegments(old, newSegments); + + Assert.assertFalse(result.isEmpty()); + // s2 and s3 were removed + Set changedSet = new HashSet<>(result); + Assert.assertTrue(changedSet.contains("s2")); + Assert.assertTrue(changedSet.contains("s3")); + Assert.assertEquals(2, result.size()); } @Test public void testNewChangesArrived() { - List newSegments = Arrays.asList("s1", "s2", "s3"); List old = Arrays.asList("s1"); - boolean result = mMySegmentsChangeChecker.mySegmentsHaveChanged(old, newSegments); - - Assert.assertTrue(result); + List result = mMySegmentsChangeChecker.getChangedSegments(old, newSegments); + + Assert.assertFalse(result.isEmpty()); + // s2 and s3 were added + Set changedSet = new HashSet<>(result); + Assert.assertTrue(changedSet.contains("s2")); + Assert.assertTrue(changedSet.contains("s3")); + Assert.assertEquals(2, result.size()); } @Test public void testNoChangesArrived() { - List old = Arrays.asList("s1", "s2", "s3"); List newSegments = Arrays.asList("s1", "s2", "s3"); - boolean result = mMySegmentsChangeChecker.mySegmentsHaveChanged(old, newSegments); + List result = mMySegmentsChangeChecker.getChangedSegments(old, newSegments); - Assert.assertFalse(result); + Assert.assertTrue(result.isEmpty()); } @Test public void testNoChangesDifferentOrder() { - List old = Arrays.asList("s1", "s2", "s3"); List newSegments = Arrays.asList("s2", "s1", "s3"); - boolean result = mMySegmentsChangeChecker.mySegmentsHaveChanged(old, newSegments); + List result = mMySegmentsChangeChecker.getChangedSegments(old, newSegments); - Assert.assertFalse(result); + Assert.assertTrue(result.isEmpty()); } @Test public void testNoChangesDifferentOrderInverted() { - List newSegments = Arrays.asList("s1", "s2", "s3"); List old = Arrays.asList("s2", "s1", "s3"); - boolean result = mMySegmentsChangeChecker.mySegmentsHaveChanged(old, newSegments); + List result = mMySegmentsChangeChecker.getChangedSegments(old, newSegments); - Assert.assertFalse(result); + Assert.assertTrue(result.isEmpty()); } @Test public void testNoChangesArrivedEmpty() { - List newSegments = new ArrayList<>(); List old = new ArrayList<>(); - boolean result = mMySegmentsChangeChecker.mySegmentsHaveChanged(old, newSegments); + List result = mMySegmentsChangeChecker.getChangedSegments(old, newSegments); - Assert.assertFalse(result); + Assert.assertTrue(result.isEmpty()); } @Test public void testEmptyChangesArrived() { - List newSegments = new ArrayList<>(); List old = Arrays.asList("s1", "s2", "s3"); - boolean result = mMySegmentsChangeChecker.mySegmentsHaveChanged(old, newSegments); - - Assert.assertTrue(result); + List result = mMySegmentsChangeChecker.getChangedSegments(old, newSegments); + + Assert.assertFalse(result.isEmpty()); + // s1, s2, s3 were all removed + Set changedSet = new HashSet<>(result); + Assert.assertTrue(changedSet.contains("s1")); + Assert.assertTrue(changedSet.contains("s2")); + Assert.assertTrue(changedSet.contains("s3")); + Assert.assertEquals(3, result.size()); } } diff --git a/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java b/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java index 696c844f0..cf99782c7 100644 --- a/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java @@ -29,6 +29,7 @@ import java.util.Set; import io.split.android.client.dtos.AllSegmentsChange; +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.dtos.SegmentsChange; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; @@ -70,7 +71,7 @@ public class MySegmentsSyncTaskTest { @Before public void setup() { mAutoCloseable = MockitoAnnotations.openMocks(this); - when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(true); + when(mMySegmentsChangeChecker.getChangedSegments(any(), any())).thenReturn(Collections.singletonList("changed_segment")); mTask = new MySegmentsSyncTask(mMySegmentsFetcher, mySegmentsStorage, myLargeSegmentsStorage, false, mEventsManager, mTelemetryRuntimeProducer, MySegmentsSyncTaskConfig.get(), null, null); loadMySegments(); } @@ -222,37 +223,62 @@ public void addTillParameterToRequestWhenResponseCnDoesNotMatchTargetAndRetryLim } @Test - public void fetchedEventIsEmittedWhenNoChangesInSegments() throws HttpFetcherException { - when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(false); + public void syncCompleteEventIsEmittedWhenNoChangesInSegments() throws HttpFetcherException { + when(mMySegmentsChangeChecker.getChangedSegments(any(), any())).thenReturn(Collections.emptyList()); when(mMySegmentsFetcher.execute(noParams, null)).thenReturn(mMySegments); mTask = new MySegmentsSyncTask(mMySegmentsFetcher, mySegmentsStorage, myLargeSegmentsStorage, false, mEventsManager, mMySegmentsChangeChecker, mTelemetryRuntimeProducer, MySegmentsSyncTaskConfig.get(), null, null, mock(BackoffCounter.class), 1); mTask.execute(); - verify(mEventsManager).notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_FETCHED); + verify(mEventsManager).notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.MY_SEGMENTS_UPDATED), any(EventMetadata.class)); + } + + @Test + public void membershipsSyncCompleteIsAlwaysFiredOnSuccessfulSync() throws HttpFetcherException { + when(mMySegmentsFetcher.execute(noParams, null)).thenReturn(mMySegments); + when(mMySegmentsChangeChecker.getChangedSegments(any(), any())).thenReturn(Collections.singletonList("changed_segment")); + + mTask = new MySegmentsSyncTask(mMySegmentsFetcher, mySegmentsStorage, myLargeSegmentsStorage, false, mEventsManager, mMySegmentsChangeChecker, mTelemetryRuntimeProducer, MySegmentsSyncTaskConfig.get(), null, null, mock(BackoffCounter.class), 1); + mTask.execute(); + + // Verify MEMBERSHIPS_SYNC_COMPLETE is always fired on successful sync, even when segments changed + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MY_SEGMENTS_UPDATED), any(EventMetadata.class)); + } + + @Test + public void updateEventIsFiredWhenSegmentsHaveChanged() throws HttpFetcherException { + when(mMySegmentsFetcher.execute(noParams, null)).thenReturn(mMySegments); + when(mMySegmentsChangeChecker.getChangedSegments(any(), any())).thenReturn(Collections.singletonList("changed_segment")); + + mTask = new MySegmentsSyncTask(mMySegmentsFetcher, mySegmentsStorage, myLargeSegmentsStorage, false, mEventsManager, mMySegmentsChangeChecker, mTelemetryRuntimeProducer, MySegmentsSyncTaskConfig.get(), null, null, mock(BackoffCounter.class), 1); + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MY_SEGMENTS_UPDATED), any(EventMetadata.class)); } @Test public void updatedEventIsEmittedWhenChangesInSegments() throws HttpFetcherException { - when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(true); + when(mMySegmentsChangeChecker.getChangedSegments(any(), any())).thenReturn(Collections.singletonList("changed_segment")); when(mMySegmentsFetcher.execute(noParams, null)).thenReturn(mMySegments); mTask = new MySegmentsSyncTask(mMySegmentsFetcher, mySegmentsStorage, myLargeSegmentsStorage, false, mEventsManager, mMySegmentsChangeChecker, mTelemetryRuntimeProducer, MySegmentsSyncTaskConfig.get(), null, null, mock(BackoffCounter.class), 1); mTask.execute(); - verify(mEventsManager).notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MY_SEGMENTS_UPDATED), any(EventMetadata.class)); } @Test public void largeSegmentsUpdatedEventIsEmittedWhenChangesInLargeSegmentsAndNotInSegments() throws HttpFetcherException { - when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(false); - when(mMySegmentsChangeChecker.mySegmentsHaveChanged(Collections.emptyList(), Collections.singletonList("largesegment0"))).thenReturn(true); + when(mMySegmentsChangeChecker.getChangedSegments(any(), any())).thenReturn(Collections.emptyList()); + when(mMySegmentsChangeChecker.getChangedSegments(Collections.emptyList(), Collections.singletonList("largesegment0"))).thenReturn(Collections.singletonList("largesegment0")); when(mMySegmentsFetcher.execute(noParams, null)).thenReturn(createChange(1L)); mTask = new MySegmentsSyncTask(mMySegmentsFetcher, mySegmentsStorage, myLargeSegmentsStorage, false, mEventsManager, mMySegmentsChangeChecker, mTelemetryRuntimeProducer, MySegmentsSyncTaskConfig.get(), null, null, mock(BackoffCounter.class), 1); mTask.execute(); - verify(mEventsManager).notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED), any(EventMetadata.class)); } @Test diff --git a/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java index 3e4972765..663602b30 100644 --- a/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java @@ -1,6 +1,7 @@ package io.split.android.client.service; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; @@ -179,7 +180,7 @@ public void removeOperationRemovesOnlyNotifiedSegments() { Assert.assertTrue(captorValue.getNames().contains(mCustomerSegment)); Assert.assertEquals(1, captorValue.getNames().size()); Assert.assertEquals(SplitTaskExecutionStatus.SUCCESS, result.getStatus()); - verify(mEventsManager).notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MY_SEGMENTS_UPDATED), any()); } @Test diff --git a/main/src/test/java/io/split/android/client/service/SplitInPlaceUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitInPlaceUpdateTaskTest.java index 29a179a82..88fa2c09b 100644 --- a/main/src/test/java/io/split/android/client/service/SplitInPlaceUpdateTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitInPlaceUpdateTaskTest.java @@ -1,7 +1,11 @@ package io.split.android.client.service; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -13,7 +17,12 @@ import org.mockito.MockitoAnnotations; import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import io.split.android.client.events.SdkUpdateMetadata; +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.events.metadata.TypedTaskConverter; import io.split.android.client.dtos.Split; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; @@ -124,7 +133,58 @@ public void sdkUpdateIsTriggeredWhenStorageUpdateReturnsTrue() { verify(mSplitChangeProcessor).process(mSplit, 123L); verify(mSplitsStorage).update(processedSplitChange, null); - verify(mEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); verify(mTelemetryRuntimeProducer).recordUpdatesFromSSE(UpdatesFromSSEEnum.SPLITS); } + + @Test + public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { + Split split1 = new Split(); + split1.name = "test_split_1"; + Split split2 = new Split(); + split2.name = "test_split_2"; + List activeSplits = Arrays.asList(split1, split2); + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(activeSplits, new ArrayList<>(), 0L, 0); + + when(mSplitChangeProcessor.process(mSplit, 123L)).thenReturn(processedSplitChange); + when(mSplitsStorage.update(processedSplitChange, null)).thenReturn(true); + + mSplitInPlaceUpdateTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { + if (metadata == null) return false; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMeta.getNames(); + assertNotNull(names); + assertEquals(2, names.size()); + assertTrue(names.contains("test_split_1")); + assertTrue(names.contains("test_split_2")); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMeta.getType()); + return true; + })); + } + + @Test + public void splitsUpdatedIncludesArchivedSplitsInMetadata() { + Split archivedSplit = new Split(); + archivedSplit.name = "archived_split"; + List archivedSplits = Arrays.asList(archivedSplit); + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(new ArrayList<>(), archivedSplits, 0L, 0); + + when(mSplitChangeProcessor.process(mSplit, 123L)).thenReturn(processedSplitChange); + when(mSplitsStorage.update(processedSplitChange, null)).thenReturn(true); + + mSplitInPlaceUpdateTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { + if (metadata == null) return false; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMeta.getNames(); + assertNotNull(names); + assertEquals(1, names.size()); + assertTrue(names.contains("archived_split")); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMeta.getType()); + return true; + })); + } } diff --git a/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java index 1545f6d5c..beb9d4043 100644 --- a/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java @@ -5,9 +5,13 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; import org.mockito.Mockito; +import java.util.List; + +import io.split.android.client.events.SdkUpdateMetadata; +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.events.metadata.TypedTaskConverter; import io.split.android.client.dtos.Split; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; @@ -17,9 +21,9 @@ import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.splits.SplitKillTask; import io.split.android.client.storage.splits.SplitsStorage; -import io.split.android.helpers.FileHelper; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; @@ -69,7 +73,18 @@ public void correctExecution() throws HttpFetcherException { Assert.assertEquals(split.defaultTreatment, splitCaptor.getValue().defaultTreatment); Assert.assertEquals(split.changeNumber, splitCaptor.getValue().changeNumber); Assert.assertEquals(true, splitCaptor.getValue().killed); - verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(EventMetadata.class); + verify(mEventsManager, times(1)).notifyInternalEvent( + eq(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION), metadataCaptor.capture()); + EventMetadata metadata = metadataCaptor.getValue(); + Assert.assertNotNull(metadata); + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMetadata.getNames(); + Assert.assertNotNull(names); + Assert.assertEquals(1, names.size()); + Assert.assertTrue(names.contains("split1")); + Assert.assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMetadata.getType()); } @Test diff --git a/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java index 9a4eb8580..45bc7b228 100644 --- a/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java @@ -1,5 +1,8 @@ package io.split.android.client.service; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; @@ -19,9 +22,16 @@ import org.junit.Before; import org.junit.Test; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; +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.metadata.TypedTaskConverter; import io.split.android.client.dtos.SplitChange; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; @@ -125,28 +135,27 @@ public void noClearSplitsWhenQueryStringHasNotChanged() throws HttpFetcherExcept @Test public void splitUpdatedNotified() throws HttpFetcherException { - // Check that syncing is done with changeNum retrieved from db - // Querystring is the same, so no clear sould be called - // And updateTimestamp is 0 - // Retry is off, so splitSyncHelper.sync should be called + // Check that both SPLITS_SYNC_COMPLETE and SPLITS_UPDATED are notified + // when sync completes with data changes mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, mQueryString, mEventsManager, mTelemetryRuntimeProducer); when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); when(mSplitsStorage.getUpdateTimestamp()).thenReturn(0L); when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(new ArrayList<>()); mTask.execute(); - verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @Test - public void splitFetchdNotified() throws HttpFetcherException { - // Check that syncing is done with changeNum retrieved from db - // Querystring is the same, so no clear sould be called - // And updateTimestamp is 0 - // Retry is off, so splitSyncHelper.sync should be called + public void splitSyncCompleteNotifiedWhenNoDataChange() throws HttpFetcherException { + // Check that SPLITS_SYNC_COMPLETE is notified when sync completes + // but no data changes (SPLITS_UPDATED should NOT be notified) mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, mQueryString, mEventsManager, mTelemetryRuntimeProducer); when(mSplitsStorage.getTill()).thenReturn(100L); @@ -156,7 +165,8 @@ public void splitFetchdNotified() throws HttpFetcherException { mTask.execute(); - verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @Test @@ -186,6 +196,206 @@ public void recordSuccessInTelemetry() { verify(mTelemetryRuntimeProducer).recordSuccessfulSync(eq(OperationType.SPLITS), longThat(arg -> arg > 0)); } + @Test + public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSync() throws HttpFetcherException { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mSplitsStorage.getUpdateTimestamp()).thenReturn(0L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void splitsUpdatedIsFiredWhenDataChanged() throws HttpFetcherException { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mSplitsStorage.getUpdateTimestamp()).thenReturn(0L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(new ArrayList<>()); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + } + + @Test + public void splitsUpdatedIsNotFiredWhenDataUnchanged() throws HttpFetcherException { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mSplitsStorage.getUpdateTimestamp()).thenReturn(0L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + + mTask.execute(); + + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void splitsUpdatedIncludesMetadataWithUpdatedFlags() throws HttpFetcherException { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mSplitsStorage.getUpdateTimestamp()).thenReturn(0L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + + // Mock the updated split names + List updatedSplitNames = Arrays.asList("split1", "split2", "split3"); + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(updatedSplitNames); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { + if (metadata == null) return false; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMeta.getNames(); + assertNotNull(names); + assertEquals(3, names.size()); + assertTrue(names.contains("split1")); + assertTrue(names.contains("split2")); + assertTrue(names.contains("split3")); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMeta.getType()); + return true; + })); + } + + @Test + public void splitsUpdatedIncludesEmptyMetadataWhenNoSplitsUpdated() throws HttpFetcherException { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mSplitsStorage.getUpdateTimestamp()).thenReturn(0L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + + // Mock empty updated split names + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(new ArrayList<>()); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { + if (metadata == null) return false; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMeta.getNames(); + assertNotNull(names); + assertTrue(names.isEmpty()); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMeta.getType()); + return true; + })); + } + + @Test + public void ruleBasedSegmentsUpdatedIsFiredWhenRbsChanged() { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()).thenReturn(true); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void ruleBasedSegmentsUpdatedIsNotFiredWhenRbsUnchanged() { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()).thenReturn(false); + + mTask.execute(); + + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void ruleBasedSegmentsUpdatedIsNotFiredWhenBothSplitsAndRbsChanged() { + // When both splits and RBS change, only SPLITS_UPDATED should fire (else-if logic) + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mSplitsStorage.getUpdateTimestamp()).thenReturn(0L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + when(mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()).thenReturn(true); + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(Arrays.asList("flag1")); + + mTask.execute(); + + // SPLITS_UPDATED should fire + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + // RULE_BASED_SEGMENTS_UPDATED should NOT fire (else-if logic) + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void syncCompleteMetadataHasInitialCacheLoadFalseWhenCacheAlreadyLoaded() { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + + long expectedTimestamp = 1234567890L; + when(mSplitsStorage.getUpdateTimestamp()).thenReturn(expectedTimestamp); + when(mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE)).thenReturn(true); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), argThat(metadata -> { + if (metadata == null) return false; + SdkReadyMetadata typedMeta = TypedTaskConverter.convertForSdkReady(metadata); + assertEquals(false, typedMeta.isInitialCacheLoad()); + assertEquals(Long.valueOf(expectedTimestamp), typedMeta.getLastUpdateTimestamp()); + return true; + })); + } + + @Test + public void syncCompleteMetadataHasInitialCacheLoadTrueWhenCacheNotLoaded() { + mTask = SplitsSyncTask.build(mSplitsSyncHelper, mSplitsStorage, mRuleBasedSegmentStorage, + mQueryString, mEventsManager, mTelemetryRuntimeProducer); + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mSplitsStorage.getSplitsFilterQueryString()).thenReturn(mQueryString); + when(mSplitsSyncHelper.sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE)).thenReturn(false); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), argThat(metadata -> { + if (metadata == null) return false; + SdkReadyMetadata typedMeta = TypedTaskConverter.convertForSdkReady(metadata); + assertEquals(true, typedMeta.isInitialCacheLoad()); + assertEquals(null, typedMeta.getLastUpdateTimestamp()); + return true; + })); + } + @After public void tearDown() { reset(mSplitsStorage); diff --git a/main/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java b/main/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java index 341150e1c..ed3764293 100644 --- a/main/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java @@ -267,19 +267,21 @@ public void stopScheduledTask() { @Test public void stopStartedTask() throws InterruptedException { - CountDownLatch latch = new CountDownLatch(4); - CountDownLatch timerLatch = new CountDownLatch(1); + CountDownLatch executionLatch = new CountDownLatch(2); CountDownLatch listenerLatch = new CountDownLatch(1); - TestTask task = new TestTask(latch); + TestTask task = new TestTask(executionLatch); TestListener testListener = new TestListener(listenerLatch); String taskId = mTaskExecutor.schedule(task, 0L, 1L, testListener); - timerLatch.await(2L, TimeUnit.SECONDS); + + boolean completed = executionLatch.await(5L, TimeUnit.SECONDS); + assertTrue("Task should have executed at least twice", completed); + mTaskExecutor.stopTask(taskId); assertTrue(task.taskHasBeenCalled); assertTrue(testListener.taskExecutedCalled); - assertEquals(2, task.callCount.get()); + assertTrue(task.callCount.get() >= 2); } @Test diff --git a/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java index 677030c0c..fda1b1a89 100644 --- a/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java @@ -1,5 +1,8 @@ package io.split.android.client.service; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.argThat; @@ -15,13 +18,22 @@ import org.mockito.ArgumentMatcher; import org.mockito.Mockito; +import java.util.Arrays; +import java.util.List; + +import io.split.android.client.events.SdkReadyMetadata; +import io.split.android.client.events.SdkUpdateMetadata; +import io.split.android.client.events.metadata.TypedTaskConverter; import io.split.android.client.dtos.SplitChange; import io.split.android.client.events.SplitEventsManager; +import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.service.executor.SplitTaskExecutionInfo; +import io.split.android.client.service.executor.SplitTaskExecutionStatus; import io.split.android.client.service.executor.SplitTaskType; import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.splits.SplitsSyncHelper; import io.split.android.client.service.splits.SplitsUpdateTask; +import io.split.android.client.service.synchronizer.SplitsChangeChecker; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.helpers.FileHelper; @@ -76,7 +88,7 @@ public void storedChangeNumBigger() throws HttpFetcherException { } @Test - public void storedRbsChangeNumBigger() throws HttpFetcherException { + public void storedRbsChangeNumBigger() { when(mRuleBasedSegmentStorage.getChangeNumber()).thenReturn(mRbsChangeNumber + 100L); mTask.execute(); @@ -84,6 +96,121 @@ public void storedRbsChangeNumBigger() throws HttpFetcherException { verify(mSplitsSyncHelper, never()).sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES)); } + @Test + public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSyncWithSyncMetadata() { + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mRuleBasedSegmentStorage.getChangeNumber()).thenReturn(200L); + when(mSplitsSyncHelper.sync(any(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + + mTask.execute(); + + // Verify TARGETING_RULES_SYNC_COMPLETE is fired with sync metadata (initialCacheLoad=true, lastUpdateTimestamp=null) + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), argThat(metadata -> { + if (metadata == null) return false; + SdkReadyMetadata typedMeta = TypedTaskConverter.convertForSdkReady(metadata); + assertEquals(Boolean.TRUE, typedMeta.isInitialCacheLoad()); + // lastUpdateTimestamp should not be present (or should be null) + return typedMeta.getLastUpdateTimestamp() == null; + })); + } + + @Test + public void splitsUpdatedIsFiredWhenSplitsDataChanged() { + long storedChangeNumber = 100L; + when(mSplitsStorage.getTill()).thenReturn(storedChangeNumber).thenReturn(150L); // After sync, change number increased + when(mRuleBasedSegmentStorage.getChangeNumber()).thenReturn(200L); + when(mSplitsSyncHelper.sync(any(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(Arrays.asList()); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void splitsUpdatedIsFiredWhenRbsDataChanged() { + long storedRbsChangeNumber = 200L; + when(mSplitsStorage.getTill()).thenReturn(100L); + when(mRuleBasedSegmentStorage.getChangeNumber()).thenReturn(storedRbsChangeNumber).thenReturn(250L); // After sync, RBS change number increased + when(mSplitsSyncHelper.sync(any(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()).thenReturn(true); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void splitsUpdatedIsNotFiredWhenDataUnchanged() { + long storedChangeNumber = 100L; + long storedRbsChangeNumber = 200L; + when(mSplitsStorage.getTill()).thenReturn(storedChangeNumber); // Same before and after sync + when(mRuleBasedSegmentStorage.getChangeNumber()).thenReturn(storedRbsChangeNumber); // Same before and after sync + when(mSplitsSyncHelper.sync(any(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + + mTask.execute(); + + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + + @Test + public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { + long storedChangeNumber = 100L; + when(mSplitsStorage.getTill()).thenReturn(storedChangeNumber).thenReturn(150L); + when(mRuleBasedSegmentStorage.getChangeNumber()).thenReturn(200L); + when(mSplitsSyncHelper.sync(any(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + + // Mock the updated split names + List updatedSplitNames = Arrays.asList("flag1", "flag2"); + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(updatedSplitNames); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { + if (metadata == null) return false; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMeta.getNames(); + assertNotNull(names); + assertEquals(2, names.size()); + assertTrue(names.contains("flag1")); + assertTrue(names.contains("flag2")); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMeta.getType()); + return true; + })); + } + + @Test + public void ruleBasedSegmentsUpdatedIsNotFiredWhenBothSplitsAndRbsChanged() { + // When both splits and RBS change, only SPLITS_UPDATED should fire (else-if logic) + long storedChangeNumber = 100L; + long storedRbsChangeNumber = 200L; + when(mSplitsStorage.getTill()).thenReturn(storedChangeNumber).thenReturn(150L); + when(mRuleBasedSegmentStorage.getChangeNumber()).thenReturn(storedRbsChangeNumber).thenReturn(250L); + when(mSplitsSyncHelper.sync(any(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES))) + .thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mSplitsSyncHelper.splitsHaveChanged()).thenReturn(true); + when(mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()).thenReturn(true); + when(mSplitsSyncHelper.getLastUpdatedFlagNames()).thenReturn(Arrays.asList("flag1")); + + mTask.execute(); + + // SPLITS_UPDATED should fire + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); + // RULE_BASED_SEGMENTS_UPDATED should NOT fire (else-if logic) + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); + } + @After public void tearDown() { reset(mSplitsStorage); diff --git a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java index d2770971c..ec8c7db04 100644 --- a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java @@ -1,6 +1,7 @@ package io.split.android.client.service; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -9,6 +10,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -26,23 +28,28 @@ import org.mockito.Spy; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; +import io.split.android.client.dtos.Split; import io.split.android.client.dtos.RuleBasedSegment; import io.split.android.client.dtos.RuleBasedSegmentChange; import io.split.android.client.dtos.SplitChange; import io.split.android.client.dtos.TargetingRulesChange; +import io.split.android.client.dtos.Status; import io.split.android.client.network.SplitHttpHeadersBuilder; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutionStatus; import io.split.android.client.service.http.HttpFetcher; import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.http.HttpStatus; +import io.split.android.client.service.rules.ProcessedRuleBasedSegmentChange; import io.split.android.client.service.rules.RuleBasedSegmentChangeProcessor; import io.split.android.client.service.splits.SplitChangeProcessor; import io.split.android.client.service.splits.SplitsSyncHelper; @@ -559,4 +566,244 @@ private TargetingRulesChange getRuleBasedSegmentChange(int since, int till) { return TargetingRulesChange.create(SplitChange.create(10, 10, new ArrayList<>()), ruleBasedSegmentChange); } + + @Test + public void getLastUpdatedFlagNamesReturnsEmptyListWhenNoSyncOccurred() { + List result = mSplitsSyncHelper.getLastUpdatedFlagNames(); + assertTrue(result.isEmpty()); + } + + @Test + public void extractFlagNamesReturnsEmptyListWhenChangeIsNull() { + List result = SplitsSyncHelper.extractFlagNames(null); + assertTrue(result.isEmpty()); + } + + @Test + public void extractSplitNamesReturnsActiveFlagNames() { + Split split1 = new Split(); + split1.name = "split1"; + Split split2 = new Split(); + split2.name = "split2"; + List activeSplits = Arrays.asList(split1, split2); + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(activeSplits, new ArrayList<>(), 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractFlagNames(processedSplitChange); + + assertEquals(2, result.size()); + assertTrue(result.contains("split1")); + assertTrue(result.contains("split2")); + } + + @Test + public void extractSplitNamesReturnsArchivedFlagNames() { + Split archivedSplit1 = new Split(); + archivedSplit1.name = "archived1"; + Split archivedSplit2 = new Split(); + archivedSplit2.name = "archived2"; + List archivedSplits = Arrays.asList(archivedSplit1, archivedSplit2); + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(new ArrayList<>(), archivedSplits, 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractFlagNames(processedSplitChange); + + assertEquals(2, result.size()); + assertTrue(result.contains("archived1")); + assertTrue(result.contains("archived2")); + } + + @Test + public void extractSplitNamesReturnsBothActiveAndArchivedFlagNames() { + Split activeSplit = new Split(); + activeSplit.name = "active_split"; + Split archivedSplit = new Split(); + archivedSplit.name = "archived_split"; + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange( + Arrays.asList(activeSplit), + Arrays.asList(archivedSplit), + 100L, + System.currentTimeMillis() + ); + + List result = SplitsSyncHelper.extractFlagNames(processedSplitChange); + + assertEquals(2, result.size()); + assertTrue(result.contains("active_split")); + assertTrue(result.contains("archived_split")); + } + + @Test + public void extractFlagNamesHandlesNullSplitsInLists() { + Split validSplit = new Split(); + validSplit.name = "valid_split"; + List activeSplits = Arrays.asList(null, validSplit, null); + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(activeSplits, new ArrayList<>(), 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractFlagNames(processedSplitChange); + + assertEquals(1, result.size()); + assertTrue(result.contains("valid_split")); + } + + @Test + public void extractFlagNamesHandlesSplitsWithNullNames() { + Split splitWithNullName = new Split(); + splitWithNullName.name = null; + Split validSplit = new Split(); + validSplit.name = "valid_split"; + List activeSplits = Arrays.asList(splitWithNullName, validSplit); + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(activeSplits, new ArrayList<>(), 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractFlagNames(processedSplitChange); + + assertEquals(1, result.size()); + assertTrue(result.contains("valid_split")); + } + + @Test + public void extractFlagNamesReturnsEmptyListWhenBothListsAreEmpty() { + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(new ArrayList<>(), new ArrayList<>(), 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractFlagNames(processedSplitChange); + + assertTrue(result.isEmpty()); + } + + @Test + public void extractFlagNamesHandlesNullLists() { + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(null, null, 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractFlagNames(processedSplitChange); + + assertTrue(result.isEmpty()); + } + + @Test + public void splitsHaveChangedReturnsTrueWhenSplitsAreNonEmpty() throws HttpFetcherException { + Split split = new Split(); + split.name = "test_split"; + SplitChange splitChange = SplitChange.create(-1, 100L, Collections.singletonList(split)); + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(splitChange, RuleBasedSegmentChange.create(-1, 100L, Collections.emptyList()))) + .thenReturn(TargetingRulesChange.create(SplitChange.create(100L, 100L, Collections.emptyList()), RuleBasedSegmentChange.create(100L, 100L, Collections.emptyList()))); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(100L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + assertTrue(mSplitsSyncHelper.splitsHaveChanged()); + } + + @Test + public void splitsHaveChangedReturnsFalseWhenSplitsAreEmpty() throws HttpFetcherException { + SplitChange splitChange = SplitChange.create(-1, 100L, Collections.emptyList()); + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(splitChange, RuleBasedSegmentChange.create(-1, 100L, Collections.emptyList()))) + .thenReturn(TargetingRulesChange.create(SplitChange.create(100L, 100L, Collections.emptyList()), RuleBasedSegmentChange.create(100L, 100L, Collections.emptyList()))); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(100L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + assertTrue(!mSplitsSyncHelper.splitsHaveChanged()); + } + + @Test + public void ruleBasedSegmentsHaveChangedReturnsTrueWhenSegmentsAreNonEmpty() throws HttpFetcherException { + RuleBasedSegment segment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("test_segment"); + SplitChange splitChange = SplitChange.create(-1, 100L, Collections.emptyList()); + RuleBasedSegmentChange rbsChange = RuleBasedSegmentChange.create(-1, 100L, Collections.singletonList(segment)); + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(splitChange, rbsChange)) + .thenReturn(TargetingRulesChange.create(SplitChange.create(100L, 100L, Collections.emptyList()), RuleBasedSegmentChange.create(100L, 100L, Collections.emptyList()))); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(100L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + assertTrue(mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()); + } + + @Test + public void ruleBasedSegmentsHaveChangedReturnsFalseWhenSegmentsAreEmpty() throws HttpFetcherException { + SplitChange splitChange = SplitChange.create(-1, 100L, Collections.emptyList()); + RuleBasedSegmentChange rbsChange = RuleBasedSegmentChange.create(-1, 100L, Collections.emptyList()); + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(splitChange, rbsChange)) + .thenReturn(TargetingRulesChange.create(SplitChange.create(100L, 100L, Collections.emptyList()), RuleBasedSegmentChange.create(100L, 100L, Collections.emptyList()))); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(100L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + assertTrue(!mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()); + } + + @Test + public void getLastUpdatedSplitNamesReturnsFlagNamesAfterSync() throws HttpFetcherException { + // Use the actual split change from loadSplitChanges which contains real splits + SplitChange secondSplitChange = mTargetingRulesChange.getFeatureFlagsChange(); + secondSplitChange.since = mTargetingRulesChange.getFeatureFlagsChange().till; + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(secondSplitChange, RuleBasedSegmentChange.create(262325L, 262325L, Collections.emptyList()))); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(1506703262916L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(262325L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + List result = mSplitsSyncHelper.getLastUpdatedFlagNames(); + // The result should contain split names from the processed split change + // Since we're using real processor, it will process the actual splits from mTargetingRulesChange + assertNotNull(result); + // The exact number depends on the splits in the test data, but it should not be null + } + + @Test + public void getLastUpdatedFlagNamesPreservesLastNonEmptyChange() throws HttpFetcherException { + Split split = new Split(); + split.name = "split_1"; + split.status = Status.ACTIVE; + + SplitChange firstSplitChange = SplitChange.create(-1, 100L, Collections.singletonList(split)); + SplitChange secondSplitChange = SplitChange.create(100L, 100L, Collections.emptyList()); + + RuleBasedSegmentChange firstRbsChange = RuleBasedSegmentChange.create(-1, 10L, Collections.emptyList()); + RuleBasedSegmentChange secondRbsChange = RuleBasedSegmentChange.create(10L, 10L, Collections.emptyList()); + + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(firstSplitChange, firstRbsChange)) + .thenReturn(TargetingRulesChange.create(secondSplitChange, secondRbsChange)); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(10L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + List result = mSplitsSyncHelper.getLastUpdatedFlagNames(); + assertEquals(1, result.size()); + assertTrue(result.contains("split_1")); + } + + @Test + public void getLastUpdatedFlagNamesIncludesArchivedSplits() throws HttpFetcherException { + Split archivedSplit = new Split(); + archivedSplit.name = "archived_split"; + List archivedSplits = Arrays.asList(archivedSplit); + SplitChange splitChange = SplitChange.create(-1, 100L, new ArrayList<>()); + // Create ProcessedSplitChange with archived splits + ProcessedSplitChange processedSplitChange = new ProcessedSplitChange(new ArrayList<>(), archivedSplits, 100L, System.currentTimeMillis()); + + when(mSplitChangeProcessor.process(any())).thenReturn(processedSplitChange); + + SplitChange secondSplitChange = splitChange; + secondSplitChange.since = splitChange.till; + when(mSplitsFetcher.execute(any(), any())) + .thenReturn(TargetingRulesChange.create(secondSplitChange, RuleBasedSegmentChange.create(262325L, 262325L, Collections.emptyList()))); + when(mSplitsStorage.getTill()).thenReturn(-1L).thenReturn(100L); + when(mRuleBasedSegmentStorageProducer.getChangeNumber()).thenReturn(-1L).thenReturn(262325L); + + mSplitsSyncHelper.sync(getSinceChangeNumbers(-1, -1L), false, false, ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); + + List result = mSplitsSyncHelper.getLastUpdatedFlagNames(); + assertEquals(1, result.size()); + assertTrue(result.contains("archived_split")); + } } diff --git a/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java b/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java index 07f4fa6a0..7e2d8b3c0 100644 --- a/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java +++ b/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java @@ -16,7 +16,6 @@ public void getForMySegments() { assertEquals(config.getTaskType(), SplitTaskType.MY_SEGMENTS_SYNC); assertEquals(config.getUpdateEvent(), SplitInternalEvent.MY_SEGMENTS_UPDATED); - assertEquals(config.getFetchedEvent(), SplitInternalEvent.MY_SEGMENTS_FETCHED); assertEquals(config.getTelemetryOperationType(), OperationType.MY_SEGMENT); } } diff --git a/main/src/test/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTaskTest.java index bd5013088..37d05d47d 100644 --- a/main/src/test/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTaskTest.java @@ -1,5 +1,11 @@ package io.split.android.client.service.rules; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -12,11 +18,14 @@ import org.junit.Test; import java.util.Collections; +import java.util.List; import java.util.Set; import io.split.android.client.dtos.RuleBasedSegment; import io.split.android.client.events.ISplitEventsManager; +import io.split.android.client.events.SdkUpdateMetadata; import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.TypedTaskConverter; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; public class RuleBasedSegmentInPlaceUpdateTaskTest { @@ -45,7 +54,7 @@ public void splitEventsManagerIsNotifiedWithUpdateEvent() { mTask.execute(); - verify(mEventsManager).notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); } @Test @@ -60,7 +69,7 @@ public void splitEventsManagerIsNotNotifiedWhenUpdateResultIsFalse() { mTask.execute(); - verify(mEventsManager, times(0)).notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + verify(mEventsManager, times(0)).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); } @Test @@ -89,6 +98,31 @@ public void updateIsCalledOnStorage() { verify(mRuleBasedSegmentStorage).update(Set.of(ruleBasedSegment), Set.of(), changeNumber, null); } + @Test + public void segmentsUpdatedIncludesMetadataWithEmptyNames() { + RuleBasedSegment activeSegment = createRuleBasedSegment("active_segment"); + RuleBasedSegment archivedSegment = createRuleBasedSegment("archived_segment"); + long changeNumber = 123L; + + when(mChangeProcessor.process(activeSegment, changeNumber)).thenReturn( + new ProcessedRuleBasedSegmentChange(Set.of(activeSegment), Set.of(archivedSegment), changeNumber, System.currentTimeMillis())); + when(mRuleBasedSegmentStorage.update(Set.of(activeSegment), Set.of(archivedSegment), changeNumber, null)).thenReturn(true); + + mTask = getTask(activeSegment, changeNumber); + mTask.execute(); + + // SEGMENTS_UPDATE always has empty names + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), argThat(metadata -> { + if (metadata == null) return false; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List names = typedMeta.getNames(); + assertNotNull(names); + assertTrue("Names should be empty for SEGMENTS_UPDATE", names.isEmpty()); + assertEquals(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, typedMeta.getType()); + return true; + })); + } + @NonNull private RuleBasedSegmentInPlaceUpdateTask getTask(RuleBasedSegment ruleBasedSegment, long changeNumber) { return new RuleBasedSegmentInPlaceUpdateTask(mRuleBasedSegmentStorage, diff --git a/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java b/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java index a996b9aba..515d3dd3e 100644 --- a/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java +++ b/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java @@ -26,6 +26,8 @@ import io.split.android.client.RetryBackoffCounterTimerFactory; import io.split.android.client.SplitClientConfig; import io.split.android.client.events.ISplitEventsManager; +import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskBatchItem; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -40,6 +42,7 @@ import io.split.android.client.service.splits.SplitsUpdateTask; import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; +import io.split.android.client.storage.splits.SplitsStorage; public class FeatureFlagsSynchronizerImplTest { @@ -257,4 +260,54 @@ public String answer(InvocationOnMock invocation) { verify(mSingleThreadTaskExecutor).stopTask("12"); verify(mSingleThreadTaskExecutor, times(1)).schedule(eq(mockTask), anyLong(), anyLong(), any()); } + + @Test + public void loadAndSynchronizeNotifiesEventsManagerWithCorrectMetadataWhenSplitsLoadedFromStorage() { + long expectedTimestamp = 1234567890L; + SplitsStorage splitsStorage = mock(SplitsStorage.class); + when(splitsStorage.getUpdateTimestamp()).thenReturn(expectedTimestamp); + + // Set up mock tasks + LoadSplitsTask mockLoadTask = mock(LoadSplitsTask.class); + when(mockLoadTask.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.LOAD_LOCAL_SPLITS)); + when(mTaskFactory.createLoadSplitsTask()).thenReturn(mockLoadTask); + + LoadRuleBasedSegmentsTask mockLoadRuleBasedSegmentsTask = mock(LoadRuleBasedSegmentsTask.class); + when(mockLoadRuleBasedSegmentsTask.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.LOAD_LOCAL_RULE_BASED_SEGMENTS)); + when(mTaskFactory.createLoadRuleBasedSegmentsTask()).thenReturn(mockLoadRuleBasedSegmentsTask); + + FilterSplitsInCacheTask mockFilterTask = mock(FilterSplitsInCacheTask.class); + when(mockFilterTask.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.FILTER_SPLITS_CACHE)); + when(mTaskFactory.createFilterSplitsInCacheTask()).thenReturn(mockFilterTask); + + SplitsSyncTask mockSplitSyncTask = mock(SplitsSyncTask.class); + when(mockSplitSyncTask.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.SPLITS_SYNC)); + when(mTaskFactory.createSplitsSyncTask(true)).thenReturn(mockSplitSyncTask); + + FeatureFlagsSynchronizerImpl synchronizer = new FeatureFlagsSynchronizerImpl( + mConfig, mTaskExecutor, mSingleThreadTaskExecutor, mTaskFactory, + mEventsManager, mRetryBackoffCounterFactory, mPushManagerEventBroadcaster, splitsStorage); + + ArgumentCaptor> batchCaptor = ArgumentCaptor.forClass(List.class); + + synchronizer.loadAndSynchronize(); + + verify(mTaskExecutor).executeSerially(batchCaptor.capture()); + List batch = batchCaptor.getValue(); + + SplitTaskBatchItem loadSplitsItem = batch.get(2); + SplitTaskExecutionListener listener = loadSplitsItem.getListener(); + + listener.taskExecuted(SplitTaskExecutionInfo.success(SplitTaskType.LOAD_LOCAL_SPLITS)); + + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(EventMetadata.class); + verify(mEventsManager).notifyInternalEvent( + eq(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE), + metadataCaptor.capture()); + + EventMetadata capturedMetadata = metadataCaptor.getValue(); + + assertEquals(false, capturedMetadata.get("initialCacheLoad")); + assertEquals(expectedTimestamp, capturedMetadata.get("lastUpdateTimestamp")); + } } diff --git a/main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java b/main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java new file mode 100644 index 000000000..252c29696 --- /dev/null +++ b/main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java @@ -0,0 +1,87 @@ +package io.split.android.client.service.synchronizer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import io.split.android.client.events.metadata.EventMetadata; +import io.split.android.client.events.ISplitEventsManager; +import io.split.android.client.events.SplitInternalEvent; +import io.split.android.client.service.executor.SplitTaskExecutionInfo; +import io.split.android.client.service.executor.SplitTaskType; + +public class LoadLocalDataListenerTest { + + private ISplitEventsManager mEventsManager; + + @Before + public void setUp() { + mEventsManager = mock(ISplitEventsManager.class); + } + + @Test + public void taskExecutedSuccessFiresEventWithoutMetadataWhenProviderIsNull() { + LoadLocalDataListener listener = new LoadLocalDataListener( + mEventsManager, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); + + listener.taskExecuted(SplitTaskExecutionInfo.success(SplitTaskType.LOAD_LOCAL_SPLITS)); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE), isNull()); + } + + @Test + public void taskExecutedSuccessFiresEventWithMetadataWhenProviderIsNotNull() { + EventMetadata mockMetadata = mock(EventMetadata.class); + LoadLocalDataListener.MetadataProvider provider = () -> mockMetadata; + + LoadLocalDataListener listener = new LoadLocalDataListener( + mEventsManager, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, provider); + + listener.taskExecuted(SplitTaskExecutionInfo.success(SplitTaskType.LOAD_LOCAL_SPLITS)); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE), eq(mockMetadata)); + } + + @Test + public void taskExecutedErrorDoesNotFireEvent() { + LoadLocalDataListener listener = new LoadLocalDataListener( + mEventsManager, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); + + listener.taskExecuted(SplitTaskExecutionInfo.error(SplitTaskType.LOAD_LOCAL_SPLITS)); + + verify(mEventsManager, never()).notifyInternalEvent(any(), any()); + } + + @Test + public void metadataProviderIsCalledWhenTaskSucceeds() { + LoadLocalDataListener.MetadataProvider provider = mock(LoadLocalDataListener.MetadataProvider.class); + when(provider.getMetadata()).thenReturn(null); + + LoadLocalDataListener listener = new LoadLocalDataListener( + mEventsManager, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, provider); + + listener.taskExecuted(SplitTaskExecutionInfo.success(SplitTaskType.LOAD_LOCAL_SPLITS)); + + verify(provider).getMetadata(); + } + + @Test + public void metadataProviderIsNotCalledWhenTaskFails() { + LoadLocalDataListener.MetadataProvider provider = mock(LoadLocalDataListener.MetadataProvider.class); + + LoadLocalDataListener listener = new LoadLocalDataListener( + mEventsManager, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, provider); + + listener.taskExecuted(SplitTaskExecutionInfo.error(SplitTaskType.LOAD_LOCAL_SPLITS)); + + verify(provider, never()).getMetadata(); + } +} diff --git a/main/src/test/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImplTest.java b/main/src/test/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImplTest.java index d3a049f28..b91b430e0 100644 --- a/main/src/test/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImplTest.java +++ b/main/src/test/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImplTest.java @@ -3,6 +3,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; import org.junit.Before; import org.junit.Test; @@ -65,4 +67,33 @@ public void getUniqueAmountReturnsUniqueSegmentCount() { assertEquals(4, distinctAmount); } + + @Test + public void clearCallsPersistentStorageClear() { + mContainer.clear(); + + verify(mPersistentMySegmentsStorage).clear(); + } + + @Test + public void clearClearsInMemoryStorageForExistingKeys() { + String userKey = "user_key"; + MySegmentsStorage storageForKey = mContainer.getStorageForKey(userKey); + storageForKey.set(SegmentsChange.create(new HashSet<>(Arrays.asList("s1", "s2")), -1L)); + + mContainer.clear(); + + assertTrue(storageForKey.getAll().isEmpty()); + } + + @Test + public void clearCallsPersistentStorageClearBeforeSettingEmptySegments() { + String userKey = "user_key"; + mContainer.getStorageForKey(userKey); + + mContainer.clear(); + + // Verify persistent storage clear was called + verify(mPersistentMySegmentsStorage).clear(); + } } diff --git a/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMyLargeSegmentsStorageTest.java b/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMyLargeSegmentsStorageTest.java index 1e594ee48..85bd35277 100644 --- a/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMyLargeSegmentsStorageTest.java +++ b/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMyLargeSegmentsStorageTest.java @@ -110,4 +110,11 @@ public void getSnapshotReturnsDecryptedValues() { assertTrue(result.getNames().containsAll(Arrays.asList("segment1", "segment2", "segment3"))); } + + @Test + public void clearCallsDeleteAllOnDao() { + mStorage.clear(); + + verify(mDao).deleteAll(); + } } diff --git a/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorageTest.java b/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorageTest.java index 112b5e4cc..4fa61edfc 100644 --- a/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorageTest.java +++ b/main/src/test/java/io/split/android/client/storage/mysegments/SqLitePersistentMySegmentsStorageTest.java @@ -110,4 +110,11 @@ public void getSnapshotReturnsDecryptedValues() { assertTrue(result.getNames().containsAll(Arrays.asList("segment1", "segment2", "segment3"))); } + + @Test + public void clearCallsDeleteAllOnDao() { + mStorage.clear(); + + verify(mDao).deleteAll(); + } } diff --git a/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java b/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java index bc8ab7410..50fec3e8f 100644 --- a/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java +++ b/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java @@ -36,7 +36,7 @@ public class SplitClientImplFactory { public static SplitClientImpl get(Key key, SplitsStorage splitsStorage) { SplitClientConfig cfg = SplitClientConfig.builder().build(); - SplitEventsManager eventsManager = new SplitEventsManager(cfg, new SplitTaskExecutorStub()); + SplitEventsManager eventsManager = new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()); SplitParser splitParser = getSplitParser(); TelemetryStorage telemetryStorage = mock(TelemetryStorage.class); TreatmentManagerFactory treatmentManagerFactory = new TreatmentManagerFactoryImpl( @@ -73,7 +73,7 @@ public static SplitClientImpl get(Key key, ImpressionListener impressionListener splitParser, impressionListener, cfg, - new SplitEventsManager(cfg, new SplitTaskExecutorStub()), + new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()), mock(EventsTracker.class), mock(AttributesManager.class), mock(SplitValidator.class), 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 49b372e7e..b8eb66b9f 100644 --- a/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java +++ b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java @@ -1,7 +1,11 @@ package io.split.android.fake; +import androidx.annotation.Nullable; + +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.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitInternalEvent; @@ -18,7 +22,12 @@ public SplitEventExecutorResources getExecutorResources() { @Override public void notifyInternalEvent(SplitInternalEvent internalEvent) { + notifyInternalEvent(internalEvent, null); + } + @Override + public void notifyInternalEvent(SplitInternalEvent internalEvent, @Nullable EventMetadata metadata) { + // Stub implementation - does nothing } @Override @@ -33,4 +42,9 @@ public boolean eventAlreadyTriggered(SplitEvent event) { } return false; } + + @Override + public void registerEventListener(SplitEventListener listener) { + // Stub implementation - does nothing + } } diff --git a/settings.gradle b/settings.gradle index 0f38584f2..b584365a6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,7 @@ rootProject.name = 'android-client' +include ':api' include ':logger' include ':main' +include ':events' +include ':events-domain' diff --git a/sonar-project.properties b/sonar-project.properties index 930a7d2dc..f598dd559 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,20 +2,41 @@ sonar.projectKey=splitio_android-client sonar.projectName=android-client -# Path to source directories -sonar.sources=src/main/java +# Path to source directories (multi-module) +# Root project contains modules: main, events, logger +sonar.sources=main/src/main/java,events/src/main/java,logger/src/main/java -# Path to compiled classes -sonar.java.binaries=build/intermediates/runtime_library_classes_dir/debug +# Path to compiled classes (multi-module) +# Include binary paths for all modules: main, events, logger +sonar.java.binaries=\ + main/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + events/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + logger/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes -# Path to test directories -sonar.tests=src/test/java,src/androidTest/java,src/sharedTest/java +# Path to dependency/libraries jars (multi-module) +sonar.java.libraries=\ + main/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + main/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + main/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + main/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + events/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + events/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + events/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + events/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + logger/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + logger/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + logger/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + logger/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar + +# Path to test directories (multi-module) +# Only include test source folders that are guaranteed to exist in all environments +sonar.tests=main/src/test/java,main/src/androidTest/java,main/src/sharedTest/java,events/src/test/java,logger/src/test/java # Encoding of the source code sonar.sourceEncoding=UTF-8 -# Include test coverage reports - prioritize combined report -sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml +# Include aggregate test coverage report from all modules +sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml # Exclusions sonar.exclusions=**/R.class,**/R$*.class,**/BuildConfig.*,**/Manifest*.*,**/*Test*.*,android/**/*.* diff --git a/split-proguard-rules.pro b/split-proguard-rules.pro index 8dc624df9..de3caa063 100644 --- a/split-proguard-rules.pro +++ b/split-proguard-rules.pro @@ -13,6 +13,7 @@ -keep class io.split.android.client.service.sseclient.SseAuthenticationResponse { *; } -keep class io.split.android.client.service.sseclient.notifications.** { *; } -keepattributes Signature +-keepattributes MethodParameters -keep class com.google.gson.reflect.TypeToken { *; } -keep class * extends com.google.gson.reflect.TypeToken -dontwarn java.beans.BeanInfo @@ -26,6 +27,9 @@ # removes such information by default, so configure it to keep all of it. -keepattributes Signature +# Preserve method parameter names so consumers see actual parameter names instead of s0, s1, s2, etc. +-keepattributes MethodParameters + # For using GSON @Expose annotation -keepattributes *Annotation*