From b4919de59bbe6ed37f1864718c17c3b8f498ebc6 Mon Sep 17 00:00:00 2001 From: gthea Date: Wed, 26 Nov 2025 16:39:48 -0300 Subject: [PATCH 01/24] Events module (#827) --- .github/workflows/sonarqube.yml | 154 ++++++++--------- build.gradle | 53 +++++- events/.gitignore | 1 + events/build.gradle | 21 +++ events/consumer-rules.pro | 0 events/proguard-rules.pro | 21 +++ events/src/main/AndroidManifest.xml | 4 + .../java/io/harness/events/EventDelivery.java | 12 ++ .../java/io/harness/events/EventHandler.java | 13 ++ .../java/io/harness/events/EventsManager.java | 50 ++++++ .../harness/events/EventsManagerConfig.java | 91 ++++++++++ .../events/EventsManagerConfigTest.java | 155 ++++++++++++++++++ gradle/common-android-library.gradle | 3 + gradle/jacoco-android.gradle | 15 +- gradle/jacoco-root.gradle | 100 +++++++++++ gradle/libs.versions.toml | 1 - main/build.gradle | 2 +- settings.gradle | 1 + sonar-project.properties | 37 ++++- 19 files changed, 622 insertions(+), 112 deletions(-) create mode 100644 events/.gitignore create mode 100644 events/build.gradle create mode 100644 events/consumer-rules.pro create mode 100644 events/proguard-rules.pro create mode 100644 events/src/main/AndroidManifest.xml create mode 100644 events/src/main/java/io/harness/events/EventDelivery.java create mode 100644 events/src/main/java/io/harness/events/EventHandler.java create mode 100644 events/src/main/java/io/harness/events/EventsManager.java create mode 100644 events/src/main/java/io/harness/events/EventsManagerConfig.java create mode 100644 events/src/test/java/io/harness/events/EventsManagerConfigTest.java create mode 100644 gradle/jacoco-root.gradle diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index dd26c3568..4d16c87f5 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -40,109 +40,91 @@ jobs: # 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..." + # Run tests - continue even if some tests fail + ./gradlew testDebugUnitTest --stacktrace || echo "Some tests failed, but continuing to generate coverage report..." - # Create the target directory - mkdir -p build/intermediates/runtime_library_classes_dir/debug + # Generate JaCoCo aggregate report separately (ensures it runs even if tests failed) + ./gradlew jacocoRootReport --stacktrace - # 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 + # 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 "" + + echo "Checking compiled class files for each module:" + for module in main events logger; 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 test execution results:" + echo "Checking JaCoCo execution data for each module:" + for module in main events logger; 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 "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 " + 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 +140,7 @@ repositories { dependencies { include project(':main') include project(':logger') + include project(':events') } def splitPOM = { 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/build.gradle b/events/build.gradle new file mode 100644 index 000000000..89ae1775c --- /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 { + implementation libs.annotation + + 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/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..70f915ff5 --- /dev/null +++ b/events/src/main/java/io/harness/events/EventsManager.java @@ -0,0 +1,50 @@ +package io.harness.events; + +import androidx.annotation.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..5b9fe38af --- /dev/null +++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java @@ -0,0 +1,91 @@ +package io.harness.events; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.HashMap; +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 internals (OR) + 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; + + /** + * Creates a new EventsManagerConfig. + * + * @param requireAll External events that require ALL listed internals (AND) + * @param requireAny External events triggered by ANY of the listed internals (OR) + * @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) + */ + public EventsManagerConfig(Map> requireAll, + Map> requireAny, + Map> prerequisites, + Map> suppressedBy, + Map executionLimits) { + 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)); + } + + public static EventsManagerConfig empty() { + return new EventsManagerConfig<>(Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap()); + } + + @NonNull + public Map> getRequireAll() { + return mRequireAll; + } + + @NonNull + public Map> getRequireAny() { + return mRequireAny; + } + + @NonNull + public Map> getPrerequisites() { + return mPrerequisites; + } + + @NonNull + public Map> getSuppressedBy() { + return mSuppressedBy; + } + + @NonNull + public Map getExecutionLimits() { + return mExecutionLimits; + } +} 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..84db4b209 --- /dev/null +++ b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java @@ -0,0 +1,155 @@ +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.Assert; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class EventsManagerConfigTest { + + @Test + public void nullInputMapsCreateEmptyMaps() { + EventsManagerConfig config = + new EventsManagerConfig<>(null, null, null, null, null); + + assertTrue(config.getRequireAll().isEmpty()); + assertTrue(config.getRequireAny().isEmpty()); + assertTrue(config.getPrerequisites().isEmpty()); + assertTrue(config.getSuppressedBy().isEmpty()); + assertTrue(config.getExecutionLimits().isEmpty()); + } + + @Test + public void mutationsToInputMapsDoNotModifyConfig() { + Map> requireAll = new HashMap<>(); + Map> requireAny = new HashMap<>(); + Map> prerequisites = new HashMap<>(); + Map> suppressedBy = new HashMap<>(); + Map executionLimits = new HashMap<>(); + + Set internals = new HashSet<>(); + internals.add("I1"); + + requireAll.put("E1", internals); + requireAny.put("E1", internals); + prerequisites.put("E1", Collections.singleton("E0")); + suppressedBy.put("E1", Collections.singleton("E2")); + executionLimits.put("E1", 3); + + EventsManagerConfig config = + new EventsManagerConfig<>(requireAll, requireAny, prerequisites, suppressedBy, executionLimits); + + // Mutate the original maps after construction + requireAll.put("E2", Collections.singleton("I2")); + requireAny.clear(); + prerequisites.put("E2", Collections.singleton("E3")); + suppressedBy.remove("E1"); + executionLimits.put("E2", 5); + + Map> requireAllFromConfig = config.getRequireAll(); + Map> requireAnyFromConfig = config.getRequireAny(); + Map> prerequisitesFromConfig = config.getPrerequisites(); + Map> suppressedByFromConfig = config.getSuppressedBy(); + Map executionLimitsFromConfig = config.getExecutionLimits(); + + assertEquals(1, requireAllFromConfig.size()); + assertTrue(requireAllFromConfig.containsKey("E1")); + assertFalse(requireAllFromConfig.containsKey("E2")); + + assertEquals(1, requireAnyFromConfig.size()); + assertTrue(requireAnyFromConfig.containsKey("E1")); + + assertEquals(1, prerequisitesFromConfig.size()); + assertTrue(prerequisitesFromConfig.containsKey("E1")); + assertFalse(prerequisitesFromConfig.containsKey("E2")); + + assertEquals(1, suppressedByFromConfig.size()); + assertTrue(suppressedByFromConfig.containsKey("E1")); + + assertEquals(1, executionLimitsFromConfig.size()); + assertTrue(executionLimitsFromConfig.containsKey("E1")); + assertFalse(executionLimitsFromConfig.containsKey("E2")); + } + + @Test + public void returnedMapsAreUnmodifiable() { + Map> requireAll = new HashMap<>(); + requireAll.put("E1", Collections.singleton("I1")); + + Map> requireAny = new HashMap<>(); + requireAny.put("E1", Collections.singleton("I1")); + + Map> prerequisites = new HashMap<>(); + prerequisites.put("E1", Collections.singleton("E0")); + + Map> suppressedBy = new HashMap<>(); + suppressedBy.put("E1", Collections.singleton("E2")); + + Map executionLimits = new HashMap<>(); + executionLimits.put("E1", 3); + + EventsManagerConfig config = + new EventsManagerConfig<>(requireAll, requireAny, prerequisites, suppressedBy, executionLimits); + + 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("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 + } + } + + @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()); + + try { + config.getRequireAll().put("E1", Collections.singleton("I1")); + Assert.fail("getRequireAll() from empty() should be unmodifiable"); + } catch (UnsupportedOperationException expected) { + // expected + } + } +} diff --git a/gradle/common-android-library.gradle b/gradle/common-android-library.gradle index de5ee9713..93f2c7962 100644 --- a/gradle/common-android-library.gradle +++ b/gradle/common-android-library.gradle @@ -24,3 +24,6 @@ if (kotlinCompileClass != null) { } } +// 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..c35a03090 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,4 +46,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/main/build.gradle b/main/build.gradle index 3654b45fe..241ba786e 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -3,7 +3,6 @@ plugins { } apply from: "$rootDir/gradle/common-android-library.gradle" -apply from: "$rootDir/gradle/jacoco-android.gradle" android { namespace 'io.split.android.client.main' @@ -52,6 +51,7 @@ android { dependencies { // Internal module dependencies api project(':logger') + implementation project(':events') // External dependencies implementation libs.roomRuntime diff --git a/settings.gradle b/settings.gradle index 0f38584f2..246c9b203 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,3 +2,4 @@ rootProject.name = 'android-client' include ':logger' include ':main' +include ':events' 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/**/*.* From 5f2b029b69d05daddd2f5572b60b2945443f07ab Mon Sep 17 00:00:00 2001 From: gthea Date: Wed, 26 Nov 2025 17:15:41 -0300 Subject: [PATCH 02/24] Events Manager implementation (#828) --- .github/workflows/sonarqube.yml | 6 +- build.gradle | 18 +- events/README.md | 5 + .../harness/events/EventsManagerConfig.java | 119 ++++- .../io/harness/events/EventsManagerCore.java | 222 ++++++++++ .../events/EventsManagerConfigTest.java | 130 +++--- .../io/harness/events/EventsManagerTest.java | 410 ++++++++++++++++++ gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 826 insertions(+), 86 deletions(-) create mode 100644 events/README.md create mode 100644 events/src/main/java/io/harness/events/EventsManagerCore.java create mode 100644 events/src/test/java/io/harness/events/EventsManagerTest.java diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 4d16c87f5..9ff19af8c 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -38,13 +38,13 @@ jobs: - name: Build project and run tests with coverage run: | # Build the project - ./gradlew assembleDebug --stacktrace + ./gradlew assembleDebug # Run tests - continue even if some tests fail - ./gradlew testDebugUnitTest --stacktrace || echo "Some tests failed, but continuing to generate coverage report..." + ./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 --stacktrace + ./gradlew jacocoRootReport # Log report location for debugging REPORT_PATH="build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" diff --git a/build.gradle b/build.gradle index 37e71c030..cf29b1342 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:9.0.0-alpha13' + classpath 'com.android.tools.build:gradle:9.0.0-beta02' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0' classpath "com.vanniktech:gradle-maven-publish-plugin:0.34.0" } @@ -41,36 +41,36 @@ allprojects { 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 -> + .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") diff --git a/events/README.md b/events/README.md new file mode 100644 index 000000000..18af5cb60 --- /dev/null +++ b/events/README.md @@ -0,0 +1,5 @@ +# Events module + +This module provides a generic events management system. + +Allows the definition of internal and external events interdependencies, as well as registration. diff --git a/events/src/main/java/io/harness/events/EventsManagerConfig.java b/events/src/main/java/io/harness/events/EventsManagerConfig.java index 5b9fe38af..8172854d6 100644 --- a/events/src/main/java/io/harness/events/EventsManagerConfig.java +++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java @@ -2,8 +2,10 @@ import androidx.annotation.NonNull; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -34,7 +36,7 @@ public final class EventsManagerConfig { * @param suppressedBy External-event guards: if any of these have fired, suppress E * @param executionLimits Execution policy: max executions per external event (-1 = unlimited) */ - public EventsManagerConfig(Map> requireAll, + private EventsManagerConfig(Map> requireAll, Map> requireAny, Map> prerequisites, Map> suppressedBy, @@ -88,4 +90,119 @@ public Map> getSuppressedBy() { public Map getExecutionLimits() { return mExecutionLimits; } + + /** + * 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 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. + * + * @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) { + mRequireAny.put(externalEvent, new HashSet<>(Arrays.asList(internalEvents))); + 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; + } + + /** + * 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 + ); + } + } } 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..aee43e8a1 --- /dev/null +++ b/events/src/main/java/io/harness/events/EventsManagerCore.java @@ -0,0 +1,222 @@ +package io.harness.events; + +import androidx.annotation.NonNull; + +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 + */ +public 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<>(); + + @NonNull + private final EventsManagerConfig mConfig; + @NonNull + private final EventDelivery mDelivery; + + @NonNull + 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) { + return mTriggerCount.containsKey(event); + } + } + + @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); + currentSeenInternal = new HashSet<>(mSeenInternal); + } + + // Evaluate AND external events + for (Map.Entry> entry : mConfig.getRequireAll().entrySet()) { + E external = entry.getKey(); + Set required = entry.getValue(); + + if (!required.isEmpty() && currentSeenInternal.containsAll(required)) { + triggerIfConditionsMet(external, metadata); + } + } + + // Evaluate OR external events + for (Map.Entry> entry : mConfig.getRequireAny().entrySet()) { + E external = entry.getKey(); + if (entry.getValue().contains(event)) { + triggerIfConditionsMet(external, metadata); + } + } + } + + private void triggerIfConditionsMet(E event, M metadata) { + if (!prerequisitesSatisfied(event) || isSuppressed(event)) { + return; + } + trigger(event, metadata); + } + + private void 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; + } + + 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); + } + } + + 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; + } +} diff --git a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java index 84db4b209..e53ec9eba 100644 --- a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java +++ b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java @@ -8,17 +8,12 @@ import org.junit.Test; import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; public class EventsManagerConfigTest { @Test - public void nullInputMapsCreateEmptyMaps() { - EventsManagerConfig config = - new EventsManagerConfig<>(null, null, null, null, null); + public void emptyBuilderCreatesEmptyMaps() { + EventsManagerConfig config = EventsManagerConfig.builder().build(); assertTrue(config.getRequireAll().isEmpty()); assertTrue(config.getRequireAny().isEmpty()); @@ -28,76 +23,67 @@ public void nullInputMapsCreateEmptyMaps() { } @Test - public void mutationsToInputMapsDoNotModifyConfig() { - Map> requireAll = new HashMap<>(); - Map> requireAny = new HashMap<>(); - Map> prerequisites = new HashMap<>(); - Map> suppressedBy = new HashMap<>(); - Map executionLimits = new HashMap<>(); - - Set internals = new HashSet<>(); - internals.add("I1"); - - requireAll.put("E1", internals); - requireAny.put("E1", internals); - prerequisites.put("E1", Collections.singleton("E0")); - suppressedBy.put("E1", Collections.singleton("E2")); - executionLimits.put("E1", 3); - - EventsManagerConfig config = - new EventsManagerConfig<>(requireAll, requireAny, prerequisites, suppressedBy, executionLimits); - - // Mutate the original maps after construction - requireAll.put("E2", Collections.singleton("I2")); - requireAny.clear(); - prerequisites.put("E2", Collections.singleton("E3")); - suppressedBy.remove("E1"); - executionLimits.put("E2", 5); - - Map> requireAllFromConfig = config.getRequireAll(); - Map> requireAnyFromConfig = config.getRequireAny(); - Map> prerequisitesFromConfig = config.getPrerequisites(); - Map> suppressedByFromConfig = config.getSuppressedBy(); - Map executionLimitsFromConfig = config.getExecutionLimits(); - - assertEquals(1, requireAllFromConfig.size()); - assertTrue(requireAllFromConfig.containsKey("E1")); - assertFalse(requireAllFromConfig.containsKey("E2")); - - assertEquals(1, requireAnyFromConfig.size()); - assertTrue(requireAnyFromConfig.containsKey("E1")); - - assertEquals(1, prerequisitesFromConfig.size()); - assertTrue(prerequisitesFromConfig.containsKey("E1")); - assertFalse(prerequisitesFromConfig.containsKey("E2")); - - assertEquals(1, suppressedByFromConfig.size()); - assertTrue(suppressedByFromConfig.containsKey("E1")); - - assertEquals(1, executionLimitsFromConfig.size()); - assertTrue(executionLimitsFromConfig.containsKey("E1")); - assertFalse(executionLimitsFromConfig.containsKey("E2")); + public void builderCreatesConfigWithAllFields() { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll("E1", "I1", "I2") + .requireAny("E2", "I3") + .prerequisite("E1", "E0") + .suppressedBy("E1", "E2") + .executionLimit("E1", 3) + .build(); + + assertEquals(1, config.getRequireAll().size()); + assertTrue(config.getRequireAll().get("E1").contains("I1")); + assertTrue(config.getRequireAll().get("E1").contains("I2")); + + assertEquals(1, config.getRequireAny().size()); + assertTrue(config.getRequireAny().get("E2").contains("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")); } @Test - public void returnedMapsAreUnmodifiable() { - Map> requireAll = new HashMap<>(); - requireAll.put("E1", Collections.singleton("I1")); - - Map> requireAny = new HashMap<>(); - requireAny.put("E1", Collections.singleton("I1")); - - Map> prerequisites = new HashMap<>(); - prerequisites.put("E1", Collections.singleton("E0")); - - Map> suppressedBy = new HashMap<>(); - suppressedBy.put("E1", Collections.singleton("E2")); + 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")); + } - Map executionLimits = new HashMap<>(); - executionLimits.put("E1", 3); + @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")); + } - EventsManagerConfig config = - new EventsManagerConfig<>(requireAll, requireAny, prerequisites, suppressedBy, executionLimits); + @Test + public void returnedMapsAreUnmodifiable() { + EventsManagerConfig config = EventsManagerConfig.builder() + .requireAll("E1", "I1") + .requireAny("E1", "I1") + .prerequisite("E1", "E0") + .suppressedBy("E1", "E2") + .executionLimit("E1", 3) + .build(); try { config.getRequireAll().put("E2", Collections.singleton("I2")); 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..6de433c73 --- /dev/null +++ b/events/src/test/java/io/harness/events/EventsManagerTest.java @@ -0,0 +1,410 @@ +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.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()); + + // SEASONING_ADDED before DISH_SERVED - should not fire + eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); + + // Trigger DISH_SERVED + 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)); + + // Now SEASONING_ADDED should trigger SEASONING_ADJUSTED + 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()); + } +} 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 From 9148f78473f6521f2540ca1df24d57aa63a76224 Mon Sep 17 00:00:00 2001 From: gthea Date: Fri, 28 Nov 2025 12:47:56 -0300 Subject: [PATCH 03/24] API and events domain modules (#829) --- api/.gitignore | 1 + api/README.md | 6 ++ api/build.gradle | 22 +++++++ api/consumer-rules.pro | 0 api/proguard-rules.pro | 22 +++++++ api/src/androidTest/java/.gitkeep | 0 api/src/main/AndroidManifest.xml | 5 ++ .../android/client/api/EventMetadata.java | 58 +++++++++++++++++++ .../java/io/split/android/client/api/.gitkeep | 0 build.gradle | 2 + events-domain/.gitignore | 1 + events-domain/README.md | 3 + events-domain/build.gradle | 24 ++++++++ events-domain/consumer-rules.pro | 0 events-domain/proguard-rules.pro | 22 +++++++ events-domain/src/androidTest/java/.gitkeep | 0 events-domain/src/main/AndroidManifest.xml | 5 ++ .../io/split/android/client/events/.gitkeep | 0 .../io/split/android/client/events/.gitkeep | 0 main/build.gradle | 6 +- settings.gradle | 2 + 21 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 api/.gitignore create mode 100644 api/README.md create mode 100644 api/build.gradle create mode 100644 api/consumer-rules.pro create mode 100644 api/proguard-rules.pro create mode 100644 api/src/androidTest/java/.gitkeep create mode 100644 api/src/main/AndroidManifest.xml create mode 100644 api/src/main/java/io/split/android/client/api/EventMetadata.java create mode 100644 api/src/test/java/io/split/android/client/api/.gitkeep create mode 100644 events-domain/.gitignore create mode 100644 events-domain/README.md create mode 100644 events-domain/build.gradle create mode 100644 events-domain/consumer-rules.pro create mode 100644 events-domain/proguard-rules.pro create mode 100644 events-domain/src/androidTest/java/.gitkeep create mode 100644 events-domain/src/main/AndroidManifest.xml create mode 100644 events-domain/src/main/java/io/split/android/client/events/.gitkeep create mode 100644 events-domain/src/test/java/io/split/android/client/events/.gitkeep diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/api/README.md b/api/README.md new file mode 100644 index 000000000..0a58d3927 --- /dev/null +++ b/api/README.md @@ -0,0 +1,6 @@ +# API module + +This module contains the public API interfaces and types exposed to consumers of the Split SDK. + +Classes in this module are part of the public API contract and should maintain backwards compatibility. + diff --git a/api/build.gradle b/api/build.gradle new file mode 100644 index 000000000..c32f26549 --- /dev/null +++ b/api/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.api' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} + diff --git a/api/consumer-rules.pro b/api/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/api/proguard-rules.pro b/api/proguard-rules.pro new file mode 100644 index 000000000..cf504086a --- /dev/null +++ b/api/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/api/src/androidTest/java/.gitkeep b/api/src/androidTest/java/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/api/src/main/AndroidManifest.xml b/api/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cf2d636b6 --- /dev/null +++ b/api/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/api/src/main/java/io/split/android/client/api/EventMetadata.java b/api/src/main/java/io/split/android/client/api/EventMetadata.java new file mode 100644 index 000000000..e1648c388 --- /dev/null +++ b/api/src/main/java/io/split/android/client/api/EventMetadata.java @@ -0,0 +1,58 @@ +package io.split.android.client.api; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +/** + * Represents metadata associated with SDK events. + *

+ * Values are sanitized to only allow String, Number, Boolean, or List<String>. + */ +public interface EventMetadata { + + /** + * Returns the set of keys in this metadata. + * + * @return set of keys + */ + @NonNull + Set keys(); + + /** + * Returns the collection of values in this metadata. + * + * @return collection of values + */ + @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); + + /** + * Returns a copy of the underlying data as a Map. + * + * @return a copy of the metadata map + */ + @NonNull + Map toMap(); +} + 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/build.gradle b/build.gradle index cf29b1342..9e7652e16 100644 --- a/build.gradle +++ b/build.gradle @@ -141,6 +141,8 @@ dependencies { include project(':main') include project(':logger') include project(':events') + include project(':events-domain') + include project(':api') } def splitPOM = { 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..518ad3f6d --- /dev/null +++ b/events-domain/README.md @@ -0,0 +1,3 @@ +# Events Domain module + +This module provides Split-specific events management implementation. diff --git a/events-domain/build.gradle b/events-domain/build.gradle new file mode 100644 index 000000000..d23b67ccc --- /dev/null +++ b/events-domain/build.gradle @@ -0,0 +1,24 @@ +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') + + 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/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/main/build.gradle b/main/build.gradle index 241ba786e..dba2405f2 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -49,9 +49,11 @@ android { } dependencies { - // Internal module dependencies + // Public api modules api project(':logger') - implementation project(':events') + api project(':api') + // Internal module dependencies + implementation project(':events-domain') // External dependencies implementation libs.roomRuntime diff --git a/settings.gradle b/settings.gradle index 246c9b203..b584365a6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,7 @@ rootProject.name = 'android-client' +include ':api' include ':logger' include ':main' include ':events' +include ':events-domain' From a94cac9b5888e549121311f4b7983fa1a6ec0f4e Mon Sep 17 00:00:00 2001 From: gthea Date: Fri, 28 Nov 2025 12:48:35 -0300 Subject: [PATCH 04/24] Event Metadata (#830) --- .github/workflows/sonarqube.yml | 2 +- build.gradle | 2 +- events-domain/README.md | 2 +- .../events/metadata/EventMetadataBuilder.java | 103 +++++++++ .../events/metadata/EventMetadataImpl.java | 74 ++++++ .../events/metadata/MetadataValidator.java | 8 + .../metadata/MetadataValidatorImpl.java | 31 +++ .../metadata/EventMetadataBuilderTest.java | 201 ++++++++++++++++ .../metadata/EventMetadataImplTest.java | 215 ++++++++++++++++++ .../metadata/MetadataValidatorImplTest.java | 132 +++++++++++ 10 files changed, 767 insertions(+), 3 deletions(-) create mode 100644 events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataBuilder.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataImpl.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidator.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/metadata/MetadataValidatorImpl.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataBuilderTest.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataImplTest.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/metadata/MetadataValidatorImplTest.java diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 9ff19af8c..f6e2d570b 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -42,7 +42,7 @@ jobs: # 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 diff --git a/build.gradle b/build.gradle index 9e7652e16..110bd8d03 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ apply plugin: 'com.vanniktech.maven.publish' apply from: "$rootDir/gradle/jacoco-root.gradle" ext { - splitVersion = '5.4.3-rc4' + splitVersion = '5.5.0-rc1' jacocoVersion = '0.8.8' } diff --git a/events-domain/README.md b/events-domain/README.md index 518ad3f6d..b64917133 100644 --- a/events-domain/README.md +++ b/events-domain/README.md @@ -1,3 +1,3 @@ # Events Domain module -This module provides Split-specific events management implementation. +This module provides Split SDK specific events management implementation. 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..f8208d4e7 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataBuilder.java @@ -0,0 +1,103 @@ +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; + +import io.split.android.client.api.EventMetadata; + +/** + * 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. + */ +public class EventMetadataBuilder { + + private static final MetadataValidator DEFAULT_VALIDATOR = new MetadataValidatorImpl(); + + private final Map mData = new HashMap<>(); + private final MetadataValidator mValidator; + + public 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/EventMetadataImpl.java b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataImpl.java new file mode 100644 index 000000000..8c9b73ffa --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataImpl.java @@ -0,0 +1,74 @@ +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; +import java.util.Set; + +import io.split.android.client.api.EventMetadata; + +/** + * 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); + } + + @NonNull + @Override + public Set keys() { + return mData.keySet(); + } + + @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); + } + + @NonNull + @Override + public Map toMap() { + Map copy = new HashMap<>(); + for (Map.Entry entry : mData.entrySet()) { + Object value = entry.getValue(); + if (value instanceof List) { + copy.put(entry.getKey(), new ArrayList<>((List) value)); + } else { + copy.put(entry.getKey(), value); + } + } + return copy; + } +} 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/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..4652c1ba5 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataBuilderTest.java @@ -0,0 +1,201 @@ +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; + +import io.split.android.client.api.EventMetadata; + +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.keys().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(42, metadata.get("count")); + } + + @Test + public void putLongAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("timestamp", 1234567890L) + .build(); + + assertEquals(1234567890L, metadata.get("timestamp")); + } + + @Test + public void putDoubleAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("rate", 3.14) + .build(); + + assertEquals(3.14, metadata.get("rate")); + } + + @Test + public void putBooleanTrueAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("enabled", true) + .build(); + + assertEquals(true, metadata.get("enabled")); + } + + @Test + public void putBooleanFalseAddsValue() { + EventMetadata metadata = new EventMetadataBuilder() + .put("disabled", false) + .build(); + + assertEquals(false, metadata.get("disabled")); + } + + @Test + public void putListOfStringsAddsValue() { + List flags = Arrays.asList("flag_1", "flag_2", "flag_3"); + + EventMetadata metadata = new EventMetadataBuilder() + .put("updatedFlags", flags) + .build(); + + assertEquals(flags, metadata.get("updatedFlags")); + } + + @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.keys().size()); + assertEquals("text", metadata.get("string")); + assertEquals(100, metadata.get("number")); + assertEquals(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/EventMetadataImplTest.java b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataImplTest.java new file mode 100644 index 000000000..54059494e --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataImplTest.java @@ -0,0 +1,215 @@ +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; +import java.util.Set; + +public class EventMetadataImplTest { + + @Test + public void keysReturnsAllKeys() { + Map data = new HashMap<>(); + data.put("key1", "value1"); + data.put("key2", 42); + data.put("key3", true); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + Set keys = metadata.keys(); + + assertEquals(3, keys.size()); + assertTrue(keys.contains("key1")); + assertTrue(keys.contains("key2")); + assertTrue(keys.contains("key3")); + } + + @Test + public void keysReturnsEmptySetForEmptyMetadata() { + EventMetadataImpl metadata = new EventMetadataImpl(new HashMap<>()); + + assertTrue(metadata.keys().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 toMapReturnsACopyOfTheData() { + Map data = new HashMap<>(); + data.put("key", "value"); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + Map copy = metadata.toMap(); + + assertEquals(1, copy.size()); + assertEquals("value", copy.get("key")); + + // Verify it's a copy by modifying it + copy.put("newKey", "newValue"); + assertFalse(metadata.containsKey("newKey")); + } + + @Test + public void toMapReturnsEmptyMapForEmptyMetadata() { + EventMetadataImpl metadata = new EventMetadataImpl(new HashMap<>()); + + assertTrue(metadata.toMap().isEmpty()); + } + + @Test + public void toMapReturnsModifiableCopyOfLists() { + Map data = new HashMap<>(); + data.put("flags", Arrays.asList("flag_1", "flag_2")); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + Map copy = metadata.toMap(); + + // Should be able to modify the list in the copy + @SuppressWarnings("unchecked") + List listInCopy = (List) copy.get("flags"); + listInCopy.add("flag_3"); + + // Original metadata should not be affected + @SuppressWarnings("unchecked") + List originalList = (List) metadata.get("flags"); + assertEquals(2, originalList.size()); + assertEquals(Arrays.asList("flag_1", "flag_2"), originalList); + } + + @Test + public void toMapListsAreIndependentAcrossCalls() { + Map data = new HashMap<>(); + data.put("flags", Arrays.asList("flag_1", "flag_2")); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + Map copy1 = metadata.toMap(); + Map copy2 = metadata.toMap(); + + // Modify copy1's list + @SuppressWarnings("unchecked") + List list1 = (List) copy1.get("flags"); + list1.add("flag_3"); + + // copy2's list should not be affected + @SuppressWarnings("unchecked") + List list2 = (List) copy2.get("flags"); + assertEquals(2, list2.size()); + assertEquals(Arrays.asList("flag_1", "flag_2"), list2); + } + + @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.keys().size()); + } + + @Test + 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 + @SuppressWarnings("unchecked") + List storedList = (List) metadata.get("flags"); + assertEquals(2, storedList.size()); + assertEquals(Arrays.asList("flag_1", "flag_2"), storedList); + } + + @Test(expected = UnsupportedOperationException.class) + public void listReturnedByGetIsUnmodifiable() { + Map data = new HashMap<>(); + data.put("flags", Arrays.asList("flag_1", "flag_2")); + + EventMetadataImpl metadata = new EventMetadataImpl(data); + + @SuppressWarnings("unchecked") + 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/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)); + } +} From 7c411b4e37f4b660f60709fa7a7e24cdfb86f6f6 Mon Sep 17 00:00:00 2001 From: gthea Date: Fri, 28 Nov 2025 12:49:26 -0300 Subject: [PATCH 05/24] Event delivery implementation (#832) --- events-domain/build.gradle | 1 + .../delivery/DualExecutorRegistration.java | 134 ++++++++++ .../client/events/logging/SplitLogging.java | 35 +++ .../DualExecutorRegistrationTest.java | 231 ++++++++++++++++++ events/build.gradle | 2 +- .../java/io/harness/events/EventsManager.java | 2 +- .../harness/events/EventsManagerConfig.java | 12 +- .../io/harness/events/EventsManagerCore.java | 10 +- .../io/harness/events/EventsManagers.java | 28 +++ .../main/java/io/harness/events/Logging.java | 18 ++ .../io/harness/events/EventsManagersTest.java | 15 ++ .../java/io/harness/events/TestLogging.java | 34 +++ gradle/libs.versions.toml | 2 + 13 files changed, 511 insertions(+), 13 deletions(-) create mode 100644 events-domain/src/main/java/io/split/android/client/events/delivery/DualExecutorRegistration.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/logging/SplitLogging.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/delivery/DualExecutorRegistrationTest.java create mode 100644 events/src/main/java/io/harness/events/EventsManagers.java create mode 100644 events/src/main/java/io/harness/events/Logging.java create mode 100644 events/src/test/java/io/harness/events/EventsManagersTest.java create mode 100644 events/src/test/java/io/harness/events/TestLogging.java diff --git a/events-domain/build.gradle b/events-domain/build.gradle index d23b67ccc..04cbce16f 100644 --- a/events-domain/build.gradle +++ b/events-domain/build.gradle @@ -18,6 +18,7 @@ dependencies { implementation project(':api') implementation project(':events') + implementation project(':logger') testImplementation libs.junit4 testImplementation libs.mockitoCore 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/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/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/build.gradle b/events/build.gradle index 89ae1775c..b4a4d8ee9 100644 --- a/events/build.gradle +++ b/events/build.gradle @@ -14,7 +14,7 @@ android { } dependencies { - implementation libs.annotation + compileOnly libs.jetbrainsAnnotations testImplementation libs.junit4 testImplementation libs.mockitoCore diff --git a/events/src/main/java/io/harness/events/EventsManager.java b/events/src/main/java/io/harness/events/EventsManager.java index 70f915ff5..2bd84bfd3 100644 --- a/events/src/main/java/io/harness/events/EventsManager.java +++ b/events/src/main/java/io/harness/events/EventsManager.java @@ -1,6 +1,6 @@ package io.harness.events; -import androidx.annotation.Nullable; +import org.jetbrains.annotations.Nullable; /** * Interface for events manager. diff --git a/events/src/main/java/io/harness/events/EventsManagerConfig.java b/events/src/main/java/io/harness/events/EventsManagerConfig.java index 8172854d6..61b20ef16 100644 --- a/events/src/main/java/io/harness/events/EventsManagerConfig.java +++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java @@ -1,6 +1,6 @@ package io.harness.events; -import androidx.annotation.NonNull; +import org.jetbrains.annotations.NotNull; import java.util.Arrays; import java.util.Collections; @@ -66,27 +66,27 @@ public static EventsManagerConfig empty() { Collections.emptyMap()); } - @NonNull + @NotNull public Map> getRequireAll() { return mRequireAll; } - @NonNull + @NotNull public Map> getRequireAny() { return mRequireAny; } - @NonNull + @NotNull public Map> getPrerequisites() { return mPrerequisites; } - @NonNull + @NotNull public Map> getSuppressedBy() { return mSuppressedBy; } - @NonNull + @NotNull public Map getExecutionLimits() { return mExecutionLimits; } diff --git a/events/src/main/java/io/harness/events/EventsManagerCore.java b/events/src/main/java/io/harness/events/EventsManagerCore.java index aee43e8a1..3cc1f31fa 100644 --- a/events/src/main/java/io/harness/events/EventsManagerCore.java +++ b/events/src/main/java/io/harness/events/EventsManagerCore.java @@ -1,6 +1,6 @@ package io.harness.events; -import androidx.annotation.NonNull; +import org.jetbrains.annotations.NotNull; import java.util.Collections; import java.util.HashMap; @@ -19,7 +19,7 @@ * @param internal events type * @param metadata type */ -public class EventsManagerCore implements EventsManager { +class EventsManagerCore implements EventsManager { private static final int UNLIMITED = -1; @@ -27,12 +27,12 @@ public class EventsManagerCore implements EventsManager { private final Map mTriggerCount = new HashMap<>(); private final Set mSeenInternal = new HashSet<>(); - @NonNull + @NotNull private final EventsManagerConfig mConfig; - @NonNull + @NotNull private final EventDelivery mDelivery; - @NonNull + @NotNull private final ExecutorService mProcessQueue; private final Object mLock = new Object(); 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index c35a03090..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" } From d6fc78080ad327db8e7eef775c3c2852b62b17bb Mon Sep 17 00:00:00 2001 From: gthea Date: Fri, 28 Nov 2025 16:26:30 -0300 Subject: [PATCH 06/24] Move classes to api and events domain modules (#833) --- .../android/client/EvaluationOptions.java | 0 .../io/split/android/client/SplitClient.java | 0 .../io/split/android/client/SplitResult.java | 0 .../java/io/split/android/client/api/Key.java | 0 .../client/attributes/AttributesManager.java | 0 .../android/client/events/SplitEvent.java | 0 .../android/client/events/SplitEventTask.java | 0 ...ventTaskMethodNotImplementedException.java | 0 .../client/events/BaseEventsManager.java | 0 .../events/EventsManagerCoordinator.java | 4 +- .../client/events/EventsManagerRegistry.java | 0 .../client/events/ISplitEventsManager.java | 0 .../events/ListenableEventsManager.java | 0 .../client/events/SplitEventsManager.java | 13 ++----- .../client/events/SplitInternalEvent.java | 0 .../executors/ClientEventSplitTask.java | 0 .../events/executors/SplitEventExecutor.java | 0 .../executors/SplitEventExecutorFactory.java | 0 .../SplitEventExecutorResources.java | 0 .../SplitEventExecutorResourcesImpl.java | 4 +- .../SplitEventExecutorWithClient.java | 4 +- .../client/service/executor/SplitTask.java | 0 .../service/executor/SplitTaskBatchItem.java | 4 +- .../executor/SplitTaskExecutionInfo.java | 8 ++-- .../executor/SplitTaskExecutionListener.java | 0 .../executor/SplitTaskExecutionStatus.java | 0 .../service/executor/SplitTaskExecutor.java | 0 .../service/executor/SplitTaskType.java | 0 .../scheduler/PausableThreadPoolExecutor.java | 0 .../PausableThreadPoolExecutorImpl.java | 0 .../java/io/harness/events/NoOpLogging.java | 37 +++++++++++++++++++ .../java/tests/service/EventsManagerTest.java | 20 +++++----- .../shared/SplitClientContainerImpl.java | 2 +- .../client/SplitClientImplBaseTest.java | 2 +- .../client/events/EventsManagerTest.java | 14 +++---- .../client/utils/SplitClientImplFactory.java | 4 +- 36 files changed, 74 insertions(+), 42 deletions(-) rename {main => api}/src/main/java/io/split/android/client/EvaluationOptions.java (100%) rename {main => api}/src/main/java/io/split/android/client/SplitClient.java (100%) rename {main => api}/src/main/java/io/split/android/client/SplitResult.java (100%) rename {main => api}/src/main/java/io/split/android/client/api/Key.java (100%) rename {main => api}/src/main/java/io/split/android/client/attributes/AttributesManager.java (100%) rename {main => api}/src/main/java/io/split/android/client/events/SplitEvent.java (100%) rename {main => api}/src/main/java/io/split/android/client/events/SplitEventTask.java (100%) rename {main => api}/src/main/java/io/split/android/client/events/SplitEventTaskMethodNotImplementedException.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/BaseEventsManager.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java (96%) rename {main => events-domain}/src/main/java/io/split/android/client/events/EventsManagerRegistry.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/ISplitEventsManager.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/ListenableEventsManager.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/SplitEventsManager.java (96%) rename {main => events-domain}/src/main/java/io/split/android/client/events/SplitInternalEvent.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/executors/ClientEventSplitTask.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/executors/SplitEventExecutor.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/executors/SplitEventExecutorFactory.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResources.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/events/executors/SplitEventExecutorResourcesImpl.java (80%) rename {main => events-domain}/src/main/java/io/split/android/client/events/executors/SplitEventExecutorWithClient.java (90%) rename {main => events-domain}/src/main/java/io/split/android/client/service/executor/SplitTask.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java (85%) rename {main => events-domain}/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java (93%) rename {main => events-domain}/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java (100%) rename {main => events-domain}/src/main/java/io/split/android/client/service/executor/SplitTaskType.java (100%) rename {main => events-domain}/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java (100%) rename {main => events-domain}/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java (100%) create mode 100644 events/src/main/java/io/harness/events/NoOpLogging.java 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 100% rename from main/src/main/java/io/split/android/client/SplitClient.java rename to api/src/main/java/io/split/android/client/SplitClient.java 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/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/main/src/main/java/io/split/android/client/events/SplitEventTask.java b/api/src/main/java/io/split/android/client/events/SplitEventTask.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/SplitEventTask.java rename to api/src/main/java/io/split/android/client/events/SplitEventTask.java 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/main/src/main/java/io/split/android/client/events/BaseEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/BaseEventsManager.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/BaseEventsManager.java rename to events-domain/src/main/java/io/split/android/client/events/BaseEventsManager.java diff --git a/main/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java similarity index 96% rename from main/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java rename to events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java index 3d3af8bf3..b307f1993 100644 --- a/main/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java +++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java @@ -1,6 +1,6 @@ package io.split.android.client.events; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -18,7 +18,7 @@ public class EventsManagerCoordinator extends BaseEventsManager implements ISpli @Override public void notifyInternalEvent(SplitInternalEvent internalEvent) { - checkNotNull(internalEvent); + requireNonNull(internalEvent); try { mQueue.add(internalEvent); } catch (IllegalStateException e) { 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/main/src/main/java/io/split/android/client/events/ISplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/ISplitEventsManager.java similarity index 100% rename from main/src/main/java/io/split/android/client/events/ISplitEventsManager.java rename to events-domain/src/main/java/io/split/android/client/events/ISplitEventsManager.java 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 100% 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 diff --git a/main/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java similarity index 96% rename from main/src/main/java/io/split/android/client/events/SplitEventsManager.java rename to events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index 9fc54670b..ac1435e4e 100644 --- a/main/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -1,6 +1,6 @@ package io.split.android.client.events; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import androidx.annotation.VisibleForTesting; @@ -9,7 +9,6 @@ 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; @@ -27,10 +26,6 @@ public class SplitEventsManager extends BaseEventsManager implements ISplitEvent private final SplitTaskExecutor mSplitTaskExecutor; - public SplitEventsManager(SplitClientConfig config, SplitTaskExecutor splitTaskExecutor) { - this(splitTaskExecutor, config.blockUntilReady()); - } - public SplitEventsManager(SplitTaskExecutor splitTaskExecutor, final int blockUntilReady) { super(); mSplitTaskExecutor = splitTaskExecutor; @@ -83,7 +78,7 @@ public SplitEventExecutorResources getExecutorResources() { @Override public void notifyInternalEvent(SplitInternalEvent internalEvent) { - checkNotNull(internalEvent); + requireNonNull(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 @@ -103,8 +98,8 @@ public void notifyInternalEvent(SplitInternalEvent internalEvent) { public void register(SplitEvent event, SplitEventTask task) { - checkNotNull(event); - checkNotNull(task); + requireNonNull(event); + requireNonNull(task); // If event is already triggered, execute the task if (mExecutionTimes.containsKey(event) && mExecutionTimes.get(event) == 0) { 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 100% 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 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/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/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/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java index 0d3c01d13..9184d2810 100644 --- a/main/src/androidTest/java/tests/service/EventsManagerTest.java +++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java @@ -20,7 +20,7 @@ public class EventsManagerTest { public void testSdkUpdateSplits() 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()); CountDownLatch updateLatch = new CountDownLatch(1); @@ -40,7 +40,7 @@ public void testSdkUpdateSplits() throws InterruptedException { public void testSdkFetchedUpdatedSplits() 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()); CountDownLatch updateLatch = new CountDownLatch(1); @@ -60,7 +60,7 @@ public void testSdkFetchedUpdatedSplits() throws InterruptedException { public void testSdkUpdatedFetchedSplits() 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()); CountDownLatch updateLatch = new CountDownLatch(1); @@ -81,7 +81,7 @@ public void testSdkUpdatedFetchedSplits() throws InterruptedException { public void testSdkUpdateSegments() 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()); CountDownLatch updateLatch = new CountDownLatch(1); @@ -101,7 +101,7 @@ public void testSdkUpdateSegments() throws InterruptedException { public void testSdkFetchedUpdatedSegments() 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()); CountDownLatch updateLatch = new CountDownLatch(1); @@ -121,7 +121,7 @@ public void testSdkFetchedUpdatedSegments() throws InterruptedException { public void testSdkUpdatedFetchedSegments() 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()); CountDownLatch updateLatch = new CountDownLatch(1); @@ -142,7 +142,7 @@ public void testSdkUpdatedFetchedSegments() throws InterruptedException { public void testKilledSplit() 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()); CountDownLatch updateLatch = new CountDownLatch(1); @@ -162,7 +162,7 @@ public void testKilledSplit() throws InterruptedException { 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 +182,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 +205,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); 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/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/events/EventsManagerTest.java b/main/src/test/java/io/split/android/client/events/EventsManagerTest.java index c76a3d533..c72f2fddf 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 @@ -38,7 +38,7 @@ 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); @@ -58,7 +58,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 +73,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; @@ -179,7 +179,7 @@ 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); @@ -195,8 +195,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); @@ -230,7 +230,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); 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), From 905d5e4a89accc2fbb7fff24143dc420d2ac5afe Mon Sep 17 00:00:00 2001 From: gthea Date: Thu, 4 Dec 2025 10:22:09 -0300 Subject: [PATCH 07/24] SplitEventsManager implementation (#834) --- .../events/DualExecutorRegistration.java | 133 +++++++ .../events/EventsManagerCoordinator.java | 99 +++-- .../client/events/SplitEventDelivery.java | 51 +++ .../client/events/SplitEventsManager.java | 376 ++++++++++-------- .../SplitEventsManagerConfigFactory.java | 63 +++ .../client/events/SplitInternalEvent.java | 17 + .../android/client/events/SplitLogging.java | 35 ++ .../events/DualExecutorRegistrationTest.java | 231 +++++++++++ .../io/harness/events/EventsManagerCore.java | 14 +- .../io/harness/events/EventsManagerTest.java | 36 ++ 10 files changed, 862 insertions(+), 193 deletions(-) create mode 100644 events-domain/src/main/java/io/split/android/client/events/DualExecutorRegistration.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/SplitEventDelivery.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/SplitLogging.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/DualExecutorRegistrationTest.java 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 index b307f1993..c8bd282b7 100644 --- a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java +++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java @@ -2,67 +2,98 @@ import static java.util.Objects.requireNonNull; +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.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. + * 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 extends BaseEventsManager implements ISplitEventsManager, EventsManagerRegistry { +public class EventsManagerCoordinator implements ISplitEventsManager, EventsManagerRegistry { - private final ConcurrentMap mChildren = new ConcurrentHashMap<>(); + /** + * 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.SPLITS_FETCHED, + 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 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) { requireNonNull(internalEvent); - try { - mQueue.add(internalEvent); - } catch (IllegalStateException e) { - Logger.d("Internal events queue is full"); + + if (!SDK_SCOPED_EVENTS.contains(internalEvent)) { + // Client-scoped events should be sent directly to the client's manager + return; } - } - @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; - } + synchronized (mEventLock) { + mTriggered.add(internalEvent); + + for (ISplitEventsManager manager : mManagers.values()) { + manager.notifyInternalEvent(internalEvent); } - } 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()); } } + /** + * 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) { - mChildren.put(key, splitEventsManager); + requireNonNull(key); + requireNonNull(splitEventsManager); - // Inform the newly registered events manager of any events that occurred prior to registration + mManagers.put(key, splitEventsManager); + + // Propagate any events that occurred before registration propagateTriggeredEvents(splitEventsManager); } + /** + * Unregisters the events manager for a client key. + * + * @param key the client key to unregister + */ @Override public void unregisterEventsManager(Key key) { - mChildren.remove(key); + if (key != null) { + mManagers.remove(key); + } } private void propagateTriggeredEvents(ISplitEventsManager splitEventsManager) { 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..bcc13a50d --- /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.api.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 index ac1435e4e..d49b404f2 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -2,73 +2,80 @@ import static java.util.Objects.requireNonNull; +import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; -import io.split.android.client.events.executors.SplitEventExecutor; -import io.split.android.client.events.executors.SplitEventExecutorFactory; +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.api.EventMetadata; 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; -public class SplitEventsManager extends BaseEventsManager implements ISplitEventsManager, ListenableEventsManager, Runnable { - - private final Map> mSubscriptions; +/** + * Events manager for Split SDK. + */ +public class SplitEventsManager implements ISplitEventsManager, ListenableEventsManager { + private final EventsManager mEventsManager; + private final DualExecutorRegistration mDualExecutorRegistration; private SplitEventExecutorResources mResources; - private final Map mExecutionTimes; - - private final SplitTaskExecutor mSplitTaskExecutor; + // Track sync completion for SDK_READY_FROM_CACHE triggering. TODO: This is a temporary adaptation before extending EventsManager requireAny. + private volatile boolean mSplitsSyncComplete = false; + private volatile boolean mSegmentsSyncComplete = false; + /** + * 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) { - super(); - mSplitTaskExecutor = splitTaskExecutor; - mSubscriptions = new ConcurrentHashMap<>(); - mExecutionTimes = new ConcurrentHashMap<>(); + requireNonNull(splitTaskExecutor); + 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(); + // 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 - public void setExecutionResources(SplitEventExecutorResources resources) { + SplitEventsManager(EventsManager eventsManager, + DualExecutorRegistration dualExecutorRegistration, + SplitEventExecutorResources resources) { + mEventsManager = eventsManager; + mDualExecutorRegistration = dualExecutorRegistration; 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); + @VisibleForTesting + public void setExecutionResources(SplitEventExecutorResources resources) { + mResources = resources; } @Override @@ -79,149 +86,202 @@ public SplitEventExecutorResources getExecutorResources() { @Override public void notifyInternalEvent(SplitInternalEvent internalEvent) { requireNonNull(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 + // Skip FETCHED events after SDK_READY to prevent unnecessary SDK_UPDATE triggers. + // TODO: This is temporary until *_FETCHED and *_UPDATED events are unified. if ((internalEvent == SplitInternalEvent.SPLITS_FETCHED - || internalEvent == SplitInternalEvent.MY_SEGMENTS_FETCHED) && - isTriggered(SplitEvent.SDK_READY)) { + || internalEvent == SplitInternalEvent.MY_SEGMENTS_FETCHED) + && eventAlreadyTriggered(SplitEvent.SDK_READY)) { return; } - try { - mQueue.add(internalEvent); - } catch (IllegalStateException e) { - Logger.d("Internal events queue is full"); + + // Notify the actual internal event + mEventsManager.notifyInternalEvent(internalEvent, null); + + // Also notify the synthetic composite events for SDK_READY evaluation + notifySyntheticEventsIfNeeded(internalEvent); + } + + /** + * Notifies an internal event with metadata. + * + * @param internalEvent the internal event + * @param metadata the event metadata + */ + public void notifyInternalEvent(SplitInternalEvent internalEvent, EventMetadata metadata) { + requireNonNull(internalEvent); + + // Skip FETCHED events after SDK_READY + if ((internalEvent == SplitInternalEvent.SPLITS_FETCHED + || internalEvent == SplitInternalEvent.MY_SEGMENTS_FETCHED) + && eventAlreadyTriggered(SplitEvent.SDK_READY)) { + return; } + + mEventsManager.notifyInternalEvent(internalEvent, metadata); + notifySyntheticEventsIfNeeded(internalEvent); } + @Override public void register(SplitEvent event, SplitEventTask task) { - requireNonNull(event); requireNonNull(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); + // Adapt SplitEventTask to EventHandler and register for both threads + mDualExecutorRegistration.register( + mEventsManager, + event, + createBackgroundHandler(task), + createMainThreadHandler(task) + ); } + @Override public boolean eventAlreadyTriggered(SplitEvent event) { - return isTriggered(event); + return mEventsManager.eventAlreadyTriggered(event); } - private boolean wasTriggered(SplitInternalEvent event) { - return mTriggered.contains(event); + /** + * Destroys this events manager. + * After calling this method, the manager will no longer process events. + */ + public void destroy() { + mEventsManager.destroy(); } - @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()); + /** + * Notifies the synthetic composite events based on the actual internal event. + * These synthetic events simplify the SDK_READY condition evaluation. + *

+ * Also handles the special case where SDK_READY_FROM_CACHE should fire + * before SDK_READY when both sync events have completed. + */ + private void notifySyntheticEventsIfNeeded(SplitInternalEvent internalEvent) { + switch (internalEvent) { + case SPLITS_UPDATED: + case SPLITS_FETCHED: + mSplitsSyncComplete = true; + // Check if SDK_READY_FROM_CACHE should fire BEFORE notifying the sync complete + // to ensure correct ordering (SDK_READY_FROM_CACHE before SDK_READY) + triggerReadyFromCacheIfNeeded(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_SYNC_COMPLETE, null); + break; + + case MY_SEGMENTS_UPDATED: + case MY_SEGMENTS_FETCHED: + case MY_LARGE_SEGMENTS_UPDATED: + mSegmentsSyncComplete = true; + // Check if SDK_READY_FROM_CACHE should fire BEFORE notifying the sync complete + triggerReadyFromCacheIfNeeded(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SEGMENTS_SYNC_COMPLETE, null); + break; + + default: + // No synthetic event needed for other internal events + break; } } - // MARK: Helper functions. - private boolean isTriggered(SplitEvent event) { - Integer times = mExecutionTimes.get(event); - return times != null ? times == 0 : false; - } + /** + * Triggers SDK_READY_FROM_CACHE if SDK_READY is about to fire (both sync events received) + * but SDK_READY_FROM_CACHE hasn't fired yet. + *

+ * TODO: This is a temporary adaptation before extending EventsManager requireAny. + */ + private void triggerReadyFromCacheIfNeeded() { + // Only trigger if both sync events have completed + if (!mSplitsSyncComplete || !mSegmentsSyncComplete) { + return; + } - 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); + // If SDK_READY_FROM_CACHE already triggered, nothing to do + if (mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE)) { + return; } - } - private void trigger(SplitEvent event) { - // If executionTimes is zero, maximum executions has been reached - if (mExecutionTimes.get(event) == 0) { + // If SDK_READY already triggered, nothing to do (too late) + if (mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY)) { 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); + + // Both sync events received and SDK_READY_FROM_CACHE not yet fired + // Trigger it by firing all required cache events + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, null); + mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE, null); + mEventsManager.notifyInternalEvent(SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE, null); + mEventsManager.notifyInternalEvent(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE, null); + } + + 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 void executeTask(SplitEvent event, SplitEventTask task) { - if (task != null) { - SplitEventExecutor executor = SplitEventExecutorFactory.factory(mSplitTaskExecutor, event, task, mResources); + private EventHandler createBackgroundHandler(final SplitEventTask task) { + return new EventHandler() { + @Override + public void handle(SplitEvent event, EventMetadata metadata) { + try { + task.onPostExecution(mResources.getSplitClient()); + } catch (SplitEventTaskMethodNotImplementedException e) { + // Method not implemented by client, ignore + } catch (Exception e) { + Logger.e("Error executing background event task: " + e.getMessage()); + } + } + }; + } - if (executor != null) { - executor.execute(); + private EventHandler createMainThreadHandler(final SplitEventTask task) { + return new EventHandler() { + @Override + public void handle(SplitEvent event, EventMetadata metadata) { + try { + task.onPostExecutionView(mResources.getSplitClient()); + } catch (SplitEventTaskMethodNotImplementedException e) { + // Method not implemented by client, ignore + } catch (Exception e) { + Logger.e("Error executing main thread 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..b6d903719 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java @@ -0,0 +1,63 @@ +package io.split.android.client.events; + +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: + *

    + *
  • SDK_READY: requires both splits and segments sync to complete
  • + *
  • SDK_READY_FROM_CACHE: requires all cache loading events
  • + *
  • SDK_READY_TIMED_OUT: fires when timeout is reached (suppressed if SDK_READY fired first)
  • + *
  • SDK_UPDATE: fires on any data update after SDK_READY
  • + *
+ * + * @return the configured EventsManagerConfig + */ + static EventsManagerConfig create() { + return EventsManagerConfig.builder() + .requireAll(SplitEvent.SDK_READY, + SplitInternalEvent.SPLITS_SYNC_COMPLETE, + SplitInternalEvent.SEGMENTS_SYNC_COMPLETE) + + .requireAll(SplitEvent.SDK_READY_FROM_CACHE, + SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, + SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE, + SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE, + SplitInternalEvent.ENCRYPTION_MIGRATION_DONE) + + .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) + + .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 + + .build(); + } +} diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java index ab070f2f6..b9184c3e2 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java @@ -17,4 +17,21 @@ public enum SplitInternalEvent { ENCRYPTION_MIGRATION_DONE, MY_LARGE_SEGMENTS_UPDATED, RULE_BASED_SEGMENTS_UPDATED, + + /** + * Synthetic event: fired when splits sync completes (either SPLITS_FETCHED or SPLITS_UPDATED). + * Used internally to simplify SDK_READY condition evaluation. + *

+ * TODO: This is a temporary adaptation before extending EventsManager requireAny. + */ + SPLITS_SYNC_COMPLETE, + + /** + * Synthetic event: fired when segments sync completes (any of MY_SEGMENTS_FETCHED, + * MY_SEGMENTS_UPDATED, or MY_LARGE_SEGMENTS_UPDATED). + * Used internally to simplify SDK_READY condition evaluation. + *

+ * TODO: This is a temporary adaptation before extending EventsManager requireAny. + */ + SEGMENTS_SYNC_COMPLETE, } 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/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/src/main/java/io/harness/events/EventsManagerCore.java b/events/src/main/java/io/harness/events/EventsManagerCore.java index 3cc1f31fa..fbbebbdf4 100644 --- a/events/src/main/java/io/harness/events/EventsManagerCore.java +++ b/events/src/main/java/io/harness/events/EventsManagerCore.java @@ -112,7 +112,19 @@ public boolean eventAlreadyTriggered(E event) { } synchronized (mLock) { - return mTriggerCount.containsKey(event); + 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; } } diff --git a/events/src/test/java/io/harness/events/EventsManagerTest.java b/events/src/test/java/io/harness/events/EventsManagerTest.java index 6de433c73..c09dbc6e7 100644 --- a/events/src/test/java/io/harness/events/EventsManagerTest.java +++ b/events/src/test/java/io/harness/events/EventsManagerTest.java @@ -407,4 +407,40 @@ public void handlersAreNotCalledAfterDestroy() throws InterruptedException { 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)); + } } From c75832cd065c73be64b9a4db991b575e2b6f241b Mon Sep 17 00:00:00 2001 From: gthea Date: Thu, 4 Dec 2025 11:13:47 -0300 Subject: [PATCH 08/24] EventsManagerConfig requireAny as Set (#835) --- .../client/events/SplitEventsManager.java | 47 +- .../SplitEventsManagerConfigFactory.java | 30 +- .../client/events/SplitInternalEvent.java | 8 +- .../harness/events/EventsManagerConfig.java | 59 ++- .../io/harness/events/EventsManagerCore.java | 111 ++++- .../events/EventsManagerConfigTest.java | 65 ++- .../io/harness/events/EventsManagerTest.java | 414 +++++++++++++++++- 7 files changed, 633 insertions(+), 101 deletions(-) diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index d49b404f2..b5a9683b7 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -2,7 +2,6 @@ import static java.util.Objects.requireNonNull; -import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import java.util.concurrent.Executor; @@ -10,7 +9,6 @@ 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.api.EventMetadata; import io.split.android.client.events.executors.SplitEventExecutorResources; import io.split.android.client.events.executors.SplitEventExecutorResourcesImpl; @@ -28,10 +26,6 @@ public class SplitEventsManager implements ISplitEventsManager, ListenableEvents private final DualExecutorRegistration mDualExecutorRegistration; private SplitEventExecutorResources mResources; - // Track sync completion for SDK_READY_FROM_CACHE triggering. TODO: This is a temporary adaptation before extending EventsManager requireAny. - private volatile boolean mSplitsSyncComplete = false; - private volatile boolean mSegmentsSyncComplete = false; - /** * Creates a new SplitEventsManager. * @@ -152,27 +146,20 @@ public void destroy() { /** * Notifies the synthetic composite events based on the actual internal event. * These synthetic events simplify the SDK_READY condition evaluation. + * The prerequisite configuration ensures SDK_READY_FROM_CACHE always fires before SDK_READY. *

- * Also handles the special case where SDK_READY_FROM_CACHE should fire - * before SDK_READY when both sync events have completed. + * TODO: Remove this method once EventsManagerConfig is updated. */ private void notifySyntheticEventsIfNeeded(SplitInternalEvent internalEvent) { switch (internalEvent) { case SPLITS_UPDATED: case SPLITS_FETCHED: - mSplitsSyncComplete = true; - // Check if SDK_READY_FROM_CACHE should fire BEFORE notifying the sync complete - // to ensure correct ordering (SDK_READY_FROM_CACHE before SDK_READY) - triggerReadyFromCacheIfNeeded(); mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_SYNC_COMPLETE, null); break; case MY_SEGMENTS_UPDATED: case MY_SEGMENTS_FETCHED: case MY_LARGE_SEGMENTS_UPDATED: - mSegmentsSyncComplete = true; - // Check if SDK_READY_FROM_CACHE should fire BEFORE notifying the sync complete - triggerReadyFromCacheIfNeeded(); mEventsManager.notifyInternalEvent(SplitInternalEvent.SEGMENTS_SYNC_COMPLETE, null); break; @@ -182,36 +169,6 @@ private void notifySyntheticEventsIfNeeded(SplitInternalEvent internalEvent) { } } - /** - * Triggers SDK_READY_FROM_CACHE if SDK_READY is about to fire (both sync events received) - * but SDK_READY_FROM_CACHE hasn't fired yet. - *

- * TODO: This is a temporary adaptation before extending EventsManager requireAny. - */ - private void triggerReadyFromCacheIfNeeded() { - // Only trigger if both sync events have completed - if (!mSplitsSyncComplete || !mSegmentsSyncComplete) { - return; - } - - // If SDK_READY_FROM_CACHE already triggered, nothing to do - if (mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE)) { - return; - } - - // If SDK_READY already triggered, nothing to do (too late) - if (mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY)) { - return; - } - - // Both sync events received and SDK_READY_FROM_CACHE not yet fired - // Trigger it by firing all required cache events - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, null); - mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE, null); - mEventsManager.notifyInternalEvent(SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE, null); - mEventsManager.notifyInternalEvent(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE, null); - } - private void startTimeoutThread(final int blockUntilReady) { Thread timeoutThread = new Thread(new Runnable() { @Override diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java index b6d903719..b1999a170 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java @@ -1,5 +1,8 @@ package io.split.android.client.events; +import java.util.HashSet; +import java.util.Set; + import io.harness.events.EventsManagerConfig; /** @@ -19,8 +22,8 @@ private SplitEventsManagerConfigFactory() { *

* Event rules: *

    - *
  • SDK_READY: requires both splits and segments sync to complete
  • - *
  • SDK_READY_FROM_CACHE: requires all cache loading events
  • + *
  • SDK_READY: requires both splits and segments sync to complete, and SDK_READY_FROM_CACHE must fire first
  • + *
  • SDK_READY_FROM_CACHE: fires when EITHER all cache loading events complete OR all sync events complete
  • *
  • SDK_READY_TIMED_OUT: fires when timeout is reached (suppressed if SDK_READY fired first)
  • *
  • SDK_UPDATE: fires on any data update after SDK_READY
  • *
@@ -28,16 +31,27 @@ private SplitEventsManagerConfigFactory() { * @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.SPLITS_SYNC_COMPLETE); + syncGroup.add(SplitInternalEvent.SEGMENTS_SYNC_COMPLETE); + return EventsManagerConfig.builder() .requireAll(SplitEvent.SDK_READY, SplitInternalEvent.SPLITS_SYNC_COMPLETE, SplitInternalEvent.SEGMENTS_SYNC_COMPLETE) - .requireAll(SplitEvent.SDK_READY_FROM_CACHE, - SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, - SplitInternalEvent.MY_SEGMENTS_LOADED_FROM_STORAGE, - SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE, - SplitInternalEvent.ENCRYPTION_MIGRATION_DONE) + // 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) @@ -49,6 +63,8 @@ static EventsManagerConfig create() { 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) diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java index b9184c3e2..48d32bec3 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java @@ -20,18 +20,14 @@ public enum SplitInternalEvent { /** * Synthetic event: fired when splits sync completes (either SPLITS_FETCHED or SPLITS_UPDATED). - * Used internally to simplify SDK_READY condition evaluation. - *

- * TODO: This is a temporary adaptation before extending EventsManager requireAny. + * Used internally to simplify SDK_READY and SDK_READY_FROM_CACHE condition evaluation. */ SPLITS_SYNC_COMPLETE, /** * Synthetic event: fired when segments sync completes (any of MY_SEGMENTS_FETCHED, * MY_SEGMENTS_UPDATED, or MY_LARGE_SEGMENTS_UPDATED). - * Used internally to simplify SDK_READY condition evaluation. - *

- * TODO: This is a temporary adaptation before extending EventsManager requireAny. + * Used internally to simplify SDK_READY and SDK_READY_FROM_CACHE condition evaluation. */ SEGMENTS_SYNC_COMPLETE, } diff --git a/events/src/main/java/io/harness/events/EventsManagerConfig.java b/events/src/main/java/io/harness/events/EventsManagerConfig.java index 61b20ef16..fc83b8b08 100644 --- a/events/src/main/java/io/harness/events/EventsManagerConfig.java +++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java @@ -18,8 +18,8 @@ public final class EventsManagerConfig { // External events that require ALL listed internals (AND) private final Map> mRequireAll; - // External events triggered by ANY of the listed internals (OR) - private final Map> mRequireAny; + // 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 @@ -31,16 +31,16 @@ public final class EventsManagerConfig { * Creates a new EventsManagerConfig. * * @param requireAll External events that require ALL listed internals (AND) - * @param requireAny External events triggered by ANY of the listed internals (OR) + * @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>> requireAny, + Map> prerequisites, + Map> suppressedBy, + Map executionLimits) { mRequireAll = requireAll == null ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(requireAll)); @@ -72,7 +72,7 @@ public Map> getRequireAll() { } @NotNull - public Map> getRequireAny() { + public Map>> getRequireAny() { return mRequireAny; } @@ -110,7 +110,7 @@ public static Builder builder() { */ public static final class Builder { private final Map> mRequireAll = new HashMap<>(); - private final Map> mRequireAny = 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<>(); @@ -121,8 +121,8 @@ 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 + * @param externalEvent the external event + * @param internalEvents the internal events that must ALL occur * @return this builder */ @SafeVarargs @@ -133,14 +133,45 @@ public final Builder requireAll(E externalEvent, I... internalEvents) { /** * 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 + * @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) { - mRequireAny.put(externalEvent, new HashSet<>(Arrays.asList(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; } diff --git a/events/src/main/java/io/harness/events/EventsManagerCore.java b/events/src/main/java/io/harness/events/EventsManagerCore.java index fbbebbdf4..bb8ae47a3 100644 --- a/events/src/main/java/io/harness/events/EventsManagerCore.java +++ b/events/src/main/java/io/harness/events/EventsManagerCore.java @@ -149,33 +149,42 @@ private void processInternal(I event, M metadata) { currentSeenInternal = new HashSet<>(mSeenInternal); } - // Evaluate AND external events - for (Map.Entry> entry : mConfig.getRequireAll().entrySet()) { - E external = entry.getKey(); - Set required = entry.getValue(); - - if (!required.isEmpty() && currentSeenInternal.containsAll(required)) { - triggerIfConditionsMet(external, metadata); - } - } + // Track events fired in this processing cycle to avoid re-firing. + // This prevents infinite loops with unlimited events. + Set firedInThisCycle = new HashSet<>(); + + // Loop until no more events fire in an iteration. + // Without this loop, events would be missed if their external prerequisites + // aren't satisfied on the first pass, but become satisfied after other events fire. + boolean anyEventFiredThisIteration; + do { + boolean requireAllEventsFired = evaluateRequireAllEvents(currentSeenInternal, firedInThisCycle, metadata); + boolean requireAnyEventsFired = evaluateRequireAnyEvents(currentSeenInternal, firedInThisCycle, metadata); + anyEventFiredThisIteration = requireAllEventsFired || requireAnyEventsFired; + + } while (anyEventFiredThisIteration); + } - // Evaluate OR external events - for (Map.Entry> entry : mConfig.getRequireAny().entrySet()) { - E external = entry.getKey(); - if (entry.getValue().contains(event)) { - triggerIfConditionsMet(external, metadata); - } + /** + * 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 void triggerIfConditionsMet(E event, M metadata) { - if (!prerequisitesSatisfied(event) || isSuppressed(event)) { - return; - } - trigger(event, metadata); + private boolean canEventBeTriggered(E event) { + return prerequisitesSatisfied(event) && !isSuppressed(event); } - private void trigger(E event, M metadata) { + /** + * 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) { @@ -184,7 +193,7 @@ private void trigger(E event, M metadata) { int triggered = count != null ? count : 0; if (max != UNLIMITED && triggered >= max) { - return; + return false; } mTriggerCount.put(event, triggered + 1); @@ -198,6 +207,7 @@ private void trigger(E event, M metadata) { for (EventHandler handler : handlersSnapshot) { mDelivery.deliver(handler, event, metadata); } + return true; } private int maxExecutions(E event) { @@ -231,4 +241,61 @@ private boolean isSuppressed(E external) { } return false; } + + /** + * Evaluates events with AND logic: fire if ALL required internal events have been seen. + */ + private boolean evaluateRequireAllEvents(Set seenInternal, Set firedInThisCycle, M metadata) { + boolean anyEventFired = false; + for (Map.Entry> entry : mConfig.getRequireAll().entrySet()) { + E externalEvent = entry.getKey(); + if (hasAlreadyFiredInCycle(externalEvent, firedInThisCycle)) { + continue; + } + Set requiredInternals = entry.getValue(); + + if (allInternalEventsSeen(requiredInternals, seenInternal) && triggerIfConditionsMet(externalEvent, metadata)) { + firedInThisCycle.add(externalEvent); + anyEventFired = true; + } + } + return anyEventFired; + } + + /** + * Evaluates events with OR-of-ANDs logic: fire if ANY group has ALL its internal events seen. + */ + private boolean evaluateRequireAnyEvents(Set seenInternal, Set firedInThisCycle, M metadata) { + boolean anyEventFired = false; + for (Map.Entry>> entry : mConfig.getRequireAny().entrySet()) { + E externalEvent = entry.getKey(); + if (hasAlreadyFiredInCycle(externalEvent, firedInThisCycle)) { + continue; + } + Set> requiredGroups = entry.getValue(); + + if (anyGroupSatisfied(requiredGroups, seenInternal) && triggerIfConditionsMet(externalEvent, metadata)) { + firedInThisCycle.add(externalEvent); + anyEventFired = true; + } + } + return anyEventFired; + } + + private boolean hasAlreadyFiredInCycle(E event, Set firedInThisCycle) { + return firedInThisCycle.contains(event); + } + + private boolean allInternalEventsSeen(Set requiredInternals, Set seenInternal) { + return !requiredInternals.isEmpty() && seenInternal.containsAll(requiredInternals); + } + + private boolean anyGroupSatisfied(Set> requiredGroups, Set seenInternal) { + for (Set group : requiredGroups) { + if (allInternalEventsSeen(group, seenInternal)) { + return true; + } + } + return false; + } } diff --git a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java index e53ec9eba..7ab4405e1 100644 --- a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java +++ b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java @@ -8,6 +8,8 @@ import org.junit.Test; import java.util.Collections; +import java.util.HashSet; +import java.util.Set; public class EventsManagerConfigTest { @@ -36,8 +38,11 @@ public void builderCreatesConfigWithAllFields() { 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()); - assertTrue(config.getRequireAny().get("E2").contains("I3")); + 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")); @@ -93,7 +98,7 @@ public void returnedMapsAreUnmodifiable() { } try { - config.getRequireAny().put("E2", Collections.singleton("I2")); + config.getRequireAny().put("E2", Collections.singleton(Collections.singleton("I2"))); Assert.fail("getRequireAny() should return an unmodifiable map"); } catch (UnsupportedOperationException expected) { // expected @@ -138,4 +143,60 @@ public void emptyMethodReturnsEmptyUnmodifiableConfig() { // 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)); + } } diff --git a/events/src/test/java/io/harness/events/EventsManagerTest.java b/events/src/test/java/io/harness/events/EventsManagerTest.java index c09dbc6e7..7039a9de4 100644 --- a/events/src/test/java/io/harness/events/EventsManagerTest.java +++ b/events/src/test/java/io/harness/events/EventsManagerTest.java @@ -6,6 +6,8 @@ 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; @@ -124,10 +126,7 @@ public void seasoningAdjustedIsEmittedOnlyAfterDishServed() throws InterruptedEx eventsManager.register(CookingEvent.SEASONING_ADJUSTED, (event, metadata) -> hCount.incrementAndGet()); - // SEASONING_ADDED before DISH_SERVED - should not fire - eventsManager.notifyInternalEvent(KitchenActivity.SEASONING_ADDED, null); - - // Trigger DISH_SERVED + // 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); @@ -135,7 +134,10 @@ public void seasoningAdjustedIsEmittedOnlyAfterDishServed() throws InterruptedEx // Wait for DISH_SERVED to be processed assertTrue(eventsManager.eventAlreadyTriggered(CookingEvent.DISH_SERVED)); - // Now SEASONING_ADDED should trigger SEASONING_ADJUSTED + // 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)); @@ -443,4 +445,406 @@ public void eventAlreadyTriggeredRespectsExecutionLimits() throws InterruptedExc // 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()); + } } From 82cf13ff282bea6d4f766effebdaa6e4e7e50ff1 Mon Sep 17 00:00:00 2001 From: gthea Date: Thu, 4 Dec 2025 11:20:44 -0300 Subject: [PATCH 09/24] Remove synthetic sync events (#836) --- .../events/EventsManagerCoordinator.java | 2 +- .../client/events/SplitEventsManager.java | 48 ----------- .../SplitEventsManagerConfigFactory.java | 8 +- .../client/events/SplitInternalEvent.java | 31 +++---- .../java/tests/service/EventsManagerTest.java | 39 +++++---- .../localhost/LocalhostSplitsStorage.java | 2 +- .../LocalhostSplitClientContainerImpl.java | 41 +++++++++- .../shared/SplitEventsManagerFactory.java | 17 ++++ .../mysegments/MySegmentsSyncTask.java | 21 ++--- .../mysegments/MySegmentsSyncTaskConfig.java | 8 -- .../client/service/splits/SplitsSyncTask.java | 9 +- .../service/splits/SplitsUpdateTask.java | 8 +- .../events/EventsManagerCoordinatorTest.java | 12 +-- .../client/events/EventsManagerTest.java | 23 +++--- .../localhost/LocalhostSplitsStorageTest.java | 82 +++++++++++++++++++ ...LocalhostSplitClientContainerImplTest.java | 37 +++++++++ .../service/MySegmentsSyncTaskTest.java | 29 ++++++- .../client/service/SplitSyncTaskTest.java | 63 +++++++++++--- .../client/service/SplitUpdateTaskTest.java | 60 +++++++++++++- .../MySegmentsSyncTaskConfigTest.java | 1 - 20 files changed, 387 insertions(+), 154 deletions(-) create mode 100644 main/src/main/java/io/split/android/client/localhost/shared/SplitEventsManagerFactory.java create mode 100644 main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java diff --git a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java index c8bd282b7..3d9171dfe 100644 --- a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java +++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java @@ -26,7 +26,7 @@ public class EventsManagerCoordinator implements ISplitEventsManager, EventsMana */ private static final Set SDK_SCOPED_EVENTS = EnumSet.of( SplitInternalEvent.SPLITS_UPDATED, - SplitInternalEvent.SPLITS_FETCHED, + SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, SplitInternalEvent.SPLIT_KILLED_NOTIFICATION, SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index b5a9683b7..4d012e7f8 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -80,20 +80,7 @@ public SplitEventExecutorResources getExecutorResources() { @Override public void notifyInternalEvent(SplitInternalEvent internalEvent) { requireNonNull(internalEvent); - - // Skip FETCHED events after SDK_READY to prevent unnecessary SDK_UPDATE triggers. - // TODO: This is temporary until *_FETCHED and *_UPDATED events are unified. - if ((internalEvent == SplitInternalEvent.SPLITS_FETCHED - || internalEvent == SplitInternalEvent.MY_SEGMENTS_FETCHED) - && eventAlreadyTriggered(SplitEvent.SDK_READY)) { - return; - } - - // Notify the actual internal event mEventsManager.notifyInternalEvent(internalEvent, null); - - // Also notify the synthetic composite events for SDK_READY evaluation - notifySyntheticEventsIfNeeded(internalEvent); } /** @@ -104,16 +91,7 @@ && eventAlreadyTriggered(SplitEvent.SDK_READY)) { */ public void notifyInternalEvent(SplitInternalEvent internalEvent, EventMetadata metadata) { requireNonNull(internalEvent); - - // Skip FETCHED events after SDK_READY - if ((internalEvent == SplitInternalEvent.SPLITS_FETCHED - || internalEvent == SplitInternalEvent.MY_SEGMENTS_FETCHED) - && eventAlreadyTriggered(SplitEvent.SDK_READY)) { - return; - } - mEventsManager.notifyInternalEvent(internalEvent, metadata); - notifySyntheticEventsIfNeeded(internalEvent); } @Override @@ -143,32 +121,6 @@ public void destroy() { mEventsManager.destroy(); } - /** - * Notifies the synthetic composite events based on the actual internal event. - * These synthetic events simplify the SDK_READY condition evaluation. - * The prerequisite configuration ensures SDK_READY_FROM_CACHE always fires before SDK_READY. - *

- * TODO: Remove this method once EventsManagerConfig is updated. - */ - private void notifySyntheticEventsIfNeeded(SplitInternalEvent internalEvent) { - switch (internalEvent) { - case SPLITS_UPDATED: - case SPLITS_FETCHED: - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_SYNC_COMPLETE, null); - break; - - case MY_SEGMENTS_UPDATED: - case MY_SEGMENTS_FETCHED: - case MY_LARGE_SEGMENTS_UPDATED: - mEventsManager.notifyInternalEvent(SplitInternalEvent.SEGMENTS_SYNC_COMPLETE, null); - break; - - default: - // No synthetic event needed for other internal events - break; - } - } - private void startTimeoutThread(final int blockUntilReady) { Thread timeoutThread = new Thread(new Runnable() { @Override diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java index b1999a170..f6c09ac6f 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManagerConfigFactory.java @@ -41,13 +41,13 @@ static EventsManagerConfig create() { cacheGroup.add(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE); Set syncGroup = new HashSet<>(); - syncGroup.add(SplitInternalEvent.SPLITS_SYNC_COMPLETE); - syncGroup.add(SplitInternalEvent.SEGMENTS_SYNC_COMPLETE); + syncGroup.add(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + syncGroup.add(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); return EventsManagerConfig.builder() .requireAll(SplitEvent.SDK_READY, - SplitInternalEvent.SPLITS_SYNC_COMPLETE, - SplitInternalEvent.SEGMENTS_SYNC_COMPLETE) + 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) diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java index 48d32bec3..3849e8e5b 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitInternalEvent.java @@ -1,33 +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, - /** - * Synthetic event: fired when splits sync completes (either SPLITS_FETCHED or SPLITS_UPDATED). - * Used internally to simplify SDK_READY and SDK_READY_FROM_CACHE condition evaluation. - */ - SPLITS_SYNC_COMPLETE, + // Sync completion events (fired when sync completes, regardless of data change) + TARGETING_RULES_SYNC_COMPLETE, + MEMBERSHIPS_SYNC_COMPLETE, - /** - * Synthetic event: fired when segments sync completes (any of MY_SEGMENTS_FETCHED, - * MY_SEGMENTS_UPDATED, or MY_LARGE_SEGMENTS_UPDATED). - * Used internally to simplify SDK_READY and SDK_READY_FROM_CACHE condition evaluation. - */ - SEGMENTS_SYNC_COMPLETE, + // Other events + SDK_READY_TIMEOUT_REACHED, } diff --git a/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java index 9184d2810..05ac09f9f 100644 --- a/main/src/androidTest/java/tests/service/EventsManagerTest.java +++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java @@ -13,7 +13,6 @@ import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.service.executor.SplitTaskExecutorImpl; -import io.split.android.client.service.synchronizer.ThreadUtils; public class EventsManagerTest { @Test @@ -37,7 +36,7 @@ public void testSdkUpdateSplits() throws InterruptedException { } @Test - public void testSdkFetchedUpdatedSplits() throws InterruptedException { + public void testSdkUpdateTriggersAfterReady() throws InterruptedException { SplitClientConfig cfg = SplitClientConfig.builder().build(); SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); @@ -47,8 +46,10 @@ public void testSdkFetchedUpdatedSplits() throws InterruptedException { TestingHelper.TestEventTask updateTask = TestingHelper.testTask(updateLatch); eventManager.register(SplitEvent.SDK_UPDATE, updateTask); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + // First make SDK_READY fire by completing sync + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + // Then trigger SDK_UPDATE with a data change eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); updateLatch.await(5, TimeUnit.SECONDS); @@ -57,7 +58,7 @@ public void testSdkFetchedUpdatedSplits() throws InterruptedException { } @Test - public void testSdkUpdatedFetchedSplits() throws InterruptedException { + public void testSdkUpdateDoesNotTriggerBeforeReady() throws InterruptedException { SplitClientConfig cfg = SplitClientConfig.builder().build(); SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); @@ -67,12 +68,11 @@ public void testSdkUpdatedFetchedSplits() throws InterruptedException { TestingHelper.TestEventTask updateTask = TestingHelper.testTask(updateLatch); eventManager.register(SplitEvent.SDK_UPDATE, updateTask); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); + // Fire UPDATED before SDK_READY - should NOT trigger SDK_UPDATE due to prerequisite eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); - + eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); - updateLatch.await(5, TimeUnit.SECONDS); + updateLatch.await(2, TimeUnit.SECONDS); Assert.assertFalse(updateTask.onExecutedCalled); } @@ -98,7 +98,7 @@ public void testSdkUpdateSegments() throws InterruptedException { } @Test - public void testSdkFetchedUpdatedSegments() throws InterruptedException { + public void testSdkUpdateTriggersOnSegmentChange() throws InterruptedException { SplitClientConfig cfg = SplitClientConfig.builder().build(); SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); @@ -108,8 +108,10 @@ public void testSdkFetchedUpdatedSegments() throws InterruptedException { TestingHelper.TestEventTask updateTask = TestingHelper.testTask(updateLatch); eventManager.register(SplitEvent.SDK_UPDATE, updateTask); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_FETCHED); + // Make SDK_READY fire + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + // Then trigger SDK_UPDATE with a segment change eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); updateLatch.await(5, TimeUnit.SECONDS); @@ -118,7 +120,7 @@ public void testSdkFetchedUpdatedSegments() throws InterruptedException { } @Test - public void testSdkUpdatedFetchedSegments() throws InterruptedException { + public void testSdkUpdateRequiresDataChange() throws InterruptedException { SplitClientConfig cfg = SplitClientConfig.builder().build(); SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorImpl(), cfg.blockUntilReady()); @@ -128,13 +130,14 @@ public void testSdkUpdatedFetchedSegments() throws InterruptedException { TestingHelper.TestEventTask updateTask = TestingHelper.testTask(updateLatch); eventManager.register(SplitEvent.SDK_UPDATE, updateTask); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_FETCHED); + // Make SDK_READY fire with only SYNC_COMPLETE events (no UPDATED) + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + // No UPDATED events fired + updateLatch.await(2, TimeUnit.SECONDS); - updateLatch.await(5, TimeUnit.SECONDS); - + // SDK_UPDATE should NOT fire because no data actually changed Assert.assertFalse(updateTask.onExecutedCalled); } 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..9c769aaba 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 @@ -216,7 +216,7 @@ private void loadSplits() { } if (!content.equals(mLastContentLoaded)) { mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); } mLastContentLoaded = content; 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/service/mysegments/MySegmentsSyncTask.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java index 2158985aa..e79e7b070 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 @@ -50,7 +50,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 +104,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,28 +263,21 @@ private void fireMySegmentsUpdatedIfNeeded(UpdateSegmentsResult segmentsResult, return; } - // MY_SEGMENTS_UPDATED event when segments have changed + // Always fire SEGMENTS_SYNC_COMPLETE when sync succeeds + mEventsManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + + // Check if data actually changed boolean segmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(segmentsResult.oldSegments, segmentsResult.newSegments); boolean largeSegmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(largeSegmentsResult.oldSegments, largeSegmentsResult.newSegments); if (segmentsHaveChanged) { Logger.v("New segments: " + segmentsResult.newSegments); + mEventsManager.notifyInternalEvent(mUpdateEvent); } if (largeSegmentsHaveChanged) { Logger.v("New large segments: " + largeSegmentsResult.newSegments); - } - - 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); - } + mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); } } 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/splits/SplitsSyncTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncTask.java index 2cb35e578..834755c41 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 @@ -94,12 +94,13 @@ public SplitTaskExecutionInfo execute() { private void notifyInternalEvent(long storedChangeNumber) { if (mEventsManager != null) { - SplitInternalEvent event = SplitInternalEvent.SPLITS_FETCHED; + // Always fire SPLITS_SYNC_COMPLETE when sync succeeds + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + + // Fire SPLITS_UPDATED only if data actually changed if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill())) { - event = SplitInternalEvent.SPLITS_UPDATED; + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); } - - mEventsManager.notifyInternalEvent(event); } } 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..ca3138d43 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 @@ -67,12 +67,14 @@ 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; + // Always fire SPLITS_SYNC_COMPLETE when sync succeeds + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + + // Fire SPLITS_UPDATED only if data actually changed if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill()) || mChangeChecker.changeNumberIsNewer(storedRbsChangeNumber, mRuleBasedSegmentStorage.getChangeNumber())) { - event = SplitInternalEvent.SPLITS_UPDATED; + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); } - mEventsManager.notifyInternalEvent(event); } return result; } 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..4f289c650 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 @@ -46,14 +46,14 @@ public void RULE_BASED_SEGMENTEventIsPassedDownToChildren() { } @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(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); } @Test @@ -83,14 +83,14 @@ 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(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); mEventsManager.registerEventsManager(new Key("new_key", "bucketing"), newMockChildEventsManager); - verify(newMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + verify(newMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); } 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 c72f2fddf..d4a1e1977 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 @@ -40,9 +40,10 @@ public void eventOnReady() { SplitClientConfig cfg = SplitClientConfig.builder().build(); 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; @@ -84,10 +85,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; @@ -181,8 +181,9 @@ public void sdkReadyWithSplitsAndUpdatedLargeSegments() { SplitClientConfig cfg = SplitClientConfig.builder().build(); 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; @@ -213,8 +214,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); 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..ebfbdd2e6 --- /dev/null +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java @@ -0,0 +1,82 @@ +package io.split.android.client.localhost; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.FileNotFoundException; +import java.io.IOException; + +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 = "splits:\n - name: split1\n treatment: on"; + private static final String UPDATED_CONTENT = "splits:\n - name: 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)); + + // 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)); + } + + @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)); + + // 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)); + } +} + 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/service/MySegmentsSyncTaskTest.java b/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java index 696c844f0..4651eaa9a 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 @@ -222,14 +222,39 @@ public void addTillParameterToRequestWhenResponseCnDoesNotMatchTargetAndRetryLim } @Test - public void fetchedEventIsEmittedWhenNoChangesInSegments() throws HttpFetcherException { + public void syncCompleteEventIsEmittedWhenNoChangesInSegments() throws HttpFetcherException { when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(false); 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(SplitInternalEvent.MY_SEGMENTS_UPDATED); + } + + @Test + public void membershipsSyncCompleteIsAlwaysFiredOnSuccessfulSync() throws HttpFetcherException { + when(mMySegmentsFetcher.execute(noParams, null)).thenReturn(mMySegments); + when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(true); + + 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)); + } + + @Test + public void updateEventIsFiredWhenSegmentsHaveChanged() throws HttpFetcherException { + when(mMySegmentsFetcher.execute(noParams, null)).thenReturn(mMySegments); + when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(true); + + 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)); } @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..513a500f3 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 @@ -125,10 +125,8 @@ 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); @@ -138,15 +136,14 @@ public void splitUpdatedNotified() throws HttpFetcherException { mTask.execute(); + verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); } @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 +153,8 @@ public void splitFetchdNotified() throws HttpFetcherException { mTask.execute(); - verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.SPLITS_FETCHED); + verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + verify(mEventsManager, never()).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); } @Test @@ -186,6 +184,51 @@ 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)); + } + + @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)); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + } + + @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)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + } + @After public void tearDown() { reset(mSplitsStorage); 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..ca19d54e1 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 @@ -17,11 +17,14 @@ 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 +79,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 +87,61 @@ public void storedRbsChangeNumBigger() throws HttpFetcherException { verify(mSplitsSyncHelper, never()).sync(any(), anyBoolean(), anyBoolean(), eq(ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES)); } + @Test + public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSync() { + 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(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + } + + @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)); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + } + + @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)); + + mTask.execute(); + + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + } + + @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)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + } + @After public void tearDown() { reset(mSplitsStorage); 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); } } From 572f3b62a9d51624e12ac556536bbe8000ff13e8 Mon Sep 17 00:00:00 2001 From: gthea Date: Thu, 4 Dec 2025 11:28:50 -0300 Subject: [PATCH 10/24] Updated flags metadata (#837) --- .../events/EventsManagerCoordinator.java | 22 ++- .../client/events/ISplitEventsManager.java | 12 ++ .../client/events/SplitEventsManager.java | 1 + .../events/metadata/EventMetadataBuilder.java | 4 +- .../events/metadata/EventMetadataHelpers.java | 27 +++ .../localhost/LocalhostSplitsStorage.java | 12 +- .../splits/SplitInPlaceUpdateTask.java | 13 +- .../service/splits/SplitsSyncHelper.java | 56 ++++++- .../client/service/splits/SplitsSyncTask.java | 12 +- .../service/splits/SplitsUpdateTask.java | 12 +- .../storage/splits/ProcessedSplitChange.java | 7 +- .../events/EventsManagerCoordinatorTest.java | 57 ++++++- .../localhost/LocalhostSplitsStorageTest.java | 42 ++++- .../service/SplitInPlaceUpdateTaskTest.java | 64 ++++++- .../client/service/SplitSyncTaskTest.java | 76 ++++++++- .../client/service/SplitUpdateTaskTest.java | 41 ++++- .../client/service/SplitsSyncHelperTest.java | 157 ++++++++++++++++++ .../android/fake/SplitEventsManagerStub.java | 8 + 18 files changed, 589 insertions(+), 34 deletions(-) create mode 100644 events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataHelpers.java diff --git a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java index 3d9171dfe..553b1a094 100644 --- a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java +++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java @@ -2,12 +2,15 @@ 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.api.EventMetadata; import io.split.android.client.api.Key; /** @@ -48,6 +51,21 @@ public class EventsManagerCoordinator implements ISplitEventsManager, EventsMana */ @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)) { @@ -59,7 +77,7 @@ public void notifyInternalEvent(SplitInternalEvent internalEvent) { mTriggered.add(internalEvent); for (ISplitEventsManager manager : mManagers.values()) { - manager.notifyInternalEvent(internalEvent); + manager.notifyInternalEvent(internalEvent, metadata); } } } @@ -99,7 +117,7 @@ public void unregisterEventsManager(Key key) { private void propagateTriggeredEvents(ISplitEventsManager splitEventsManager) { synchronized (mEventLock) { for (SplitInternalEvent event : mTriggered) { - splitEventsManager.notifyInternalEvent(event); + splitEventsManager.notifyInternalEvent(event, null); } } } 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 index 31b72ffd0..32580a40d 100644 --- 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 @@ -1,6 +1,18 @@ package io.split.android.client.events; +import androidx.annotation.Nullable; + +import io.split.android.client.api.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); } diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index 4d012e7f8..449bc3adf 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -89,6 +89,7 @@ public void notifyInternalEvent(SplitInternalEvent internalEvent) { * @param internalEvent the internal event * @param metadata the event metadata */ + @Override public void notifyInternalEvent(SplitInternalEvent internalEvent, EventMetadata metadata) { requireNonNull(internalEvent); mEventsManager.notifyInternalEvent(internalEvent, metadata); 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 index f8208d4e7..76cb30289 100644 --- 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 @@ -15,14 +15,14 @@ * Values are validated during put operations. Only String, Number, Boolean, * and List<String> values are accepted. Invalid values will be silently ignored. */ -public class EventMetadataBuilder { +class EventMetadataBuilder { private static final MetadataValidator DEFAULT_VALIDATOR = new MetadataValidatorImpl(); private final Map mData = new HashMap<>(); private final MetadataValidator mValidator; - public EventMetadataBuilder() { + EventMetadataBuilder() { this(DEFAULT_VALIDATOR); } 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..70fc71dec --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadataHelpers.java @@ -0,0 +1,27 @@ +package io.split.android.client.events.metadata; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import io.split.android.client.api.EventMetadata; + +/** + * 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 static final String KEY_UPDATED_FLAGS = "updatedFlags"; + + private EventMetadataHelpers() { + // Utility class + } + + public static EventMetadata createUpdatedFlagsMetadata(List updatedSplitNames) { + return new EventMetadataBuilder() + .put(KEY_UPDATED_FLAGS, new ArrayList<>(new HashSet<>(updatedSplitNames))) + .build(); + } +} 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 9c769aaba..4ceec166c 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.api.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; @@ -217,7 +221,8 @@ private void loadSplits() { if (!content.equals(mLastContentLoaded)) { mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + EventMetadata metadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); } mLastContentLoaded = content; } @@ -258,4 +263,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/service/splits/SplitInPlaceUpdateTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java index 4198cd401..53fe35a04 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.api.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/SplitsSyncHelper.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java index 8b53774fd..c04c324f5 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,18 @@ 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.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 +30,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 +58,7 @@ public class SplitsSyncHelper { private final OutdatedSplitProxyHandler mOutdatedSplitProxyHandler; private final ExecutorService mExecutor; private final TargetingRulesCache mTargetingRulesCache; + private final AtomicReference mLastProcessedSplitChange = new AtomicReference<>(); public SplitsSyncHelper(@NonNull HttpFetcher splitFetcher, @NonNull SplitsStorage splitsStorage, @@ -306,10 +312,58 @@ private void updateStorage(boolean clearBeforeUpdate, SplitChange splitChange, R mSplitsStorage.clear(); mRuleBasedSegmentStorage.clear(); } - mSplitsStorage.update(mSplitChangeProcessor.process(splitChange), mExecutor); + ProcessedSplitChange processedSplitChange = mSplitChangeProcessor.process(splitChange); + mLastProcessedSplitChange.set(processedSplitChange); + mSplitsStorage.update(processedSplitChange, mExecutor); updateRbsStorage(ruleBasedSegmentChange); } + /** + * 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()); mRuleBasedSegmentStorage.update(change.getActive(), change.getArchived(), change.getChangeNumber(), mExecutor); 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 834755c41..9e07fc52b 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,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.List; + +import io.split.android.client.api.EventMetadata; 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.ServiceConstants; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -99,11 +103,17 @@ private void notifyInternalEvent(long storedChangeNumber) { // Fire SPLITS_UPDATED only if data actually changed if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill())) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + EventMetadata metadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); } } } + private EventMetadata createUpdatedFlagsMetadata() { + List updatedSplitNames = mSplitsSyncHelper.getLastUpdatedFlagNames(); + return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); + } + private boolean splitsFilterHasChanged(String storedSplitsFilterQueryString) { return !sanitizeString(mSplitsFilterQueryStringFromConfig).equals(sanitizeString(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 ca3138d43..a7d35c671 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,12 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import java.util.List; + +import io.split.android.client.api.EventMetadata; 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.ServiceConstants; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -73,12 +77,18 @@ public SplitTaskExecutionInfo execute() { // Fire SPLITS_UPDATED only if data actually changed if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill()) || mChangeChecker.changeNumberIsNewer(storedRbsChangeNumber, mRuleBasedSegmentStorage.getChangeNumber())) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + EventMetadata metadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); } } 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/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/events/EventsManagerCoordinatorTest.java b/main/src/test/java/io/split/android/client/events/EventsManagerCoordinatorTest.java index 4f289c650..eb8768aae 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,5 +1,10 @@ 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.verify; @@ -9,6 +14,10 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.util.Arrays; +import java.util.List; + +import io.split.android.client.api.EventMetadata; import io.split.android.client.api.Key; public class EventsManagerCoordinatorTest { @@ -31,7 +40,7 @@ public void SPLITS_UPDATEDEventIsPassedDownToChildren() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), isNull()); } @Test @@ -42,7 +51,7 @@ 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 @@ -53,7 +62,7 @@ public void SPLITS_SYNC_COMPLETEEventIsPassedDownToChildren() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), isNull()); } @Test @@ -64,7 +73,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 +84,7 @@ public void SPLIT_KILLED_NOTIFICATIONEventIsPassedDownToChildren() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION), isNull()); } @Test @@ -87,10 +96,44 @@ public void EventIsPassedDownToChildrenIfRegisteredAfterEmission() { delay(); - verify(mMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), isNull()); mEventsManager.registerEventsManager(new Key("new_key", "bucketing"), newMockChildEventsManager); - verify(newMockChildEventsManager).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + 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; + assertTrue(meta.containsKey("updatedFlags")); + Object flagsValue = meta.get("updatedFlags"); + assertNotNull(flagsValue); + assertTrue(flagsValue instanceof List); + @SuppressWarnings("unchecked") + List flags = (List) flagsValue; + return flags.size() == 2 && flags.contains("flag1") && flags.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)); } private void delay() { 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 index ebfbdd2e6..456210555 100644 --- a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java @@ -1,7 +1,12 @@ package io.split.android.client.localhost; +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; @@ -11,12 +16,15 @@ 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.api.EventMetadata; import io.split.android.client.events.EventsManagerCoordinator; import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.storage.legacy.FileStorage; @@ -34,8 +42,8 @@ public class LocalhostSplitsStorageTest { private LocalhostSplitsStorage mLocalhostSplitsStorage; private static final String TEST_FILE_NAME = "test-splits.yaml"; - private static final String INITIAL_CONTENT = "splits:\n - name: split1\n treatment: on"; - private static final String UPDATED_CONTENT = "splits:\n - name: split2\n treatment: off"; + 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 { @@ -52,7 +60,7 @@ public void loadLocalNotifiesTargetingRulesSyncCompleteAndSplitsUpdatedWhenConte mLocalhostSplitsStorage.loadLocal(); verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); - verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); // Update content and reload when(mFileStorage.read(TEST_FILE_NAME)).thenReturn(UPDATED_CONTENT); @@ -60,7 +68,7 @@ public void loadLocalNotifiesTargetingRulesSyncCompleteAndSplitsUpdatedWhenConte // 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)); + verify(mEventsManagerCoordinator, org.mockito.Mockito.times(2)).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @Test @@ -69,14 +77,36 @@ public void loadLocalDoesNotNotifyEventsWhenContentUnchanged() throws IOExceptio mLocalhostSplitsStorage.loadLocal(); verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); - verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + 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)); + 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)); + 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); + assertTrue("Metadata should contain 'updatedFlags' key", metadata.containsKey("updatedFlags")); + Object flagsValue = metadata.get("updatedFlags"); + assertNotNull("updatedFlags value should not be null", flagsValue); + assertTrue("updatedFlags should be a List", flagsValue instanceof List); + @SuppressWarnings("unchecked") + List flags = (List) flagsValue; + assertTrue("Metadata should contain 'split1' flag", flags.contains("split1")); } } 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..682bbf70b 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,10 @@ import org.mockito.MockitoAnnotations; import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import io.split.android.client.api.EventMetadata; import io.split.android.client.dtos.Split; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; @@ -124,7 +131,62 @@ 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; + assertTrue(metadata.containsKey("updatedFlags")); + Object flagsValue = metadata.get("updatedFlags"); + assertNotNull(flagsValue); + assertTrue(flagsValue instanceof List); + @SuppressWarnings("unchecked") + List flags = (List) flagsValue; + assertEquals(2, flags.size()); + assertTrue(flags.contains("test_split_1")); + assertTrue(flags.contains("test_split_2")); + 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; + assertTrue(metadata.containsKey("updatedFlags")); + Object flagsValue = metadata.get("updatedFlags"); + assertNotNull(flagsValue); + assertTrue(flagsValue instanceof List); + @SuppressWarnings("unchecked") + List flags = (List) flagsValue; + assertEquals(1, flags.size()); + assertTrue(flags.contains("archived_split")); + return true; + })); + } } 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 513a500f3..90352c09f 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,7 +22,10 @@ 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.dtos.SplitChange; @@ -136,8 +142,8 @@ public void splitUpdatedNotified() throws HttpFetcherException { mTask.execute(); - verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); - verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @Test @@ -153,8 +159,8 @@ public void splitSyncCompleteNotifiedWhenNoDataChange() throws HttpFetcherExcept mTask.execute(); - verify(mEventsManager, times(1)).notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); - verify(mEventsManager, never()).notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @Test @@ -210,7 +216,7 @@ public void splitsUpdatedIsFiredWhenDataChanged() throws HttpFetcherException { mTask.execute(); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @Test @@ -225,10 +231,68 @@ public void splitsUpdatedIsNotFiredWhenDataUnchanged() throws HttpFetcherExcepti mTask.execute(); - verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); } + @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)); + + // 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; + assertTrue(metadata.containsKey("updatedFlags")); + Object flagsValue = metadata.get("updatedFlags"); + assertNotNull(flagsValue); + assertTrue(flagsValue instanceof List); + @SuppressWarnings("unchecked") + List flags = (List) flagsValue; + assertEquals(3, flags.size()); + assertTrue(flags.contains("split1")); + assertTrue(flags.contains("split2")); + assertTrue(flags.contains("split3")); + 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)); + + // 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; + assertTrue(metadata.containsKey("updatedFlags")); + Object flagsValue = metadata.get("updatedFlags"); + assertNotNull(flagsValue); + assertTrue(flagsValue instanceof List); + @SuppressWarnings("unchecked") + List flags = (List) flagsValue; + assertTrue(flags.isEmpty()); + return true; + })); + } + @After public void tearDown() { reset(mSplitsStorage); 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 ca19d54e1..4900c0548 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,6 +18,9 @@ import org.mockito.ArgumentMatcher; import org.mockito.Mockito; +import java.util.Arrays; +import java.util.List; + import io.split.android.client.dtos.SplitChange; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; @@ -109,7 +115,7 @@ public void splitsUpdatedIsFiredWhenSplitsDataChanged() { mTask.execute(); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); } @@ -123,7 +129,7 @@ public void splitsUpdatedIsFiredWhenRbsDataChanged() { mTask.execute(); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); } @@ -138,10 +144,39 @@ public void splitsUpdatedIsNotFiredWhenDataUnchanged() { mTask.execute(); - verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED)); + verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); } + @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)); + + // 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; + assertTrue(metadata.containsKey("updatedFlags")); + Object flagsValue = metadata.get("updatedFlags"); + assertNotNull(flagsValue); + assertTrue(flagsValue instanceof List); + @SuppressWarnings("unchecked") + List flags = (List) flagsValue; + assertEquals(2, flags.size()); + assertTrue(flags.contains("flag1")); + assertTrue(flags.contains("flag2")); + return true; + })); + } + @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..b816381a6 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; @@ -26,6 +27,7 @@ import org.mockito.Spy; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -33,6 +35,7 @@ import java.util.Map; 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; @@ -559,4 +562,158 @@ 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 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 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/fake/SplitEventsManagerStub.java b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java index 49b372e7e..7fed3538a 100644 --- a/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java +++ b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java @@ -1,5 +1,8 @@ package io.split.android.fake; +import androidx.annotation.Nullable; + +import io.split.android.client.api.EventMetadata; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.ListenableEventsManager; import io.split.android.client.events.SplitEvent; @@ -18,7 +21,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 From 5dd4ff97136a8d7eb7b8afcf47012e0de29a47be Mon Sep 17 00:00:00 2001 From: gthea Date: Thu, 4 Dec 2025 11:40:45 -0300 Subject: [PATCH 11/24] Ready from cache metadata (#838) --- .../events/metadata/EventMetadataHelpers.java | 22 +++++ .../metadata/EventMetadataHelpersTest.java | 85 ++++++++++++++++++ gradle/common-android-library.gradle | 6 +- main/build.gradle | 2 +- main/consumer-rules.pro | 10 --- .../android/client/SplitFactoryImpl.java | 3 +- .../localhost/LocalhostSplitsStorage.java | 8 +- .../client/service/splits/SplitsSyncTask.java | 6 +- .../service/splits/SplitsUpdateTask.java | 6 +- .../FeatureFlagsSynchronizerImpl.java | 22 ++++- .../synchronizer/LoadLocalDataListener.java | 22 ++++- .../synchronizer/SynchronizerImpl.java | 7 +- .../localhost/LocalhostSplitsStorageTest.java | 2 +- .../client/service/SplitSyncTaskTest.java | 8 +- .../client/service/SplitUpdateTaskTest.java | 17 ++-- .../LoadLocalDataListenerTest.java | 87 +++++++++++++++++++ split-proguard-rules.pro | 4 + 17 files changed, 283 insertions(+), 34 deletions(-) create mode 100644 events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataHelpersTest.java delete mode 100644 main/consumer-rules.pro create mode 100644 main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java 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 index 70fc71dec..d7fc334a7 100644 --- 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 @@ -1,5 +1,7 @@ package io.split.android.client.events.metadata; +import androidx.annotation.Nullable; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -14,6 +16,8 @@ public class EventMetadataHelpers { private static final String KEY_UPDATED_FLAGS = "updatedFlags"; + private static final String KEY_LAST_UPDATE_TIMESTAMP = "lastUpdateTimestamp"; + private static final String KEY_FRESH_INSTALL = "freshInstall"; private EventMetadataHelpers() { // Utility class @@ -24,4 +28,22 @@ public static EventMetadata createUpdatedFlagsMetadata(List updatedSplit .put(KEY_UPDATED_FLAGS, new ArrayList<>(new HashSet<>(updatedSplitNames))) .build(); } + + /** + * Creates metadata for the SDK_READY_FROM_CACHE event. + * + * @param lastUpdateTimestamp the timestamp when the cache was last updated, or null if not available + * @param freshInstall true if this is a fresh install (no prior cache), false if loaded from cache + * @return the event metadata + */ + public static EventMetadata createCacheReadyMetadata(@Nullable Long lastUpdateTimestamp, boolean freshInstall) { + EventMetadataBuilder builder = new EventMetadataBuilder() + .put(KEY_FRESH_INSTALL, freshInstall); + + if (lastUpdateTimestamp != null) { + builder.put(KEY_LAST_UPDATE_TIMESTAMP, lastUpdateTimestamp); + } + + return builder.build(); + } } 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..d21bc8d3f --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/EventMetadataHelpersTest.java @@ -0,0 +1,85 @@ +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.Arrays; +import java.util.List; + +import io.split.android.client.api.EventMetadata; + +public class EventMetadataHelpersTest { + + // Tests for createUpdatedFlagsMetadata (existing) + @Test + public void createUpdatedFlagsMetadataContainsFlags() { + List flags = Arrays.asList("flag1", "flag2", "flag3"); + EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata(flags); + + assertTrue(metadata.containsKey("updatedFlags")); + @SuppressWarnings("unchecked") + List result = (List) metadata.get("updatedFlags"); + assertEquals(3, result.size()); + assertTrue(result.contains("flag1")); + assertTrue(result.contains("flag2")); + assertTrue(result.contains("flag3")); + } + + // Tests for createCacheReadyMetadata + @Test + public void createCacheReadyMetadataWithTimestampAndFreshInstallFalse() { + EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(1234567890L, false); + + assertEquals(1234567890L, metadata.get("lastUpdateTimestamp")); + assertEquals(false, metadata.get("freshInstall")); + } + + @Test + public void createCacheReadyMetadataWithNullTimestampAndFreshInstallTrue() { + EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + + assertNull(metadata.get("lastUpdateTimestamp")); + assertEquals(true, metadata.get("freshInstall")); + } + + @Test + public void createCacheReadyMetadataKeysAreCorrect() { + EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(123L, false); + + assertTrue(metadata.containsKey("lastUpdateTimestamp")); + assertTrue(metadata.containsKey("freshInstall")); + assertEquals(2, metadata.keys().size()); + } + + @Test + public void createCacheReadyMetadataWithZeroTimestamp() { + EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(0L, false); + + assertEquals(0L, metadata.get("lastUpdateTimestamp")); + assertEquals(false, metadata.get("freshInstall")); + } + + @Test + public void createCacheReadyMetadataForCachePath() { + // Cache path: freshInstall=false, timestamp from storage + long storedTimestamp = 1700000000000L; + EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(storedTimestamp, false); + + assertFalse((Boolean) metadata.get("freshInstall")); + assertEquals(storedTimestamp, metadata.get("lastUpdateTimestamp")); + } + + @Test + public void createCacheReadyMetadataForSyncPath() { + // Sync path: freshInstall=true, timestamp=null + EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + + assertTrue((Boolean) metadata.get("freshInstall")); + assertNull(metadata.get("lastUpdateTimestamp")); + } +} + diff --git a/gradle/common-android-library.gradle b/gradle/common-android-library.gradle index 93f2c7962..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,10 +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/main/build.gradle b/main/build.gradle index dba2405f2..7ec1e3110 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -9,7 +9,7 @@ android { defaultConfig { multiDexEnabled true - consumerProguardFiles 'consumer-rules.pro' + consumerProguardFiles "$rootDir/split-proguard-rules.pro" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' 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/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/localhost/LocalhostSplitsStorage.java b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitsStorage.java index 4ceec166c..ed6402229 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 @@ -219,10 +219,12 @@ private void loadSplits() { } } if (!content.equals(mLastContentLoaded)) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE); + // Cache path metadata: freshInstall=false (loaded from file), timestamp=null for localhost + EventMetadata cacheMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, false); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE, cacheMetadata); mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); - EventMetadata metadata = createUpdatedFlagsMetadata(); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); + EventMetadata updateMetadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, updateMetadata); } mLastContentLoaded = content; } 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 9e07fc52b..a9487f044 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 @@ -98,8 +98,10 @@ public SplitTaskExecutionInfo execute() { private void notifyInternalEvent(long storedChangeNumber) { if (mEventsManager != null) { - // Always fire SPLITS_SYNC_COMPLETE when sync succeeds - mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + // Always fire TARGETING_RULES_SYNC_COMPLETE when sync succeeds + // Sync path metadata: freshInstall=true (synced from network), timestamp=null + EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); // Fire SPLITS_UPDATED only if data actually changed if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill())) { 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 a7d35c671..c739bce11 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 @@ -71,8 +71,10 @@ public SplitTaskExecutionInfo execute() { SplitTaskExecutionInfo result = mSplitsSyncHelper.sync(new SplitsSyncHelper.SinceChangeNumbers(mChangeNumber, mRbsChangeNumber), ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); if (result.getStatus() == SplitTaskExecutionStatus.SUCCESS) { - // Always fire SPLITS_SYNC_COMPLETE when sync succeeds - mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + // Always fire TARGETING_RULES_SYNC_COMPLETE when sync succeeds + // Sync path metadata: freshInstall=true (synced from network), timestamp=null + EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); // Fire SPLITS_UPDATED only if data actually changed if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill()) || 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..0c0746dad 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,14 @@ public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { } mSplitsSyncRetryTimer.setTask(mSplitTaskFactory.createSplitsSyncTask(true), mSplitsSyncListener); + + // Create metadata provider for cache path (freshInstall=false, lastUpdateTimestamp from storage) + LoadLocalDataListener.MetadataProvider cacheMetadataProvider = splitsStorage != null + ? () -> EventMetadataHelpers.createCacheReadyMetadata(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..49d5e91c3 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.api.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/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/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java index 456210555..0d7fdc85a 100644 --- a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java @@ -92,7 +92,7 @@ public void loadLocalNotifiesSplitsUpdatedWithMetadataContainingUpdatedFlags() t // First load - should notify events with metadata mLocalhostSplitsStorage.loadLocal(); - verify(mEventsManagerCoordinator).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_LOADED_FROM_STORAGE)); + 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); 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 90352c09f..0713d0d14 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 @@ -142,7 +142,7 @@ public void splitUpdatedNotified() throws HttpFetcherException { mTask.execute(); - verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @@ -159,7 +159,7 @@ public void splitSyncCompleteNotifiedWhenNoDataChange() throws HttpFetcherExcept mTask.execute(); - verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager, times(1)).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); } @@ -201,7 +201,7 @@ public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSync() throws Htt mTask.execute(); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } @Test @@ -232,7 +232,7 @@ public void splitsUpdatedIsNotFiredWhenDataUnchanged() throws HttpFetcherExcepti mTask.execute(); verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } @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 4900c0548..191b557af 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 @@ -94,7 +94,7 @@ public void storedRbsChangeNumBigger() { } @Test - public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSync() { + 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))) @@ -102,7 +102,14 @@ public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSync() { mTask.execute(); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + // Verify TARGETING_RULES_SYNC_COMPLETE is fired with sync metadata (freshInstall=true, lastUpdateTimestamp=null) + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), argThat(metadata -> { + if (metadata == null) return false; + assertTrue(metadata.containsKey("freshInstall")); + assertEquals(true, metadata.get("freshInstall")); + // lastUpdateTimestamp should not be present (null) + return !metadata.containsKey("lastUpdateTimestamp") || metadata.get("lastUpdateTimestamp") == null; + })); } @Test @@ -116,7 +123,7 @@ public void splitsUpdatedIsFiredWhenSplitsDataChanged() { mTask.execute(); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } @Test @@ -130,7 +137,7 @@ public void splitsUpdatedIsFiredWhenRbsDataChanged() { mTask.execute(); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } @Test @@ -145,7 +152,7 @@ public void splitsUpdatedIsNotFiredWhenDataUnchanged() { mTask.execute(); verify(mEventsManager, never()).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), any()); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } @Test 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..adda882a1 --- /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.api.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/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* From 4c55548ceb38153a81b1653151c5dc564a06b733 Mon Sep 17 00:00:00 2001 From: gthea Date: Tue, 9 Dec 2025 15:08:03 -0300 Subject: [PATCH 12/24] Sort external events dependencies (#839) --- .../client/events/BaseEventsManager.java | 68 ---- .../client/events/SplitEventsManager.java | 1 + .../SplitEventsManagerConfigFactoryTest.java | 214 ++++++++++++ events/README.md | 68 ++++ .../events/EvaluationOrderComputer.java | 116 +++++++ .../harness/events/EventsManagerConfig.java | 21 ++ .../io/harness/events/EventsManagerCore.java | 94 ++---- .../io/harness/events/TopologicalSorter.java | 107 ++++++ .../events/EvaluationOrderComputerTest.java | 317 ++++++++++++++++++ .../events/EventsManagerConfigTest.java | 86 +++++ .../io/harness/events/EventsManagerTest.java | 144 ++++++++ .../harness/events/TopologicalSorterTest.java | 231 +++++++++++++ .../LargeSegmentsStreamingTest.java | 9 +- .../streaming/MySegmentsSyncProcessTest.java | 20 +- .../java/tests/service/EventsManagerTest.java | 18 +- .../mysegments/MySegmentsSyncTask.java | 14 +- .../service/splits/SplitsSyncHelper.java | 20 ++ .../client/service/splits/SplitsSyncTask.java | 33 +- .../service/splits/SplitsUpdateTask.java | 20 +- .../client/service/SplitSyncTaskTest.java | 36 ++ .../client/service/SplitUpdateTaskTest.java | 6 +- .../client/service/SplitsSyncHelperTest.java | 61 ++++ 22 files changed, 1542 insertions(+), 162 deletions(-) delete mode 100644 events-domain/src/main/java/io/split/android/client/events/BaseEventsManager.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/SplitEventsManagerConfigFactoryTest.java create mode 100644 events/src/main/java/io/harness/events/EvaluationOrderComputer.java create mode 100644 events/src/main/java/io/harness/events/TopologicalSorter.java create mode 100644 events/src/test/java/io/harness/events/EvaluationOrderComputerTest.java create mode 100644 events/src/test/java/io/harness/events/TopologicalSorterTest.java diff --git a/events-domain/src/main/java/io/split/android/client/events/BaseEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/BaseEventsManager.java deleted file mode 100644 index e22b9a7dc..000000000 --- a/events-domain/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/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index 449bc3adf..3dca17c88 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -9,6 +9,7 @@ import io.harness.events.EventHandler; import io.harness.events.EventsManager; import io.harness.events.EventsManagers; + import io.split.android.client.api.EventMetadata; import io.split.android.client.events.executors.SplitEventExecutorResources; import io.split.android.client.events.executors.SplitEventExecutorResourcesImpl; 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/README.md b/events/README.md index 18af5cb60..23a505c54 100644 --- a/events/README.md +++ b/events/README.md @@ -3,3 +3,71 @@ 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) + +## 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. + +### 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/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/EventsManagerConfig.java b/events/src/main/java/io/harness/events/EventsManagerConfig.java index fc83b8b08..79d4ccbbb 100644 --- a/events/src/main/java/io/harness/events/EventsManagerConfig.java +++ b/events/src/main/java/io/harness/events/EventsManagerConfig.java @@ -6,6 +6,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -26,6 +27,8 @@ public final class EventsManagerConfig { private final Map> mSuppressedBy; // Execution policy: max executions per external event (-1 = unlimited) private final Map mExecutionLimits; + // Topologically sorted evaluation order (prerequisites and suppressors come before dependents) + private final List mEvaluationOrder; /** * Creates a new EventsManagerConfig. @@ -56,6 +59,8 @@ private EventsManagerConfig(Map> requireAll, mExecutionLimits = executionLimits == null ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(executionLimits)); + + mEvaluationOrder = computeEvaluationOrder(); } public static EventsManagerConfig empty() { @@ -66,6 +71,17 @@ public static EventsManagerConfig empty() { 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; @@ -91,6 +107,11 @@ public Map getExecutionLimits() { return mExecutionLimits; } + @NotNull + public List getEvaluationOrder() { + return mEvaluationOrder; + } + /** * Creates a new Builder for EventsManagerConfig. * diff --git a/events/src/main/java/io/harness/events/EventsManagerCore.java b/events/src/main/java/io/harness/events/EventsManagerCore.java index bb8ae47a3..4368417b9 100644 --- a/events/src/main/java/io/harness/events/EventsManagerCore.java +++ b/events/src/main/java/io/harness/events/EventsManagerCore.java @@ -149,20 +149,19 @@ private void processInternal(I event, M metadata) { currentSeenInternal = new HashSet<>(mSeenInternal); } - // Track events fired in this processing cycle to avoid re-firing. - // This prevents infinite loops with unlimited events. - Set firedInThisCycle = new HashSet<>(); - - // Loop until no more events fire in an iteration. - // Without this loop, events would be missed if their external prerequisites - // aren't satisfied on the first pass, but become satisfied after other events fire. - boolean anyEventFiredThisIteration; - do { - boolean requireAllEventsFired = evaluateRequireAllEvents(currentSeenInternal, firedInThisCycle, metadata); - boolean requireAnyEventsFired = evaluateRequireAnyEvents(currentSeenInternal, firedInThisCycle, metadata); - anyEventFiredThisIteration = requireAllEventsFired || requireAnyEventsFired; - - } while (anyEventFiredThisIteration); + // 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) + boolean internalConditionsMet = checkInternalTriggerConditions(externalEvent, currentSeenInternal, event); + + if (!internalConditionsMet) { + continue; + } + + // Check external guards (prerequisites and suppression) and fire if all conditions met + triggerIfConditionsMet(externalEvent, metadata); + } } /** @@ -243,59 +242,32 @@ private boolean isSuppressed(E external) { } /** - * Evaluates events with AND logic: fire if ALL required internal events have been seen. + * 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 boolean evaluateRequireAllEvents(Set seenInternal, Set firedInThisCycle, M metadata) { - boolean anyEventFired = false; - for (Map.Entry> entry : mConfig.getRequireAll().entrySet()) { - E externalEvent = entry.getKey(); - if (hasAlreadyFiredInCycle(externalEvent, firedInThisCycle)) { - continue; - } - Set requiredInternals = entry.getValue(); - - if (allInternalEventsSeen(requiredInternals, seenInternal) && triggerIfConditionsMet(externalEvent, metadata)) { - firedInThisCycle.add(externalEvent); - anyEventFired = true; - } + private boolean checkInternalTriggerConditions(E externalEvent, Set seenInternal, I currentEvent) { + Set requireAll = mConfig.getRequireAll().get(externalEvent); + if (requireAll != null && !requireAll.isEmpty() && seenInternal.containsAll(requireAll)) { + return true; } - return anyEventFired; - } - /** - * Evaluates events with OR-of-ANDs logic: fire if ANY group has ALL its internal events seen. - */ - private boolean evaluateRequireAnyEvents(Set seenInternal, Set firedInThisCycle, M metadata) { - boolean anyEventFired = false; - for (Map.Entry>> entry : mConfig.getRequireAny().entrySet()) { - E externalEvent = entry.getKey(); - if (hasAlreadyFiredInCycle(externalEvent, firedInThisCycle)) { - continue; - } - Set> requiredGroups = entry.getValue(); - - if (anyGroupSatisfied(requiredGroups, seenInternal) && triggerIfConditionsMet(externalEvent, metadata)) { - firedInThisCycle.add(externalEvent); - anyEventFired = true; + // 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 true; + } } } - return anyEventFired; - } - - private boolean hasAlreadyFiredInCycle(E event, Set firedInThisCycle) { - return firedInThisCycle.contains(event); - } - - private boolean allInternalEventsSeen(Set requiredInternals, Set seenInternal) { - return !requiredInternals.isEmpty() && seenInternal.containsAll(requiredInternals); - } - private boolean anyGroupSatisfied(Set> requiredGroups, Set seenInternal) { - for (Set group : requiredGroups) { - if (allInternalEventsSeen(group, seenInternal)) { - return true; - } - } return false; } + } 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 index 7ab4405e1..5fafb6fe6 100644 --- a/events/src/test/java/io/harness/events/EventsManagerConfigTest.java +++ b/events/src/test/java/io/harness/events/EventsManagerConfigTest.java @@ -3,12 +3,14 @@ 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 { @@ -199,4 +201,88 @@ public void requireAnyWithMixedGroupSizes() { 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/EventsManagerTest.java b/events/src/test/java/io/harness/events/EventsManagerTest.java index 7039a9de4..536763989 100644 --- a/events/src/test/java/io/harness/events/EventsManagerTest.java +++ b/events/src/test/java/io/harness/events/EventsManagerTest.java @@ -446,6 +446,7 @@ public void eventAlreadyTriggeredRespectsExecutionLimits() throws InterruptedExc assertFalse(eventsManager.eventAlreadyTriggered(CookingEvent.SEASONING_ADJUSTED)); } + @Test public void requireAnyWithGroupsFiresWhenFirstGroupComplete() throws InterruptedException { // External event fires when EITHER: @@ -847,4 +848,147 @@ public void threeLevelPrerequisiteChain() throws InterruptedException { 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/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/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/streaming/MySegmentsSyncProcessTest.java b/main/src/androidTest/java/tests/integration/streaming/MySegmentsSyncProcessTest.java index 63af0388c..eae280771 100644 --- a/main/src/androidTest/java/tests/integration/streaming/MySegmentsSyncProcessTest.java +++ b/main/src/androidTest/java/tests/integration/streaming/MySegmentsSyncProcessTest.java @@ -186,15 +186,31 @@ public void onPostExecutionView(SplitClient client) { MySegmentEntity client1SegmentEntity = mSplitRoomDatabase.mySegmentDao().getByUserKey(mUserKey.matchingKey()); MySegmentEntity client2SegmentEntity = mSplitRoomDatabase.mySegmentDao().getByUserKey("key2"); + CountDownLatch client1UpdateLatch2 = new CountDownLatch(1); + mClient.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client) { + client1UpdateLatch2.countDown(); + } + }); + mCurrentUpdate.set(segment1Update()); testMySegmentsPush(TestingData.largeSegmentsUnboundedNoCompression("1")); - client1UpdateLatch.await(5, TimeUnit.SECONDS); + client1UpdateLatch2.await(5, TimeUnit.SECONDS); MySegmentEntity client1SegmentEntityPayload = mSplitRoomDatabase.mySegmentDao().getByUserKey(mUserKey.matchingKey()); MySegmentEntity client2SegmentEntityPayload = mSplitRoomDatabase.mySegmentDao().getByUserKey("key2"); + CountDownLatch client1UpdateLatch3 = new CountDownLatch(1); + mClient.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client) { + client1UpdateLatch3.countDown(); + } + }); + mCurrentUpdate.set(IntegrationHelper.emptyAllSegments()); testMySegmentsPush(TestingData.largeSegmentsUnboundedNoCompression("1")); - client1UpdateLatch.await(5, TimeUnit.SECONDS); + client1UpdateLatch3.await(5, TimeUnit.SECONDS); MySegmentEntity client1SegmentEntityEmptyPayload = mSplitRoomDatabase.mySegmentDao().getByUserKey(mUserKey.matchingKey()); MySegmentEntity client2SegmentEntityEmptyPayload = mSplitRoomDatabase.mySegmentDao().getByUserKey("key2"); diff --git a/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java index 05ac09f9f..6462168bc 100644 --- a/main/src/androidTest/java/tests/service/EventsManagerTest.java +++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java @@ -26,8 +26,10 @@ public void testSdkUpdateSplits() throws InterruptedException { TestingHelper.TestEventTask updateTask = TestingHelper.testTask(updateLatch); eventManager.register(SplitEvent.SDK_UPDATE, updateTask); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + // First make SDK_READY fire (prerequisite for SDK_UPDATE) + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + // Then trigger SDK_UPDATE eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); updateLatch.await(5, TimeUnit.SECONDS); @@ -88,8 +90,10 @@ public void testSdkUpdateSegments() throws InterruptedException { TestingHelper.TestEventTask updateTask = TestingHelper.testTask(updateLatch); eventManager.register(SplitEvent.SDK_UPDATE, updateTask); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); + // First make SDK_READY fire (prerequisite for SDK_UPDATE) + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + // Then trigger SDK_UPDATE with segment change eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); updateLatch.await(5, TimeUnit.SECONDS); @@ -152,8 +156,10 @@ public void testKilledSplit() throws InterruptedException { TestingHelper.TestEventTask updateTask = TestingHelper.testTask(updateLatch); eventManager.register(SplitEvent.SDK_UPDATE, updateTask); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED); - eventManager.notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); + // First make SDK_READY fire (prerequisite for SDK_UPDATE) + eventManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE); + eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); + // Then trigger SDK_UPDATE with killed notification eventManager.notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION); updateLatch.await(5, TimeUnit.SECONDS); 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 e79e7b070..a5f0759bd 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 @@ -16,6 +16,7 @@ import io.split.android.client.dtos.AllSegmentsChange; import io.split.android.client.dtos.SegmentsChange; +import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.network.SplitHttpHeadersBuilder; @@ -263,10 +264,10 @@ private void fireMySegmentsUpdatedIfNeeded(UpdateSegmentsResult segmentsResult, return; } - // Always fire SEGMENTS_SYNC_COMPLETE when sync succeeds - mEventsManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); - - // Check if data actually changed + // 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. boolean segmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(segmentsResult.oldSegments, segmentsResult.newSegments); boolean largeSegmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(largeSegmentsResult.oldSegments, largeSegmentsResult.newSegments); @@ -279,6 +280,11 @@ private void fireMySegmentsUpdatedIfNeeded(UpdateSegmentsResult segmentsResult, Logger.v("New large segments: " + largeSegmentsResult.newSegments); mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); } + + // 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/splits/SplitsSyncHelper.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java index c04c324f5..bf203a469 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 @@ -59,6 +59,8 @@ public class SplitsSyncHelper { private final ExecutorService mExecutor; private final TargetingRulesCache mTargetingRulesCache; private final AtomicReference mLastProcessedSplitChange = new AtomicReference<>(); + private boolean mSplitsHaveChanged; + private boolean mRuleBasedSegmentsHaveChanged; public SplitsSyncHelper(@NonNull HttpFetcher splitFetcher, @NonNull SplitsStorage splitsStorage, @@ -142,6 +144,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()) { @@ -308,6 +312,14 @@ 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(); @@ -417,6 +429,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 a9487f044..2baad9dd9 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 @@ -9,6 +9,7 @@ import io.split.android.client.api.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; @@ -97,18 +98,28 @@ public SplitTaskExecutionInfo execute() { } private void notifyInternalEvent(long storedChangeNumber) { - if (mEventsManager != null) { - // Always fire TARGETING_RULES_SYNC_COMPLETE when sync succeeds - // Sync path metadata: freshInstall=true (synced from network), timestamp=null - EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); - mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); - - // Fire SPLITS_UPDATED only if data actually changed - if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill())) { - EventMetadata metadata = createUpdatedFlagsMetadata(); - mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); - } + if (mEventsManager == null) { + return; } + + // 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. + if (mSplitsSyncHelper.splitsHaveChanged()) { + EventMetadata metadata = createUpdatedFlagsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); + } + + if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + } + + // 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). + EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); } private EventMetadata createUpdatedFlagsMetadata() { 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 c739bce11..c19907ced 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 @@ -71,17 +71,21 @@ public SplitTaskExecutionInfo execute() { SplitTaskExecutionInfo result = mSplitsSyncHelper.sync(new SplitsSyncHelper.SinceChangeNumbers(mChangeNumber, mRbsChangeNumber), ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); if (result.getStatus() == SplitTaskExecutionStatus.SUCCESS) { - // Always fire TARGETING_RULES_SYNC_COMPLETE when sync succeeds - // Sync path metadata: freshInstall=true (synced from network), timestamp=null - EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); - mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); - - // Fire SPLITS_UPDATED only if data actually changed - if (mChangeChecker.changeNumberIsNewer(storedChangeNumber, mSplitsStorage.getTill()) || - mChangeChecker.changeNumberIsNewer(storedRbsChangeNumber, mRuleBasedSegmentStorage.getChangeNumber())) { + // 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. + if (mSplitsSyncHelper.splitsHaveChanged()) { EventMetadata metadata = createUpdatedFlagsMetadata(); mEventsManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); } + + if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + } + + // Fire sync complete AFTER update events + EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); } return result; } 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 0713d0d14..9d9b76f2c 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 @@ -139,6 +139,8 @@ public void splitUpdatedNotified() throws HttpFetcherException { 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(); @@ -213,6 +215,8 @@ public void splitsUpdatedIsFiredWhenDataChanged() throws HttpFetcherException { 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(); @@ -243,6 +247,7 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() throws HttpFetcherEx 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"); @@ -274,6 +279,7 @@ public void splitsUpdatedIncludesEmptyMetadataWhenNoSplitsUpdated() throws HttpF 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<>()); @@ -293,6 +299,36 @@ public void splitsUpdatedIncludesEmptyMetadataWhenNoSplitsUpdated() throws HttpF })); } + @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)); + 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()); + } + @After public void tearDown() { reset(mSplitsStorage); 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 191b557af..931643f08 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 @@ -119,6 +119,8 @@ public void splitsUpdatedIsFiredWhenSplitsDataChanged() { 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(); @@ -133,10 +135,11 @@ public void splitsUpdatedIsFiredWhenRbsDataChanged() { 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.SPLITS_UPDATED), any()); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED)); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } @@ -162,6 +165,7 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { 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"); 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 b816381a6..fb195df7b 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 @@ -673,6 +673,67 @@ public void extractFlagNamesHandlesNullLists() { 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 From 621bcb3034dce9f687d688b602455a8c02e8fcc5 Mon Sep 17 00:00:00 2001 From: gthea Date: Tue, 9 Dec 2025 20:58:03 -0300 Subject: [PATCH 13/24] New methods for SplitEventTask (#840) --- .../android/client/events/SplitEventTask.java | 100 +++++++++- .../client/events/SplitEventsManager.java | 60 ++++-- .../java/tests/service/EventsManagerTest.java | 100 ++++++++++ .../client/events/EventsManagerTest.java | 178 ++++++++++++++++++ .../events/SplitEventTaskMetadataTest.java | 123 ++++++++++++ 5 files changed, 547 insertions(+), 14 deletions(-) create mode 100644 main/src/test/java/io/split/android/client/events/SplitEventTaskMetadataTest.java diff --git a/api/src/main/java/io/split/android/client/events/SplitEventTask.java b/api/src/main/java/io/split/android/client/events/SplitEventTask.java index 5a5dd6db9..f880e0fe1 100644 --- a/api/src/main/java/io/split/android/client/events/SplitEventTask.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventTask.java @@ -1,17 +1,113 @@ package io.split.android.client.events; +import androidx.annotation.Nullable; + import io.split.android.client.SplitClient; +import io.split.android.client.api.EventMetadata; /** - * Created by sarrubia on 3/26/18. + * Base class for handling Split SDK events. + *

+ * Extend this class and override the methods you need to handle specific SDK events. + * You can implement both the metadata-enabled and versions of the methods; + * if both are implemented, both will be called (metadata version first). + *

+ * Threading: + *

    + *
  • {@code onPostExecution} methods are called on a background thread (faster, executed immediately)
  • + *
  • {@code onPostExecutionView} methods are called on the main/UI thread (queued on main looper)
  • + *
+ *

+ * Metadata: + *

    + *
  • Metadata-enabled methods receive {@link EventMetadata} containing event-specific information
  • + *
  • Metadata may be {@code null} for some events
  • + *
  • If you only need metadata, implement the metadata version; if you need backward compatibility, + * implement both versions
  • + *
+ *

+ * Example usage: + *

{@code
+ * client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() {
+ *     @Override
+ *     public void onPostExecution(SplitClient client, EventMetadata metadata) {
+ *         List updatedFlags = (List) metadata.get("updatedFlags");
+ *         // Handle update with metadata
+ *     }
+ *
+ *     @Override
+ *     public void onPostExecution(SplitClient client) {
+ *         // Legacy handling (also called if both are implemented)
+ *     }
+ * });
+ * }
*/ - public class SplitEventTask { + /** + * Called when an event occurs, executed on a background thread. + *

+ * Override this method to handle events on a background thread without metadata. + * 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 without metadata. + * 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(); } + + /** + * Called when an event occurs with metadata, executed on a background thread. + *

+ * Override this method to handle events on a background thread with access to event metadata. + * The metadata contains event-specific information such as updated flag names for SDK_UPDATE events. + * This method is executed immediately and is faster than {@link #onPostExecutionView(SplitClient, EventMetadata)}. + *

+ * If both this method and {@link #onPostExecution(SplitClient)} are implemented, + * both will be called (this method first). + * + * @param client the Split client instance + * @param metadata the event metadata, may be {@code null} for some events + * @throws SplitEventTaskMethodNotImplementedException if not overridden (default behavior) + */ + public void onPostExecution(SplitClient client, @Nullable EventMetadata metadata) { + throw new SplitEventTaskMethodNotImplementedException(); + } + + /** + * Called when an event occurs with metadata, executed on the main/UI thread. + *

+ * Override this method to handle events on the main thread with access to event metadata. + * The metadata contains event-specific information such as updated flag names for SDK_UPDATE events. + * Use this when you need to update UI components based on event metadata. + *

+ * Note: This method is queued on the main looper, so execution may be delayed + * compared to {@link #onPostExecution(SplitClient, EventMetadata)}. + *

+ * If both this method and {@link #onPostExecutionView(SplitClient)} are implemented, + * both will be called (this method first). + * + * @param client the Split client instance + * @param metadata the event metadata, may be {@code null} for some events + * @throws SplitEventTaskMethodNotImplementedException if not overridden (default behavior) + */ + public void onPostExecutionView(SplitClient client, @Nullable EventMetadata metadata) { + throw new SplitEventTaskMethodNotImplementedException(); + } } diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index 3dca17c88..cc417efe2 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -145,30 +145,66 @@ public void run() { } private EventHandler createBackgroundHandler(final SplitEventTask task) { - return new EventHandler() { + return createEventHandler(task, "background", new TaskMethodCaller() { @Override - public void handle(SplitEvent event, EventMetadata metadata) { - try { - task.onPostExecution(mResources.getSplitClient()); - } catch (SplitEventTaskMethodNotImplementedException e) { - // Method not implemented by client, ignore - } catch (Exception e) { - Logger.e("Error executing background event task: " + e.getMessage()); - } + public void callWithMetadata(EventMetadata metadata) { + task.onPostExecution(mResources.getSplitClient(), metadata); } - }; + + @Override + public void callWithoutMetadata() { + task.onPostExecution(mResources.getSplitClient()); + } + }); } private EventHandler createMainThreadHandler(final SplitEventTask task) { + return createEventHandler(task, "main thread", new TaskMethodCaller() { + @Override + public void callWithMetadata(EventMetadata metadata) { + task.onPostExecutionView(mResources.getSplitClient(), metadata); + } + + @Override + public void callWithoutMetadata() { + task.onPostExecutionView(mResources.getSplitClient()); + } + }); + } + + /** + * Helper interface for calling task methods. + */ + private interface TaskMethodCaller { + void callWithMetadata(EventMetadata metadata) throws Exception; + void callWithoutMetadata() throws Exception; + } + + private EventHandler createEventHandler( + final SplitEventTask task, + final String threadType, + final TaskMethodCaller caller) { return new EventHandler() { @Override public void handle(SplitEvent event, EventMetadata metadata) { + executeTaskMethod(metadata, true, threadType, caller); + executeTaskMethod(metadata, false, threadType, caller); + } + + private void executeTaskMethod(EventMetadata metadata, boolean withMetadata, String threadType, TaskMethodCaller caller) { try { - task.onPostExecutionView(mResources.getSplitClient()); + if (withMetadata) { + caller.callWithMetadata(metadata); + } else { + caller.callWithoutMetadata(); + } } catch (SplitEventTaskMethodNotImplementedException e) { // Method not implemented by client, ignore } catch (Exception e) { - Logger.e("Error executing main thread event task: " + e.getMessage()); + String errorPrefix = withMetadata + ? "Error executing " + threadType + " event task (with metadata): " + : "Error executing " + threadType + " event task: "; + Logger.e(errorPrefix + e.getMessage()); } } }; diff --git a/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java index 6462168bc..0ff18385a 100644 --- a/main/src/androidTest/java/tests/service/EventsManagerTest.java +++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java @@ -1,17 +1,26 @@ package tests.service; +import android.os.Looper; + import org.junit.Assert; import org.junit.Test; +import java.util.Arrays; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import fake.SplitEventExecutorResourcesMock; import helper.TestingHelper; +import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; +import io.split.android.client.api.EventMetadata; 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.events.SplitInternalEvent; +import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTaskExecutorImpl; public class EventsManagerTest { @@ -232,4 +241,95 @@ public void testTimeoutMySegmentsUpdated() throws InterruptedException { Assert.assertFalse(updateTask.onExecutedCalled); Assert.assertTrue(timeoutTask.onExecutedCalled); } + + @Test + public void testAllFourCallbackMethodsAreCalledWithCorrectThreadContext() 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(4); // Expect 4 calls + + AtomicBoolean backgroundMetadataCalled = new AtomicBoolean(false); + AtomicBoolean backgroundLegacyCalled = new AtomicBoolean(false); + AtomicBoolean mainThreadMetadataCalled = new AtomicBoolean(false); + AtomicBoolean mainThreadLegacyCalled = new AtomicBoolean(false); + + AtomicBoolean backgroundMetadataOnMainThread = new AtomicBoolean(true); // Should be false + AtomicBoolean backgroundLegacyOnMainThread = new AtomicBoolean(true); // Should be false + AtomicBoolean mainThreadMetadataOnMainThread = new AtomicBoolean(false); // Should be true + AtomicBoolean mainThreadLegacyOnMainThread = 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 a task that implements ALL FOUR methods + eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + backgroundMetadataCalled.set(true); + backgroundMetadataOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + backgroundMetadata.set(metadata); + allCalledLatch.countDown(); + } + + @Override + public void onPostExecution(SplitClient client) { + backgroundLegacyCalled.set(true); + backgroundLegacyOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + allCalledLatch.countDown(); + } + + @Override + public void onPostExecutionView(SplitClient client, EventMetadata metadata) { + mainThreadMetadataCalled.set(true); + mainThreadMetadataOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + mainThreadMetadata.set(metadata); + allCalledLatch.countDown(); + } + + @Override + public void onPostExecutionView(SplitClient client) { + mainThreadLegacyCalled.set(true); + mainThreadLegacyOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + 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 + EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata( + Arrays.asList("flag1", "flag2")); + eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); + + Assert.assertTrue("All four callbacks should be called", allCalledLatch.await(5, TimeUnit.SECONDS)); + + Assert.assertTrue("Background metadata method should be called", backgroundMetadataCalled.get()); + Assert.assertTrue("Background legacy method should be called", backgroundLegacyCalled.get()); + Assert.assertTrue("Main thread metadata method should be called", mainThreadMetadataCalled.get()); + Assert.assertTrue("Main thread legacy method should be called", mainThreadLegacyCalled.get()); + + Assert.assertFalse("Background metadata method should NOT run on main thread", backgroundMetadataOnMainThread.get()); + Assert.assertFalse("Background legacy method should NOT run on main thread", backgroundLegacyOnMainThread.get()); + Assert.assertTrue("Main thread metadata method SHOULD run on main thread", mainThreadMetadataOnMainThread.get()); + Assert.assertTrue("Main thread legacy method SHOULD run on main thread", mainThreadLegacyOnMainThread.get()); + + Assert.assertNotNull("Background metadata should not be null", backgroundMetadata.get()); + Assert.assertTrue("Background metadata should contain updatedFlags", backgroundMetadata.get().containsKey("updatedFlags")); + Assert.assertNotNull("Main thread metadata should not be null", mainThreadMetadata.get()); + Assert.assertTrue("Main thread metadata should contain updatedFlags", mainThreadMetadata.get().containsKey("updatedFlags")); + } } 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 d4a1e1977..5b4643f3a 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.api.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 { @@ -266,4 +272,176 @@ private static void execute(boolean shouldStop, long intervalExecutionTime, long } } } + + @Test + public void sdkUpdateWithMetadataCallsMetadataMethod() 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.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata 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()); + assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); + } + + @Test + public void sdkUpdateWithMetadataCallsMetadataMethodOnMainThread() 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.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client, EventMetadata 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()); + assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); + } + + @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 sdkUpdateCallsBothMethodsWhenBothImplemented() throws InterruptedException { + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch bothCalledLatch = new CountDownLatch(2); + final boolean[] metadataMethodCalled = {false}; + final boolean[] legacyMethodCalled = {false}; + AtomicReference receivedMetadata = new AtomicReference<>(); + + waitForSdkReady(eventManager, readyLatch); + + eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + metadataMethodCalled[0] = true; + receivedMetadata.set(metadata); + bothCalledLatch.countDown(); + } + + @Override + public void onPostExecution(SplitClient client) { + legacyMethodCalled[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("Metadata method should be called", metadataMethodCalled[0]); + assertTrue("Legacy method should also be called", legacyMethodCalled[0]); + assertNotNull("Metadata should be passed to metadata method", receivedMetadata.get()); + assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); + } + + @Test + public void sdkReadyFromCacheCallsBothMethodsWhenBothImplemented() throws InterruptedException { + SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); + + CountDownLatch bothCalledLatch = new CountDownLatch(2); // Expect 2 calls + final boolean[] metadataMethodCalled = {false}; + final boolean[] legacyMethodCalled = {false}; + + // Register a task that implements both versions + eventManager.register(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + metadataMethodCalled[0] = true; + bothCalledLatch.countDown(); + } + + @Override + public void onPostExecution(SplitClient client) { + legacyMethodCalled[0] = true; + bothCalledLatch.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 bothCalled = bothCalledLatch.await(3, TimeUnit.SECONDS); + assertTrue("Both callbacks should be called", bothCalled); + assertTrue("Metadata method should be called", metadataMethodCalled[0]); + assertTrue("Legacy method should also be called", legacyMethodCalled[0]); + } + + 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/events/SplitEventTaskMetadataTest.java b/main/src/test/java/io/split/android/client/events/SplitEventTaskMetadataTest.java new file mode 100644 index 000000000..03b125ae2 --- /dev/null +++ b/main/src/test/java/io/split/android/client/events/SplitEventTaskMetadataTest.java @@ -0,0 +1,123 @@ +package io.split.android.client.events; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import io.split.android.client.SplitClient; +import io.split.android.client.api.EventMetadata; +import io.split.android.client.events.metadata.EventMetadataHelpers; + +public class SplitEventTaskMetadataTest { + + @Mock + private SplitClient mClient; + + @Mock + private EventMetadata mMetadata; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void onPostExecutionWithMetadataThrowsExceptionWhenNotImplemented() { + SplitEventTask task = new SplitEventTask(); + + assertThrows(SplitEventTaskMethodNotImplementedException.class, () -> { + task.onPostExecution(mClient, mMetadata); + }); + } + + @Test + public void onPostExecutionViewWithMetadataThrowsExceptionWhenNotImplemented() { + SplitEventTask task = new SplitEventTask(); + + assertThrows(SplitEventTaskMethodNotImplementedException.class, () -> { + task.onPostExecutionView(mClient, mMetadata); + }); + } + + @Test + public void onPostExecutionWithMetadataCanBeOverridden() { + EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata( + java.util.Arrays.asList("flag1", "flag2")); + + SplitEventTask task = new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + // Overridden implementation + } + }; + + // Should not throw exception + task.onPostExecution(mClient, metadata); + } + + @Test + public void onPostExecutionViewWithMetadataCanBeOverridden() { + EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(1234567890L, false); + + SplitEventTask task = new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client, EventMetadata metadata) { + // Overridden implementation + } + }; + + // Should not throw exception + task.onPostExecutionView(mClient, metadata); + } + + @Test + public void onPostExecutionWithMetadataReceivesCorrectParameters() { + EventMetadata expectedMetadata = EventMetadataHelpers.createUpdatedFlagsMetadata( + java.util.Arrays.asList("flag1", "flag2")); + + final boolean[] metadataReceived = {false}; + final boolean[] hasUpdatedFlags = {false}; + + SplitEventTask task = new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + metadataReceived[0] = metadata != null; + hasUpdatedFlags[0] = metadata != null && metadata.containsKey("updatedFlags"); + } + }; + + task.onPostExecution(mClient, expectedMetadata); + + assertTrue("Metadata should be received", metadataReceived[0]); + assertTrue("Metadata should contain updatedFlags", hasUpdatedFlags[0]); + } + + @Test + public void onPostExecutionViewWithMetadataReceivesCorrectParameters() { + EventMetadata expectedMetadata = EventMetadataHelpers.createCacheReadyMetadata(1234567890L, false); + + final boolean[] metadataReceived = {false}; + final boolean[] hasTimestamp = {false}; + final boolean[] hasFreshInstall = {false}; + + SplitEventTask task = new SplitEventTask() { + @Override + public void onPostExecutionView(SplitClient client, EventMetadata metadata) { + metadataReceived[0] = metadata != null; + hasTimestamp[0] = metadata != null && metadata.containsKey("lastUpdateTimestamp"); + hasFreshInstall[0] = metadata != null && metadata.containsKey("freshInstall"); + } + }; + + task.onPostExecutionView(mClient, expectedMetadata); + + assertTrue("Metadata should be received", metadataReceived[0]); + assertTrue("Metadata should contain lastUpdateTimestamp", hasTimestamp[0]); + assertTrue("Metadata should contain freshInstall", hasFreshInstall[0]); + } +} + From fb1c58197d95553fb1c4264acb7fb48e75c75c39 Mon Sep 17 00:00:00 2001 From: gthea Date: Wed, 10 Dec 2025 16:21:45 -0300 Subject: [PATCH 14/24] Metadata for killed flag (#841) --- .../java/tests/service/EventsManagerTest.java | 45 +++++++++++++++++++ .../client/service/splits/SplitKillTask.java | 8 +++- .../client/service/SplitKillTaskTest.java | 16 ++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java index 0ff18385a..a7080874c 100644 --- a/main/src/androidTest/java/tests/service/EventsManagerTest.java +++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java @@ -6,6 +6,8 @@ import org.junit.Test; import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -176,6 +178,49 @@ public void testKilledSplit() throws InterruptedException { Assert.assertTrue(updateTask.onExecutedCalled); } + @Test + public void testKilledSplitWithMetadata() 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 updateLatch = new CountDownLatch(1); + AtomicReference 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 + eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata 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)); + + EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata( + Collections.singletonList("killed_flag")); + eventManager.notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION, metadata); + + Assert.assertTrue("SDK_UPDATE should fire", updateLatch.await(5, TimeUnit.SECONDS)); + Assert.assertNotNull("Metadata should not be null", receivedMetadata.get()); + Assert.assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); + List metadataList = (List) receivedMetadata.get().get("updatedFlags"); + Assert.assertTrue("Metadata should contain only killed_flag", metadataList.size() == 1 && metadataList.contains("killed_flag")); + } + @Test public void testKilledSplitBeforeReady() throws InterruptedException { 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..7754d1c0d 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.api.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/test/java/io/split/android/client/service/SplitKillTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java index 1545f6d5c..7f1c3de16 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 @@ -8,6 +8,9 @@ import org.mockito.Mock; import org.mockito.Mockito; +import java.util.List; + +import io.split.android.client.api.EventMetadata; import io.split.android.client.dtos.Split; import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; @@ -20,6 +23,7 @@ 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,17 @@ 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); + @SuppressWarnings("unchecked") + List updatedFlags = (List) metadata.get("updatedFlags"); + Assert.assertNotNull(updatedFlags); + Assert.assertEquals(1, updatedFlags.size()); + Assert.assertTrue(updatedFlags.contains("split1")); } @Test From 28f5534246fe153a2c5a57efa394e7f10dbd4fbe Mon Sep 17 00:00:00 2001 From: gthea Date: Fri, 12 Dec 2025 16:34:32 -0300 Subject: [PATCH 15/24] Integration tests for events with metadata (#842) --- .../events/SdkEventsIntegrationTest.java | 1298 +++++++++++++++++ .../split/android/client/SplitClientImpl.java | 8 +- .../SplitClientImplEventRegistrationTest.java | 129 ++ .../client/service/SplitTaskExecutorTest.java | 12 +- 4 files changed, 1440 insertions(+), 7 deletions(-) create mode 100644 main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java create mode 100644 main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java 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..9cef41e98 --- /dev/null +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -0,0 +1,1298 @@ +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.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +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 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.EventMetadata; +import io.split.android.client.api.Key; +import io.split.android.client.events.SplitEvent; +import io.split.android.client.events.SplitEventTask; +import io.split.android.client.network.HttpMethod; +import io.split.android.client.storage.db.GeneralInfoEntity; +import io.split.android.client.storage.db.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 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()) + .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 = 1; + 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 "freshInstall" 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 + long testTimestamp = System.currentTimeMillis(); + populateDatabaseWithCacheData(testTimestamp); + + SplitClientConfig config = buildConfig(); + SplitFactory factory = buildFactory(config); + + // And: a handler H is registered for sdkReadyFromCache + AtomicInteger handlerInvocationCount = new AtomicInteger(0); + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch cacheReadyLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + registerCacheReadyHandler(client, handlerInvocationCount, receivedMetadata, cacheReadyLatch); + + boolean fired = cacheReadyLatch.await(10, TimeUnit.SECONDS); + + // Then: sdkReadyFromCache is emitted exactly once + assertTrue("SDK_READY_FROM_CACHE should fire", fired); + assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get()); + + // And: the metadata contains "freshInstall" with value false + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertTrue("Metadata should contain freshInstall key", receivedMetadata.get().containsKey("freshInstall")); + assertFalse("freshInstall should be false for cache path", + (Boolean) receivedMetadata.get().get("freshInstall")); + + // And: the metadata contains "lastUpdateTimestamp" with a valid timestamp + assertTrue("Metadata should contain lastUpdateTimestamp key", + receivedMetadata.get().containsKey("lastUpdateTimestamp")); + Long lastUpdateTimestamp = (Long) receivedMetadata.get().get("lastUpdateTimestamp"); + assertNotNull("lastUpdateTimestamp should not be null", lastUpdateTimestamp); + assertTrue("lastUpdateTimestamp should be valid", lastUpdateTimestamp > 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 "freshInstall" with value true + */ + @Test + public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exception { + // Given: SDK is starting without persistent storage (fresh install) + // Database is already empty from setup() + + SplitClientConfig config = buildConfig(); + SplitFactory factory = buildFactory(config); + + // And: a handler H is registered for sdkReadyFromCache + AtomicInteger handlerInvocationCount = new AtomicInteger(0); + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch cacheReadyLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + registerCacheReadyHandler(client, handlerInvocationCount, receivedMetadata, cacheReadyLatch); + + // When: internal events "targetingRulesSyncComplete" and "membershipsSyncComplete" are notified + boolean fired = cacheReadyLatch.await(10, TimeUnit.SECONDS); + + // Then: sdkReadyFromCache is emitted exactly once + assertTrue("SDK_READY_FROM_CACHE should fire", fired); + assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get()); + + // And: the metadata contains "freshInstall" with value true + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertTrue("Metadata should contain freshInstall key", receivedMetadata.get().containsKey("freshInstall")); + assertTrue("freshInstall should be true for sync path (fresh install)", + (Boolean) receivedMetadata.get().get("freshInstall")); + + 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 + // Use fresh install (no cache) so SDK_READY_FROM_CACHE fires via sync path, + // then SDK_READY fires after sync completes + // Database is already empty from setup() + + SplitClientConfig config = buildConfig(); + SplitFactory factory = buildFactory(config); + + // And: handlers are registered BEFORE creating client to catch all events + AtomicInteger cacheHandlerCount = new AtomicInteger(0); + AtomicInteger readyHandlerCount = new AtomicInteger(0); + CountDownLatch cacheReadyLatch = new CountDownLatch(1); + CountDownLatch readyLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + + // Register handlers immediately + client.on(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + cacheHandlerCount.incrementAndGet(); + cacheReadyLatch.countDown(); + } + }); + + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + readyHandlerCount.incrementAndGet(); + readyLatch.countDown(); + } + }); + + // When: sync completes (happens automatically during initialization) + // SDK_READY_FROM_CACHE fires via sync path when TARGETING_RULES_SYNC_COMPLETE and MEMBERSHIPS_SYNC_COMPLETE fire + // Wait for SDK_READY_FROM_CACHE first + boolean cacheFired = cacheReadyLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_READY_FROM_CACHE should fire", cacheFired); + assertEquals("Cache handler should be invoked once", 1, cacheHandlerCount.get()); + + // SDK_READY requires both SDK_READY_FROM_CACHE (prerequisite) and sync completion (requireAll) + // Wait for SDK_READY to fire + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + + // Then: sdkReady is emitted exactly once + assertTrue("SDK_READY should fire after SDK_READY_FROM_CACHE and sync completion. " + + "Cache fired: " + cacheHandlerCount.get() + ", Ready fired: " + readyHandlerCount.get(), + readyFired); + assertEquals("Ready handler should be invoked exactly once", 1, readyHandlerCount.get()); + + // Verify both events fired + assertEquals("SDK_READY_FROM_CACHE should fire", 1, cacheHandlerCount.get()); + assertEquals("SDK_READY should fire after SDK_READY_FROM_CACHE", 1, readyHandlerCount.get()); + + 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 + AtomicInteger lateHandlerCount = new AtomicInteger(0); + CountDownLatch lateHandlerLatch = new CountDownLatch(1); + + registerReadyHandler(fixture.client, lateHandlerCount, lateHandlerLatch); + + // Then: handler H is invoked exactly once immediately (replay) + boolean replayFired = lateHandlerLatch.await(5, TimeUnit.SECONDS); + assertTrue("Late handler should receive replay", replayFired); + assertEquals("Late handler should be invoked exactly once", 1, lateHandlerCount.get()); + + // And: sdkReady is not emitted again (verify no additional invocations) + Thread.sleep(500); + assertEquals("Late handler should not be invoked again", 1, lateHandlerCount.get()); + + 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")); + + AtomicInteger updateHandlerCount = new AtomicInteger(0); + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + + // Register handlers BEFORE SDK_READY fires + fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + updateHandlerCount.incrementAndGet(); + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + readyLatch.countDown(); + } + }); + + // Wait a bit to see if SDK_UPDATE fires prematurely (during initial sync) + Thread.sleep(1000); + + // Then: sdkUpdate is not emitted because sdkReady has not fired yet + assertEquals("SDK_UPDATE should not fire before SDK_READY", 0, updateHandlerCount.get()); + + // When: SDK_READY fires + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_READY should fire", readyFired); + + // Wait for SSE connection + fixture.waitForSseConnection(); + + // When: a new "splitsUpdated" event is notified via SSE (after SDK_READY has fired) + fixture.pushSplitUpdate("2000", "1000"); + + // Then: sdkUpdate is emitted and handler H is invoked once + boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire after SDK_READY when splits update arrives", updateFired); + assertEquals("Handler should be invoked exactly once", 1, updateHandlerCount.get()); + assertNotNull("Metadata should not be null", receivedMetadata.get()); + + 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")); + + AtomicInteger updateHandlerCount = new AtomicInteger(0); + AtomicReference lastMetadata = new AtomicReference<>(); + CountDownLatch updateLatch = new CountDownLatch(1); + + fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + updateHandlerCount.incrementAndGet(); + lastMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + // When: a split update notification arrives via SSE + fixture.pushSplitUpdate(); + + // Then: sdkUpdate is emitted and handler H is invoked + boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire after split update notification", updateFired); + assertEquals("Handler should be invoked once", 1, updateHandlerCount.get()); + assertNotNull("Metadata should not be null", lastMetadata.get()); + + 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); + AtomicInteger handler2Count = new AtomicInteger(0); + CountDownLatch firstUpdateLatch = new CountDownLatch(1); + AtomicReference secondUpdateLatchRef = new AtomicReference<>(null); + + // And: a handler H1 is registered for sdkUpdate + fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handler1Count.incrementAndGet(); + firstUpdateLatch.countDown(); + // Count down second latch if it exists (second update) + CountDownLatch secondLatch = secondUpdateLatchRef.get(); + if (secondLatch != null) { + secondLatch.countDown(); + } + } + }); + + // When: an internal "splitsUpdated" event is notified via SSE + // Use large change numbers to avoid any edge cases with change number validation + fixture.pushSplitUpdate("2000", "1000"); + + // Then: sdkUpdate is emitted and handler H1 is invoked once + boolean firstUpdateFired = firstUpdateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire for H1", firstUpdateFired); + assertEquals("H1 should be invoked once", 1, handler1Count.get()); + + // Wait to ensure first update is fully processed and stored + Thread.sleep(1000); + + // When: a second handler H2 is registered for sdkUpdate after one sdkUpdate has already fired + CountDownLatch secondUpdateLatch = new CountDownLatch(2); + secondUpdateLatchRef.set(secondUpdateLatch); + + fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handler2Count.incrementAndGet(); + secondUpdateLatch.countDown(); + } + }); + + // Then: H2 does not receive a replay for past sdkUpdate events + Thread.sleep(500); + assertEquals("H2 should not receive replay", 0, handler2Count.get()); + + // Ensure handlers are registered and first update is fully processed before pushing second update + Thread.sleep(500); + + // Send keep-alive to ensure SSE connection is still active + if (fixture.streamingData != null) { + TestingHelper.pushKeepAlive(fixture.streamingData); + } + + // When: another internal "splitsUpdated" event is notified (with incrementing change number) + // Use a higher change number to ensure it's accepted after the first update + fixture.pushSplitUpdate("2001", "2000"); + + // Then: both H1 and H2 are invoked for that second sdkUpdate + boolean secondUpdateFired = secondUpdateLatch.await(15, TimeUnit.SECONDS); + assertTrue("Second SDK_UPDATE should fire. H1 count: " + handler1Count.get() + + ", H2 count: " + handler2Count.get() + + ", secondUpdateLatch count: " + secondUpdateLatch.getCount(), secondUpdateFired); + + // H1 should now have 2 total invocations (1 from first + 1 from second) + assertEquals("H1 should have 2 total invocations", 2, handler1Count.get()); + // H2 should have 1 invocation (only from second update, no replay) + assertEquals("H2 should have 1 invocation (no replay)", 1, handler2Count.get()); + + 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: handlers are registered + // And: the readiness timeout is configured to a short timeout (2 seconds) + // Use a mock server that delays responses to prevent sync from completing quickly + SplitClientConfig config = SplitClientConfig.builder() + .serviceEndpoints(endpoints()) + .ready(2000) // 2 second timeout + .featuresRefreshRate(999999) + .segmentsRefreshRate(999999) + .impressionsRefreshRate(999999) + .syncEnabled(true) // Keep sync enabled but delay responses + .trafficType("account") + .build(); + + // Set up mock server to delay responses so sync doesn't complete before timeout + final Dispatcher delayedDispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + // Delay response to prevent sync from completing + return new MockResponse() + .setResponseCode(200) + .setBody(IntegrationHelper.dummyAllSegments()) + .setBodyDelay(5, TimeUnit.SECONDS); // 5 second delay + } else if (path.contains("/splitChanges")) { + // Delay response to prevent sync from completing + long id = mCurSplitReqId++; + return new MockResponse() + .setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id)) + .setBodyDelay(5, TimeUnit.SECONDS); // 5 second delay + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(delayedDispatcher); + + SplitFactory factory = buildFactory(config); + + AtomicInteger timeoutHandlerCount = new AtomicInteger(0); + AtomicInteger readyHandlerCount = new AtomicInteger(0); + CountDownLatch timeoutLatch = new CountDownLatch(1); + CountDownLatch readyLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + timeoutHandlerCount.incrementAndGet(); + timeoutLatch.countDown(); + } + }); + + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + readyHandlerCount.incrementAndGet(); + readyLatch.countDown(); + } + }); + + // When: the timeout elapses without sdkReady firing (due to delayed responses) + boolean timeoutFired = timeoutLatch.await(5, TimeUnit.SECONDS); + + // Then: sdkReadyTimedOut is emitted exactly once + assertTrue("SDK_READY_TIMED_OUT should fire after timeout. " + + "Timeout count: " + timeoutHandlerCount.get() + ", Ready count: " + readyHandlerCount.get(), + timeoutFired); + assertEquals("Timeout handler should be invoked once", 1, timeoutHandlerCount.get()); + + // And: sdkReady is not emitted (sync didn't complete in time) + Thread.sleep(500); + assertEquals("SDK_READY should not fire before timeout", 0, readyHandlerCount.get()); + + 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: handlers are registered + // And: the readiness timeout is configured to a longer timeout (10 seconds) + SplitClientConfig config = SplitClientConfig.builder() + .serviceEndpoints(endpoints()) + .ready(10000) // 10 second timeout + .featuresRefreshRate(999999) + .segmentsRefreshRate(999999) + .impressionsRefreshRate(999999) + .syncEnabled(true) + .trafficType("account") + .build(); + + SplitFactory factory = buildFactory(config); + AtomicInteger timeoutHandlerCount = new AtomicInteger(0); + AtomicInteger readyHandlerCount = new AtomicInteger(0); + CountDownLatch readyLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + timeoutHandlerCount.incrementAndGet(); + } + }); + registerReadyHandler(client, readyHandlerCount, readyLatch); + + // When: internal events for sdkReadyFromCache and sdkReady complete before the timeout elapses + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + + // Then: sdkReady is emitted + assertTrue("SDK_READY should fire", readyFired); + assertEquals("Ready handler should be invoked once", 1, readyHandlerCount.get()); + + // And: sdkReadyTimedOut is not emitted + Thread.sleep(2000); // Wait a bit to ensure timeout doesn't fire + assertEquals("SDK_READY_TIMED_OUT should not fire (suppressed)", 0, timeoutHandlerCount.get()); + + 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 { + // Given: handlers are registered + SplitClientConfig config = buildConfig(); + SplitFactory factory = buildFactory(config); + + AtomicInteger updateHandlerCount = new AtomicInteger(0); + AtomicInteger readyHandlerCount = new AtomicInteger(0); + CountDownLatch readyLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + registerUpdateHandler(client, updateHandlerCount, null); + registerReadyHandler(client, readyHandlerCount, readyLatch); + + // When: sync completes (happens automatically during initialization) + // The *_UPDATED events fire before SDK_READY, so SDK_UPDATE shouldn't fire + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_READY should fire", readyFired); + + // Then: sdkUpdate is NOT emitted because the *_UPDATED events were notified before sdkReady fired + Thread.sleep(1000); + assertEquals("SDK_UPDATE should not fire during initial sync", 0, updateHandlerCount.get()); + + 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.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handler1Count.incrementAndGet(); + handler1Order.set(orderCounter.incrementAndGet()); + updateLatch.countDown(); + } + }); + + fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handler2Count.incrementAndGet(); + handler2Order.set(orderCounter.incrementAndGet()); + updateLatch.countDown(); + throw new RuntimeException("Handler H2 exception"); + } + }); + + fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata 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")); + + AtomicInteger updateHandlerCount = new AtomicInteger(0); + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch updateLatch = new CountDownLatch(1); + + // Given: a handler H is registered for sdkUpdate which inspects the received metadata + fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + updateHandlerCount.incrementAndGet(); + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + // When: an internal "splitsUpdated" event is notified via SSE + fixture.pushSplitUpdate(); + + // Then: sdkUpdate is emitted and handler H is invoked once + boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire", updateFired); + assertEquals("Handler should be invoked exactly once", 1, updateHandlerCount.get()); + + // And: handler H receives metadata + assertNotNull("Metadata should not be null", receivedMetadata.get()); + + 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 for that client + * Then no external events are emitted + * And handler H is never invoked + * When registering a new handler H2 for sdkUpdate after destroy + * Then the registration is a no-op + * And H2 is never invoked + */ + @Test + public void destroyingClientStopsEventsAndClearsHandlers() throws Exception { + // Given: sdkReady has already been emitted + TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); + + AtomicInteger handler1Count = new AtomicInteger(0); + AtomicInteger handler2Count = new AtomicInteger(0); + + // Given: a handler H registered for sdkUpdate + registerUpdateHandler(fixture.client, handler1Count, null); + + // When: the client is destroyed + fixture.client.destroy(); + + // When: registering a new handler H2 for sdkUpdate after destroy + registerUpdateHandler(fixture.client, handler2Count, null); + + // Then: handlers are not invoked (client is destroyed) + Thread.sleep(1000); + assertEquals("Handler H1 should not be invoked after destroy", 0, handler1Count.get()); + assertEquals("Handler H2 should not be invoked after destroy", 0, handler2Count.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")); + + AtomicInteger handlerACount = new AtomicInteger(0); + AtomicInteger handlerBCount = new AtomicInteger(0); + CountDownLatch updateLatchA = new CountDownLatch(1); + CountDownLatch updateLatchB = new CountDownLatch(1); + + // And: handlers HA and HB are registered for sdkUpdate + fixture.clientA.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handlerACount.incrementAndGet(); + updateLatchA.countDown(); + } + }); + + fixture.clientB.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handlerBCount.incrementAndGet(); + updateLatchB.countDown(); + } + }); + + // When: a SDK-scoped internal "splitsUpdated" event is notified via SSE + fixture.pushSplitUpdate(); + + // Then: sdkUpdate is emitted once per client + boolean updateAFired = updateLatchA.await(10, TimeUnit.SECONDS); + boolean updateBFired = updateLatchB.await(10, TimeUnit.SECONDS); + + assertTrue("SDK_UPDATE should fire for ClientA", updateAFired); + assertTrue("SDK_UPDATE should fire for ClientB", updateBFired); + + // And: handler HA is invoked once and handler HB is invoked once + assertEquals("Handler A should be invoked once", 1, handlerACount.get()); + assertEquals("Handler B should be invoked once", 1, handlerBCount.get()); + + 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")); + + AtomicInteger handlerACount = new AtomicInteger(0); + AtomicInteger handlerBCount = new AtomicInteger(0); + CountDownLatch updateLatchA = new CountDownLatch(1); + CountDownLatch updateLatchB = new CountDownLatch(1); + + // And: handlers HA and HB are registered for sdkUpdate + fixture.clientA.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handlerACount.incrementAndGet(); + updateLatchA.countDown(); + } + }); + + fixture.clientB.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + handlerBCount.incrementAndGet(); + updateLatchB.countDown(); + } + }); + + // When: a SDK-scoped split update notification arrives (affects all clients) + fixture.pushSplitUpdate(); + + // Then: both clients receive SDK_UPDATE since splitsUpdated is SDK-scoped + boolean updateAFired = updateLatchA.await(10, TimeUnit.SECONDS); + boolean updateBFired = updateLatchB.await(10, TimeUnit.SECONDS); + + assertTrue("SDK_UPDATE should fire for ClientA", updateAFired); + assertTrue("SDK_UPDATE should fire for ClientB", updateBFired); + assertEquals("Handler A should be invoked once", 1, handlerACount.get()); + assertEquals("Handler B should be invoked once", 1, handlerBCount.get()); + + fixture.destroy(); + } + + /** + * Creates a client and waits for SDK_READY to fire. + * Returns a TestClientFixture containing the factory, client, and ready latch. + */ + private TestClientFixture createClientAndWaitForReady(SplitClientConfig config, Key key) throws InterruptedException { + SplitFactory factory = buildFactory(config); + SplitClient client = factory.client(key); + CountDownLatch readyLatch = new CountDownLatch(1); + + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + readyLatch.countDown(); + } + }); + + boolean readyFired = readyLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_READY should fire", readyFired); + + return new TestClientFixture(factory, client, readyLatch); + } + + /** + * Creates a client with default config and waits for SDK_READY. + */ + private TestClientFixture createClientAndWaitForReady(Key key) throws InterruptedException { + return createClientAndWaitForReady(buildConfig(), key); + } + + /** + * Creates a client with streaming enabled but does NOT wait for SDK_READY. + * Useful for tests that need to register handlers before SDK_READY fires. + * Returns a fixture that can push SSE messages to trigger SDK_UPDATE. + */ + private TestClientFixture createStreamingClient(Key key) throws IOException { + BlockingQueue streamingData = new LinkedBlockingDeque<>(); + 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); + } + + /** + * Creates a client with streaming enabled and waits for SDK_READY. + * Returns a fixture that can push SSE messages to trigger SDK_UPDATE. + */ + private TestClientFixture createStreamingClientAndWaitForReady(Key key) throws InterruptedException, IOException { + TestClientFixture fixture = createStreamingClient(key); + + CountDownLatch readyLatch = new CountDownLatch(1); + fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + 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); + } + + /** + * Creates a standard streaming dispatcher for mock HTTP responses. + */ + 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; + } + } + }; + } + + /** + * Creates two clients with streaming enabled and waits for both to be ready. + */ + private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key keyB) throws InterruptedException, IOException { + BlockingQueue streamingData = new LinkedBlockingDeque<>(); + CountDownLatch sseLatch = new CountDownLatch(1); + + 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 = new CountDownLatch(1); + CountDownLatch readyLatchB = new CountDownLatch(1); + + registerReadyHandler(clientA, null, readyLatchA); + registerReadyHandler(clientB, null, readyLatchB); + + boolean readyA = readyLatchA.await(30, TimeUnit.SECONDS); + boolean readyB = readyLatchB.await(30, TimeUnit.SECONDS); + assertTrue("ClientA SDK_READY should fire", readyA); + assertTrue("ClientB SDK_READY should fire", readyB); + + // Wait for SSE connection and send keep-alive + sseLatch.await(10, TimeUnit.SECONDS); + TestingHelper.pushKeepAlive(streamingData); + + return new TwoClientFixture(factory, clientA, clientB, streamingData); + } + + /** + * Registers a handler for SDK_READY_FROM_CACHE that captures metadata and counts invocations. + */ + private void registerCacheReadyHandler(SplitClient client, AtomicInteger count, + AtomicReference metadata, + CountDownLatch latch) { + client.on(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata eventMetadata) { + count.incrementAndGet(); + if (metadata != null) metadata.set(eventMetadata); + if (latch != null) latch.countDown(); + } + }); + } + + /** + * Registers a handler for SDK_UPDATE that counts invocations and optionally captures metadata. + */ + private void registerUpdateHandler(SplitClient client, AtomicInteger count, + AtomicReference metadata) { + client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata eventMetadata) { + count.incrementAndGet(); + if (metadata != null) metadata.set(eventMetadata); + } + }); + } + + /** + * Registers a handler for SDK_READY that counts invocations and optionally counts down a latch. + */ + private void registerReadyHandler(SplitClient client, AtomicInteger count, CountDownLatch latch) { + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client, EventMetadata metadata) { + if (count != null) count.incrementAndGet(); + if (latch != null) latch.countDown(); + } + }); + } + + private static final String SPLIT_UPDATE_PAYLOAD = "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0="; + + /** + * 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() { + pushSplitUpdate("9999999999999", "1000"); + } + + void pushSplitUpdate(String changeNumber, String previousChangeNumber) { + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.splitChangeV2( + changeNumber, previousChangeNumber, "0", SPLIT_UPDATE_PAYLOAD)); + } + } + + void pushSplitKill(String splitName) { + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.splitKill("9999999999999", splitName)); + } + } + + void destroy() { + factory.destroy(); + } + } + + /** + * Helper class to hold factory and two clients together for cleanup. + */ + private static class TwoClientFixture { + final SplitFactory factory; + final SplitClient clientA; + final SplitClient clientB; + final BlockingQueue streamingData; + + TwoClientFixture(SplitFactory factory, SplitClient clientA, SplitClient clientB) { + this(factory, clientA, clientB, null); + } + + TwoClientFixture(SplitFactory factory, SplitClient clientA, SplitClient clientB, BlockingQueue streamingData) { + this.factory = factory; + this.clientA = clientA; + this.clientB = clientB; + this.streamingData = streamingData; + } + + void pushSplitUpdate() { + if (streamingData != null) { + pushMessage(streamingData, IntegrationHelper.splitChangeV2CompressionType0()); + } + } + + void destroy() { + factory.destroy(); + } + } + + 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(String.format("{\"name\":\"split_%d\", \"changeNumber\": %d}", 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("{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":null}"); + segmentEntity.setUpdatedAt(System.currentTimeMillis() / 1000); + mDatabase.mySegmentDao().update(segmentEntity); + + // Populate segments for key_1 + MySegmentEntity segmentEntity2 = new MySegmentEntity(); + segmentEntity2.setUserKey("key_1"); + segmentEntity2.setSegmentList("{\"k\":[{\"n\":\"segment1\"}],\"cn\":null}"); + segmentEntity2.setUpdatedAt(System.currentTimeMillis() / 1000); + mDatabase.mySegmentDao().update(segmentEntity2); + } +} 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..fedd546ec 100644 --- a/main/src/main/java/io/split/android/client/SplitClientImpl.java +++ b/main/src/main/java/io/split/android/client/SplitClientImpl.java @@ -190,8 +190,12 @@ 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; } 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..508371213 --- /dev/null +++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java @@ -0,0 +1,129 @@ +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.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.MockitoAnnotations; + +import io.split.android.client.api.Key; +import io.split.android.client.attributes.AttributesManager; +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.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)); + } +} 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 From e061820831542377fc13e9dbe2df596ec01f2036 Mon Sep 17 00:00:00 2001 From: gthea Date: Wed, 7 Jan 2026 16:35:57 -0300 Subject: [PATCH 16/24] Metadata using Event Listener (#846) --- .github/workflows/sonarqube.yml | 16 ++- api/README.md | 1 - .../io/split/android/client/SplitClient.java | 28 ++++ .../client/events/SdkEventListener.java | 84 +++++++++++ .../events/SdkReadyFromCacheMetadata.java | 49 +++++++ .../client/events/SdkUpdateMetadata.java | 36 +++++ .../android/client/events/SplitEventTask.java | 67 +-------- .../events/SdkReadyFromCacheMetadataTest.java | 66 +++++++++ .../client/events/SdkUpdateMetadataTest.java | 46 ++++++ build.gradle | 6 +- .../events/EventsManagerCoordinator.java | 10 +- .../client/events/ISplitEventsManager.java | 2 +- .../events/ListenableEventsManager.java | 2 + .../client/events/SplitEventDelivery.java | 2 +- .../client/events/SplitEventsManager.java | 131 ++++++++++-------- .../events/metadata}/EventMetadata.java | 33 ++--- .../events/metadata/EventMetadataBuilder.java | 2 - .../events/metadata/EventMetadataHelpers.java | 12 +- .../events/metadata/EventMetadataImpl.java | 23 +-- .../client/events/metadata/MetadataKeys.java | 34 +++++ .../events/metadata/TypedTaskConverter.java | 52 +++++++ .../events/TypedTaskConversionTest.java | 67 +++++++++ .../metadata/EventMetadataBuilderTest.java | 22 ++- .../metadata/EventMetadataHelpersTest.java | 36 +++-- .../metadata/EventMetadataImplTest.java | 88 ++---------- .../events/metadata/MetadataKeysTest.java | 27 ++++ .../java/fake/SplitClientStub.java | 6 + .../events/SdkEventsIntegrationTest.java | 108 +++++++-------- .../java/tests/service/EventsManagerTest.java | 100 ++++++------- .../AlwaysReturnControlSplitClient.java | 6 + .../split/android/client/SplitClientImpl.java | 10 ++ .../localhost/LocalhostSplitClient.java | 13 +- .../localhost/LocalhostSplitsStorage.java | 2 +- .../splits/SplitInPlaceUpdateTask.java | 2 +- .../client/service/splits/SplitKillTask.java | 2 +- .../client/service/splits/SplitsSyncTask.java | 2 +- .../service/splits/SplitsUpdateTask.java | 2 +- .../synchronizer/LoadLocalDataListener.java | 2 +- .../SplitClientImplEventRegistrationTest.java | 17 +++ .../events/EventsManagerCoordinatorTest.java | 53 ++++++- .../client/events/EventsManagerTest.java | 79 +++++------ .../events/SplitEventTaskMetadataTest.java | 123 ---------------- .../localhost/LocalhostSplitClientTest.java | 17 +++ .../localhost/LocalhostSplitsStorageTest.java | 13 +- .../service/SplitInPlaceUpdateTaskTest.java | 22 ++- .../client/service/SplitKillTaskTest.java | 10 +- .../client/service/SplitSyncTaskTest.java | 20 ++- .../client/service/SplitUpdateTaskTest.java | 20 +-- .../LoadLocalDataListenerTest.java | 2 +- .../android/fake/SplitEventsManagerStub.java | 8 +- 50 files changed, 953 insertions(+), 628 deletions(-) create mode 100644 api/src/main/java/io/split/android/client/events/SdkEventListener.java create mode 100644 api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java create mode 100644 api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java create mode 100644 api/src/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.java create mode 100644 api/src/test/java/io/split/android/client/events/SdkUpdateMetadataTest.java rename {api/src/main/java/io/split/android/client/api => events-domain/src/main/java/io/split/android/client/events/metadata}/EventMetadata.java (67%) create mode 100644 events-domain/src/main/java/io/split/android/client/events/metadata/MetadataKeys.java create mode 100644 events-domain/src/main/java/io/split/android/client/events/metadata/TypedTaskConverter.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/TypedTaskConversionTest.java create mode 100644 events-domain/src/test/java/io/split/android/client/events/metadata/MetadataKeysTest.java delete mode 100644 main/src/test/java/io/split/android/client/events/SplitEventTaskMetadataTest.java diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index f6e2d570b..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: - '*' @@ -59,8 +64,13 @@ jobs: 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 main events logger; do + 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) @@ -98,7 +108,7 @@ jobs: echo "" echo "Checking JaCoCo execution data for each module:" - for module in main events logger; do + for module in $MODULES; do EXEC_FILE="${module}/build/jacoco/testDebugUnitTest.exec" if [ -f "$EXEC_FILE" ]; then EXEC_SIZE=$(wc -c < "$EXEC_FILE") @@ -127,7 +137,7 @@ jobs: echo "=== Verification Complete ===" - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@v6 + uses: SonarSource/sonarqube-scan-action@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} diff --git a/api/README.md b/api/README.md index 0a58d3927..e455d981e 100644 --- a/api/README.md +++ b/api/README.md @@ -3,4 +3,3 @@ This module contains the public API interfaces and types exposed to consumers of the Split SDK. Classes in this module are part of the public API contract and should maintain backwards compatibility. - diff --git a/api/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java index 63d35f457..ba315e0e6 100644 --- a/api/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.SdkEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; @@ -179,6 +180,33 @@ 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_UPDATE and SDK_READY_FROM_CACHE events. + * Override the methods you need in the listener. + *

+ * Example usage: + *

{@code
+     * client.addEventListener(new SdkEventListener() {
+     *     @Override
+     *     public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
+     *         List flags = metadata.getUpdatedFlags();
+     *         // Handle on background thread
+     *     }
+     *
+     *     @Override
+     *     public void onReadyFromCacheView(SplitClient client, SdkReadyFromCacheMetadata metadata) {
+     *         // Handle on main/UI thread
+     *         Boolean freshInstall = metadata.isFreshInstall();
+     *     }
+     * });
+     * }
+ * + * @param listener the event listener to register + */ + void addEventListener(SdkEventListener listener); + /** * Enqueue a new event to be sent to Split data collection services. *

diff --git a/api/src/main/java/io/split/android/client/events/SdkEventListener.java b/api/src/main/java/io/split/android/client/events/SdkEventListener.java new file mode 100644 index 000000000..d86946a16 --- /dev/null +++ b/api/src/main/java/io/split/android/client/events/SdkEventListener.java @@ -0,0 +1,84 @@ +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: + *

    + *
  • Background thread callbacks (e.g., {@link #onUpdate}) - executed immediately on a background thread
  • + *
  • Main thread callbacks (e.g., {@link #onUpdateView}) - executed on the main/UI thread
  • + *
+ *

+ * Example usage: + *

{@code
+ * client.addEventListener(new SdkEventListener() {
+ *     @Override
+ *     public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) {
+ *         List flags = metadata.getUpdatedFlags();
+ *         // Handle updated flags on background thread
+ *     }
+ *
+ *     @Override
+ *     public void onReadyFromCacheView(SplitClient client, SdkReadyFromCacheMetadata metadata) {
+ *         // Handle cache ready on main/UI thread
+ *         Boolean freshInstall = metadata.isFreshInstall();
+ *     }
+ * });
+ * }
+ */ +public abstract class SdkEventListener { + + /** + * 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, SdkReadyFromCacheMetadata 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, SdkReadyFromCacheMetadata metadata) { + // Default empty implementation + } +} + diff --git a/api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java b/api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java new file mode 100644 index 000000000..3f1a883ed --- /dev/null +++ b/api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java @@ -0,0 +1,49 @@ +package io.split.android.client.events; + +import androidx.annotation.Nullable; + +/** + * Typed metadata for SDK_READY_FROM_CACHE events. + *

+ * Contains information about the cache state when the SDK is ready from cache. + */ +public final class SdkReadyFromCacheMetadata { + + @Nullable + private final Boolean mFreshInstall; + + @Nullable + private final Long mLastUpdateTimestamp; + + /** + * Creates a new SdkReadyFromCacheMetadata instance. + * + * @param freshInstall true if this is a fresh install 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 SdkReadyFromCacheMetadata(@Nullable Boolean freshInstall, @Nullable Long lastUpdateTimestamp) { + mFreshInstall = freshInstall; + mLastUpdateTimestamp = lastUpdateTimestamp; + } + + /** + * Returns whether this is a fresh install with no usable cache. + * + * @return true if fresh install, false otherwise, or null if not available + */ + @Nullable + public Boolean isFreshInstall() { + return mFreshInstall; + } + + /** + * 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..a9e6772b6 --- /dev/null +++ b/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java @@ -0,0 +1,36 @@ +package io.split.android.client.events; + +import androidx.annotation.Nullable; + +import java.util.List; + +/** + * Typed metadata for SDK_UPDATE events. + *

+ * Contains information about flags that were updated in the event. + */ +public final class SdkUpdateMetadata { + + @Nullable + private final List mUpdatedFlags; + + /** + * Creates a new SdkUpdateMetadata instance. + * + * @param updatedFlags the list of flag names that were updated, or null if not available + */ + public SdkUpdateMetadata(@Nullable List updatedFlags) { + mUpdatedFlags = updatedFlags; + } + + /** + * Returns the list of flag names that changed in this update. + * + * @return the list of updated flag names, or null if not available + */ + @Nullable + public List getUpdatedFlags() { + return mUpdatedFlags; + } +} + diff --git a/api/src/main/java/io/split/android/client/events/SplitEventTask.java b/api/src/main/java/io/split/android/client/events/SplitEventTask.java index f880e0fe1..c2b704cf5 100644 --- a/api/src/main/java/io/split/android/client/events/SplitEventTask.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventTask.java @@ -1,16 +1,11 @@ package io.split.android.client.events; -import androidx.annotation.Nullable; - import io.split.android.client.SplitClient; -import io.split.android.client.api.EventMetadata; /** * Base class for handling Split SDK events. *

* Extend this class and override the methods you need to handle specific SDK events. - * You can implement both the metadata-enabled and versions of the methods; - * if both are implemented, both will be called (metadata version first). *

* Threading: *

    @@ -18,26 +13,15 @@ *
  • {@code onPostExecutionView} methods are called on the main/UI thread (queued on main looper)
  • *
*

- * Metadata: - *

    - *
  • Metadata-enabled methods receive {@link EventMetadata} containing event-specific information
  • - *
  • Metadata may be {@code null} for some events
  • - *
  • If you only need metadata, implement the metadata version; if you need backward compatibility, - * implement both versions
  • - *
+ * For events with metadata (like SDK_UPDATE or SDK_READY_FROM_CACHE), use + * {@link SdkEventListener} instead for type-safe metadata access. *

* Example usage: *

{@code
- * client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() {
- *     @Override
- *     public void onPostExecution(SplitClient client, EventMetadata metadata) {
- *         List updatedFlags = (List) metadata.get("updatedFlags");
- *         // Handle update with metadata
- *     }
- *
+ * client.on(SplitEvent.SDK_READY, new SplitEventTask() {
  *     @Override
  *     public void onPostExecution(SplitClient client) {
- *         // Legacy handling (also called if both are implemented)
+ *         // SDK is ready, start using Split
  *     }
  * });
  * }
@@ -46,7 +30,7 @@ public class SplitEventTask { /** * Called when an event occurs, executed on a background thread. *

- * Override this method to handle events on a background thread without metadata. + * 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 @@ -59,7 +43,7 @@ public void onPostExecution(SplitClient client) { /** * Called when an event occurs, executed on the main/UI thread. *

- * Override this method to handle events on the main thread without metadata. + * 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 @@ -71,43 +55,4 @@ public void onPostExecution(SplitClient client) { public void onPostExecutionView(SplitClient client) { throw new SplitEventTaskMethodNotImplementedException(); } - - /** - * Called when an event occurs with metadata, executed on a background thread. - *

- * Override this method to handle events on a background thread with access to event metadata. - * The metadata contains event-specific information such as updated flag names for SDK_UPDATE events. - * This method is executed immediately and is faster than {@link #onPostExecutionView(SplitClient, EventMetadata)}. - *

- * If both this method and {@link #onPostExecution(SplitClient)} are implemented, - * both will be called (this method first). - * - * @param client the Split client instance - * @param metadata the event metadata, may be {@code null} for some events - * @throws SplitEventTaskMethodNotImplementedException if not overridden (default behavior) - */ - public void onPostExecution(SplitClient client, @Nullable EventMetadata metadata) { - throw new SplitEventTaskMethodNotImplementedException(); - } - - /** - * Called when an event occurs with metadata, executed on the main/UI thread. - *

- * Override this method to handle events on the main thread with access to event metadata. - * The metadata contains event-specific information such as updated flag names for SDK_UPDATE events. - * Use this when you need to update UI components based on event metadata. - *

- * Note: This method is queued on the main looper, so execution may be delayed - * compared to {@link #onPostExecution(SplitClient, EventMetadata)}. - *

- * If both this method and {@link #onPostExecutionView(SplitClient)} are implemented, - * both will be called (this method first). - * - * @param client the Split client instance - * @param metadata the event metadata, may be {@code null} for some events - * @throws SplitEventTaskMethodNotImplementedException if not overridden (default behavior) - */ - public void onPostExecutionView(SplitClient client, @Nullable EventMetadata metadata) { - throw new SplitEventTaskMethodNotImplementedException(); - } } diff --git a/api/src/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.java b/api/src/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.java new file mode 100644 index 000000000..64fd003f4 --- /dev/null +++ b/api/src/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.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 SdkReadyFromCacheMetadataTest { + + @Test + public void isFreshInstallReturnsNullWhenConstructedWithNull() { + SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(null, null); + + assertNull(metadata.isFreshInstall()); + } + + @Test + public void isFreshInstallReturnsTrueWhenConstructedWithTrue() { + SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(true, null); + + assertTrue(metadata.isFreshInstall()); + } + + @Test + public void isFreshInstallReturnsFalseWhenConstructedWithFalse() { + SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(false, null); + + assertFalse(metadata.isFreshInstall()); + } + + @Test + public void getLastUpdateTimestampReturnsNullWhenConstructedWithNull() { + SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(null, null); + + assertNull(metadata.getLastUpdateTimestamp()); + } + + @Test + public void getLastUpdateTimestampReturnsValueWhenConstructedWithValue() { + long timestamp = 1704067200000L; + SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(null, timestamp); + + assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); + } + + @Test + public void bothValuesReturnCorrectlyWhenBothAreSet() { + long timestamp = 1704067200000L; + SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(true, timestamp); + + assertTrue(metadata.isFreshInstall()); + assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); + } + + @Test + public void bothValuesReturnCorrectlyWhenFreshInstallIsFalse() { + long timestamp = 1704067200000L; + SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(false, timestamp); + + assertFalse(metadata.isFreshInstall()); + 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..143c2b259 --- /dev/null +++ b/api/src/test/java/io/split/android/client/events/SdkUpdateMetadataTest.java @@ -0,0 +1,46 @@ +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 getUpdatedFlagsReturnsNullWhenConstructedWithNull() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(null); + + assertNull(metadata.getUpdatedFlags()); + } + + @Test + public void getUpdatedFlagsReturnsEmptyListWhenConstructedWithEmptyList() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(Collections.emptyList()); + + assertEquals(Collections.emptyList(), metadata.getUpdatedFlags()); + } + + @Test + public void getUpdatedFlagsReturnsListWhenConstructedWithList() { + List flags = Arrays.asList("flag1", "flag2", "flag3"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(flags); + + assertEquals(flags, metadata.getUpdatedFlags()); + } + + @Test + public void getUpdatedFlagsReturnsSingleItemList() { + List flags = Collections.singletonList("singleFlag"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(flags); + + assertEquals(flags, metadata.getUpdatedFlags()); + assertEquals(1, metadata.getUpdatedFlags().size()); + assertEquals("singleFlag", metadata.getUpdatedFlags().get(0)); + } +} + diff --git a/build.gradle b/build.gradle index 110bd8d03..8ce6071dd 100644 --- a/build.gradle +++ b/build.gradle @@ -8,8 +8,8 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:9.0.0-beta02' - 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" } } @@ -19,7 +19,7 @@ apply plugin: 'com.vanniktech.maven.publish' apply from: "$rootDir/gradle/jacoco-root.gradle" ext { - splitVersion = '5.5.0-rc1' + splitVersion = '5.5.0-rc5' jacocoVersion = '0.8.8' } diff --git a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java index 553b1a094..4fba4b4d4 100644 --- a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java +++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java @@ -10,7 +10,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import io.split.android.client.api.EventMetadata; +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.api.Key; /** @@ -104,13 +104,19 @@ public void registerEventsManager(Key key, ISplitEventsManager splitEventsManage /** * 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) { - mManagers.remove(key); + ISplitEventsManager removed = mManagers.remove(key); + if (removed instanceof SplitEventsManager) { + ((SplitEventsManager) removed).destroy(); + } } } 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 index 32580a40d..d6dd48859 100644 --- 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 @@ -2,7 +2,7 @@ import androidx.annotation.Nullable; -import io.split.android.client.api.EventMetadata; +import io.split.android.client.events.metadata.EventMetadata; public interface ISplitEventsManager { diff --git a/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java index f0b4aff46..a8ad9c0f1 100644 --- a/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/ListenableEventsManager.java @@ -8,5 +8,7 @@ public interface ListenableEventsManager { void register(SplitEvent event, SplitEventTask task); + void registerEventListener(SdkEventListener 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 index bcc13a50d..5930fd21c 100644 --- 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 @@ -5,7 +5,7 @@ import io.harness.events.EventDelivery; import io.harness.events.EventHandler; import io.harness.events.Logging; -import io.split.android.client.api.EventMetadata; +import io.split.android.client.events.metadata.EventMetadata; /** * Event delivery implementation for Split SDK events. diff --git a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index cc417efe2..5cf91b3e0 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -10,7 +10,9 @@ import io.harness.events.EventsManager; import io.harness.events.EventsManagers; -import io.split.android.client.api.EventMetadata; +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; @@ -110,6 +112,27 @@ public void register(SplitEvent event, SplitEventTask task) { ); } + @Override + public void registerEventListener(SdkEventListener listener) { + requireNonNull(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); @@ -145,71 +168,71 @@ public void run() { } private EventHandler createBackgroundHandler(final SplitEventTask task) { - return createEventHandler(task, "background", new TaskMethodCaller() { - @Override - public void callWithMetadata(EventMetadata metadata) { - task.onPostExecution(mResources.getSplitClient(), metadata); - } - - @Override - public void callWithoutMetadata() { - task.onPostExecution(mResources.getSplitClient()); - } - }); + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + executeBackgroundTask(task, client, metadata); + }; } private EventHandler createMainThreadHandler(final SplitEventTask task) { - return createEventHandler(task, "main thread", new TaskMethodCaller() { - @Override - public void callWithMetadata(EventMetadata metadata) { - task.onPostExecutionView(mResources.getSplitClient(), metadata); - } + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + executeMainThreadTask(task, client, metadata); + }; + } - @Override - public void callWithoutMetadata() { - task.onPostExecutionView(mResources.getSplitClient()); - } - }); + // SdkEventListener handlers for SDK_UPDATE + private EventHandler createUpdateBackgroundHandler(final SdkEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + executeMethod(() -> listener.onUpdate(client, typedMetadata)); + }; } - /** - * Helper interface for calling task methods. - */ - private interface TaskMethodCaller { - void callWithMetadata(EventMetadata metadata) throws Exception; - void callWithoutMetadata() throws Exception; + private EventHandler createUpdateMainThreadHandler(final SdkEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + executeMethod(() -> listener.onUpdateView(client, typedMetadata)); + }; } - private EventHandler createEventHandler( - final SplitEventTask task, - final String threadType, - final TaskMethodCaller caller) { - return new EventHandler() { - @Override - public void handle(SplitEvent event, EventMetadata metadata) { - executeTaskMethod(metadata, true, threadType, caller); - executeTaskMethod(metadata, false, threadType, caller); - } + // SdkEventListener handlers for SDK_READY_FROM_CACHE + private EventHandler createReadyFromCacheBackgroundHandler(final SdkEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkReadyFromCacheMetadata typedMetadata = TypedTaskConverter.convertForSdkReadyFromCache(metadata); + executeMethod(() -> listener.onReadyFromCache(client, typedMetadata)); + }; + } - private void executeTaskMethod(EventMetadata metadata, boolean withMetadata, String threadType, TaskMethodCaller caller) { - try { - if (withMetadata) { - caller.callWithMetadata(metadata); - } else { - caller.callWithoutMetadata(); - } - } catch (SplitEventTaskMethodNotImplementedException e) { - // Method not implemented by client, ignore - } catch (Exception e) { - String errorPrefix = withMetadata - ? "Error executing " + threadType + " event task (with metadata): " - : "Error executing " + threadType + " event task: "; - Logger.e(errorPrefix + e.getMessage()); - } - } + private EventHandler createReadyFromCacheMainThreadHandler(final SdkEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkReadyFromCacheMetadata typedMetadata = TypedTaskConverter.convertForSdkReadyFromCache(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 { diff --git a/api/src/main/java/io/split/android/client/api/EventMetadata.java b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadata.java similarity index 67% rename from api/src/main/java/io/split/android/client/api/EventMetadata.java rename to events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadata.java index e1648c388..1d531b131 100644 --- a/api/src/main/java/io/split/android/client/api/EventMetadata.java +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/EventMetadata.java @@ -1,31 +1,35 @@ -package io.split.android.client.api; +package io.split.android.client.events.metadata; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collection; -import java.util.Map; -import java.util.Set; /** * 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 SdkReadyFromCacheMetadata}. + *

* Values are sanitized to only allow String, Number, Boolean, or List<String>. */ public interface EventMetadata { /** - * Returns the set of keys in this metadata. - * - * @return set of keys + * Returns the number of entries in this metadata. */ - @NonNull - Set keys(); + int size(); + + /** + * Returns whether this metadata has no entries. + */ + default boolean isEmpty() { + return size() == 0; + } /** * Returns the collection of values in this metadata. - * - * @return collection of values */ @NonNull Collection values(); @@ -46,13 +50,4 @@ public interface EventMetadata { * @return true if the key exists, false otherwise */ boolean containsKey(@NonNull String key); - - /** - * Returns a copy of the underlying data as a Map. - * - * @return a copy of the metadata map - */ - @NonNull - Map toMap(); } - 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 index 76cb30289..86c3b142b 100644 --- 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 @@ -7,8 +7,6 @@ import java.util.List; import java.util.Map; -import io.split.android.client.api.EventMetadata; - /** * Builder for creating {@link EventMetadata} instances. *

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 index d7fc334a7..67dda836e 100644 --- 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 @@ -6,8 +6,6 @@ import java.util.HashSet; import java.util.List; -import io.split.android.client.api.EventMetadata; - /** * Helper class for creating {@link EventMetadata} instances. *

@@ -15,17 +13,13 @@ */ public class EventMetadataHelpers { - private static final String KEY_UPDATED_FLAGS = "updatedFlags"; - private static final String KEY_LAST_UPDATE_TIMESTAMP = "lastUpdateTimestamp"; - private static final String KEY_FRESH_INSTALL = "freshInstall"; - private EventMetadataHelpers() { // Utility class } public static EventMetadata createUpdatedFlagsMetadata(List updatedSplitNames) { return new EventMetadataBuilder() - .put(KEY_UPDATED_FLAGS, new ArrayList<>(new HashSet<>(updatedSplitNames))) + .put(MetadataKeys.UPDATED_FLAGS, new ArrayList<>(new HashSet<>(updatedSplitNames))) .build(); } @@ -38,10 +32,10 @@ public static EventMetadata createUpdatedFlagsMetadata(List updatedSplit */ public static EventMetadata createCacheReadyMetadata(@Nullable Long lastUpdateTimestamp, boolean freshInstall) { EventMetadataBuilder builder = new EventMetadataBuilder() - .put(KEY_FRESH_INSTALL, freshInstall); + .put(MetadataKeys.FRESH_INSTALL, freshInstall); if (lastUpdateTimestamp != null) { - builder.put(KEY_LAST_UPDATE_TIMESTAMP, lastUpdateTimestamp); + builder.put(MetadataKeys.LAST_UPDATE_TIMESTAMP, lastUpdateTimestamp); } return builder.build(); 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 index 8c9b73ffa..97aace947 100644 --- 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 @@ -9,9 +9,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; - -import io.split.android.client.api.EventMetadata; /** * Implementation of {@link EventMetadata}. @@ -34,10 +31,9 @@ class EventMetadataImpl implements EventMetadata { mData = Collections.unmodifiableMap(copy); } - @NonNull @Override - public Set keys() { - return mData.keySet(); + public int size() { + return mData.size(); } @NonNull @@ -56,19 +52,4 @@ public Object get(@NonNull String key) { public boolean containsKey(@NonNull String key) { return mData.containsKey(key); } - - @NonNull - @Override - public Map toMap() { - Map copy = new HashMap<>(); - for (Map.Entry entry : mData.entrySet()) { - Object value = entry.getValue(); - if (value instanceof List) { - copy.put(entry.getKey(), new ArrayList<>((List) value)); - } else { - copy.put(entry.getKey(), value); - } - } - return copy; - } } 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..c76dd578f --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataKeys.java @@ -0,0 +1,34 @@ +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 + + /** + * Names of flags that changed in this update. + */ + static final String UPDATED_FLAGS = "updatedFlags"; + + // SDK_READY_FROM_CACHE event keys + + /** + * True if this is a fresh install with no usable cache. + */ + static final String FRESH_INSTALL = "freshInstall"; + + /** + * 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/TypedTaskConverter.java b/events-domain/src/main/java/io/split/android/client/events/metadata/TypedTaskConverter.java new file mode 100644 index 000000000..a93123ef6 --- /dev/null +++ b/events-domain/src/main/java/io/split/android/client/events/metadata/TypedTaskConverter.java @@ -0,0 +1,52 @@ +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.SdkReadyFromCacheMetadata; +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) { + List updatedFlags = null; + if (metadata != null) { + updatedFlags = (List) metadata.get(MetadataKeys.UPDATED_FLAGS); + } + return new SdkUpdateMetadata(updatedFlags); + } + + /** + * Converts EventMetadata to SdkReadyFromCacheMetadata. + * + * @param metadata the event metadata, may be null + * @return the typed metadata for SDK_READY_FROM_CACHE events + */ + @NonNull + public static SdkReadyFromCacheMetadata convertForSdkReadyFromCache(@Nullable EventMetadata metadata) { + Boolean freshInstall = null; + Long lastUpdateTimestamp = null; + if (metadata != null) { + freshInstall = (Boolean) metadata.get(MetadataKeys.FRESH_INSTALL); + lastUpdateTimestamp = (Long) metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP); + } + return new SdkReadyFromCacheMetadata(freshInstall, lastUpdateTimestamp); + } +} 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..f1f93f347 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/TypedTaskConversionTest.java @@ -0,0 +1,67 @@ +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 convertForSdkUpdateConvertsMetadataCorrectly() { + List expectedFlags = Arrays.asList("flag1", "flag2"); + + EventMetadata eventMetadata = EventMetadataHelpers.createUpdatedFlagsMetadata(expectedFlags); + + // Call conversion method + SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(eventMetadata); + + assertNotNull(converted); + assertEquals(expectedFlags.size(), converted.getUpdatedFlags().size()); + assertTrue(converted.getUpdatedFlags().containsAll(expectedFlags)); + } + + @Test + public void convertForSdkReadyFromCacheConvertsMetadataCorrectly() { + long expectedTimestamp = 1704067200000L; + + EventMetadata eventMetadata = EventMetadataHelpers.createCacheReadyMetadata(expectedTimestamp, true); + + // Call conversion method + SdkReadyFromCacheMetadata converted = TypedTaskConverter.convertForSdkReadyFromCache(eventMetadata); + + assertNotNull(converted); + assertTrue(converted.isFreshInstall()); + assertEquals(Long.valueOf(expectedTimestamp), converted.getLastUpdateTimestamp()); + } + + @Test + public void convertForSdkUpdateHandlesNullMetadata() { + SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(null); + + assertNotNull(converted); + assertNull(converted.getUpdatedFlags()); + } + + @Test + public void convertForSdkReadyFromCacheHandlesNullMetadata() { + SdkReadyFromCacheMetadata converted = TypedTaskConverter.convertForSdkReadyFromCache(null); + + assertNotNull(converted); + assertNull(converted.isFreshInstall()); + assertNull(converted.getLastUpdateTimestamp()); + } +} + 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 index 4652c1ba5..8f561e0c2 100644 --- 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 @@ -15,8 +15,6 @@ import java.util.Arrays; import java.util.List; -import io.split.android.client.api.EventMetadata; - public class EventMetadataBuilderTest { @Mock @@ -94,7 +92,7 @@ public void putIncludesValueWhenValidatorReturnsTrue() { public void buildCreatesEmptyMetadataWhenNothingAdded() { EventMetadata metadata = new EventMetadataBuilder().build(); - assertTrue(metadata.keys().isEmpty()); + assertTrue(metadata.isEmpty()); } @Test @@ -112,7 +110,7 @@ public void putIntegerAddsValue() { .put("count", 42) .build(); - assertEquals(42, metadata.get("count")); + assertEquals(Integer.valueOf(42), metadata.get("count")); } @Test @@ -121,7 +119,7 @@ public void putLongAddsValue() { .put("timestamp", 1234567890L) .build(); - assertEquals(1234567890L, metadata.get("timestamp")); + assertEquals(Long.valueOf(1234567890L), metadata.get("timestamp")); } @Test @@ -130,7 +128,7 @@ public void putDoubleAddsValue() { .put("rate", 3.14) .build(); - assertEquals(3.14, metadata.get("rate")); + assertEquals(Double.valueOf(3.14), metadata.get("rate")); } @Test @@ -139,7 +137,7 @@ public void putBooleanTrueAddsValue() { .put("enabled", true) .build(); - assertEquals(true, metadata.get("enabled")); + assertEquals(Boolean.TRUE, metadata.get("enabled")); } @Test @@ -148,7 +146,7 @@ public void putBooleanFalseAddsValue() { .put("disabled", false) .build(); - assertEquals(false, metadata.get("disabled")); + assertEquals(Boolean.FALSE, metadata.get("disabled")); } @Test @@ -159,7 +157,7 @@ public void putListOfStringsAddsValue() { .put("updatedFlags", flags) .build(); - assertEquals(flags, metadata.get("updatedFlags")); + assertEquals(flags, metadata.get(MetadataKeys.UPDATED_FLAGS)); } @Test @@ -171,10 +169,10 @@ public void chainingMultiplePutsWorks() { .put("list", Arrays.asList("a", "b")) .build(); - assertEquals(4, metadata.keys().size()); + assertEquals(4, metadata.size()); assertEquals("text", metadata.get("string")); - assertEquals(100, metadata.get("number")); - assertEquals(true, metadata.get("flag")); + assertEquals(Integer.valueOf(100), metadata.get("number")); + assertEquals(Boolean.TRUE, metadata.get("flag")); assertEquals(Arrays.asList("a", "b"), metadata.get("list")); } 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 index d21bc8d3f..7fe8d577d 100644 --- 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 @@ -1,7 +1,6 @@ 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; @@ -10,19 +9,17 @@ import java.util.Arrays; import java.util.List; -import io.split.android.client.api.EventMetadata; - public class EventMetadataHelpersTest { // Tests for createUpdatedFlagsMetadata (existing) @Test + @SuppressWarnings("unchecked") public void createUpdatedFlagsMetadataContainsFlags() { List flags = Arrays.asList("flag1", "flag2", "flag3"); EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata(flags); - assertTrue(metadata.containsKey("updatedFlags")); - @SuppressWarnings("unchecked") - List result = (List) metadata.get("updatedFlags"); + assertTrue(metadata.containsKey(MetadataKeys.UPDATED_FLAGS)); + List result = (List) metadata.get(MetadataKeys.UPDATED_FLAGS); assertEquals(3, result.size()); assertTrue(result.contains("flag1")); assertTrue(result.contains("flag2")); @@ -34,33 +31,33 @@ public void createUpdatedFlagsMetadataContainsFlags() { public void createCacheReadyMetadataWithTimestampAndFreshInstallFalse() { EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(1234567890L, false); - assertEquals(1234567890L, metadata.get("lastUpdateTimestamp")); - assertEquals(false, metadata.get("freshInstall")); + assertEquals(Long.valueOf(1234567890L), metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.FRESH_INSTALL)); } @Test public void createCacheReadyMetadataWithNullTimestampAndFreshInstallTrue() { EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); - assertNull(metadata.get("lastUpdateTimestamp")); - assertEquals(true, metadata.get("freshInstall")); + assertNull(metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.FRESH_INSTALL)); } @Test public void createCacheReadyMetadataKeysAreCorrect() { EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(123L, false); - assertTrue(metadata.containsKey("lastUpdateTimestamp")); - assertTrue(metadata.containsKey("freshInstall")); - assertEquals(2, metadata.keys().size()); + assertTrue(metadata.containsKey(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertTrue(metadata.containsKey(MetadataKeys.FRESH_INSTALL)); + assertEquals(2, metadata.size()); } @Test public void createCacheReadyMetadataWithZeroTimestamp() { EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(0L, false); - assertEquals(0L, metadata.get("lastUpdateTimestamp")); - assertEquals(false, metadata.get("freshInstall")); + assertEquals(Long.valueOf(0L), metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.FRESH_INSTALL)); } @Test @@ -69,8 +66,8 @@ public void createCacheReadyMetadataForCachePath() { long storedTimestamp = 1700000000000L; EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(storedTimestamp, false); - assertFalse((Boolean) metadata.get("freshInstall")); - assertEquals(storedTimestamp, metadata.get("lastUpdateTimestamp")); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.FRESH_INSTALL)); + assertEquals(Long.valueOf(storedTimestamp), metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); } @Test @@ -78,8 +75,7 @@ public void createCacheReadyMetadataForSyncPath() { // Sync path: freshInstall=true, timestamp=null EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); - assertTrue((Boolean) metadata.get("freshInstall")); - assertNull(metadata.get("lastUpdateTimestamp")); + assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.FRESH_INSTALL)); + 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 index 54059494e..5f539c6a6 100644 --- 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 @@ -13,31 +13,30 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; + public class EventMetadataImplTest { @Test - public void keysReturnsAllKeys() { + public void sizeAndContainsKeyReflectStoredEntries() { Map data = new HashMap<>(); data.put("key1", "value1"); data.put("key2", 42); data.put("key3", true); EventMetadataImpl metadata = new EventMetadataImpl(data); - Set keys = metadata.keys(); - assertEquals(3, keys.size()); - assertTrue(keys.contains("key1")); - assertTrue(keys.contains("key2")); - assertTrue(keys.contains("key3")); + assertEquals(3, metadata.size()); + assertTrue(metadata.containsKey("key1")); + assertTrue(metadata.containsKey("key2")); + assertTrue(metadata.containsKey("key3")); } @Test - public void keysReturnsEmptySetForEmptyMetadata() { + public void isEmptyReturnsTrueForEmptyMetadata() { EventMetadataImpl metadata = new EventMetadataImpl(new HashMap<>()); - assertTrue(metadata.keys().isEmpty()); + assertTrue(metadata.isEmpty()); } @Test @@ -101,71 +100,6 @@ public void containsKeyReturnsFalseForNonExistingKey() { assertFalse(metadata.containsKey("nonExistingKey")); } - @Test - public void toMapReturnsACopyOfTheData() { - Map data = new HashMap<>(); - data.put("key", "value"); - - EventMetadataImpl metadata = new EventMetadataImpl(data); - Map copy = metadata.toMap(); - - assertEquals(1, copy.size()); - assertEquals("value", copy.get("key")); - - // Verify it's a copy by modifying it - copy.put("newKey", "newValue"); - assertFalse(metadata.containsKey("newKey")); - } - - @Test - public void toMapReturnsEmptyMapForEmptyMetadata() { - EventMetadataImpl metadata = new EventMetadataImpl(new HashMap<>()); - - assertTrue(metadata.toMap().isEmpty()); - } - - @Test - public void toMapReturnsModifiableCopyOfLists() { - Map data = new HashMap<>(); - data.put("flags", Arrays.asList("flag_1", "flag_2")); - - EventMetadataImpl metadata = new EventMetadataImpl(data); - Map copy = metadata.toMap(); - - // Should be able to modify the list in the copy - @SuppressWarnings("unchecked") - List listInCopy = (List) copy.get("flags"); - listInCopy.add("flag_3"); - - // Original metadata should not be affected - @SuppressWarnings("unchecked") - List originalList = (List) metadata.get("flags"); - assertEquals(2, originalList.size()); - assertEquals(Arrays.asList("flag_1", "flag_2"), originalList); - } - - @Test - public void toMapListsAreIndependentAcrossCalls() { - Map data = new HashMap<>(); - data.put("flags", Arrays.asList("flag_1", "flag_2")); - - EventMetadataImpl metadata = new EventMetadataImpl(data); - - Map copy1 = metadata.toMap(); - Map copy2 = metadata.toMap(); - - // Modify copy1's list - @SuppressWarnings("unchecked") - List list1 = (List) copy1.get("flags"); - list1.add("flag_3"); - - // copy2's list should not be affected - @SuppressWarnings("unchecked") - List list2 = (List) copy2.get("flags"); - assertEquals(2, list2.size()); - assertEquals(Arrays.asList("flag_1", "flag_2"), list2); - } - @Test public void metadataIsImmutableAfterConstruction() { Map data = new HashMap<>(); @@ -178,10 +112,11 @@ public void metadataIsImmutableAfterConstruction() { // Metadata should not be affected assertFalse(metadata.containsKey("newKey")); - assertEquals(1, metadata.keys().size()); + assertEquals(1, metadata.size()); } @Test + @SuppressWarnings("unchecked") public void listIsDefensivelyCopiedDuringConstruction() { List originalList = new ArrayList<>(Arrays.asList("flag_1", "flag_2")); Map data = new HashMap<>(); @@ -193,20 +128,19 @@ public void listIsDefensivelyCopiedDuringConstruction() { originalList.add("flag_3"); // Metadata should not be affected - @SuppressWarnings("unchecked") 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); - @SuppressWarnings("unchecked") List list = (List) metadata.get("flags"); // This should throw UnsupportedOperationException 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..530dfd6a2 --- /dev/null +++ b/events-domain/src/test/java/io/split/android/client/events/metadata/MetadataKeysTest.java @@ -0,0 +1,27 @@ +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 updatedFlagsKeyHasCorrectValue() { + assertEquals("updatedFlags", MetadataKeys.UPDATED_FLAGS); + } + + @Test + public void freshInstallKeyHasCorrectValue() { + assertEquals("freshInstall", MetadataKeys.FRESH_INSTALL); + } + + @Test + public void lastUpdateTimestampKeyHasCorrectValue() { + assertEquals("lastUpdateTimestamp", MetadataKeys.LAST_UPDATE_TIMESTAMP); + } +} diff --git a/main/src/androidTest/java/fake/SplitClientStub.java b/main/src/androidTest/java/fake/SplitClientStub.java index 4acebddbc..b9d354bf9 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.SdkEventListener; 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(SdkEventListener listener) { + // Stub implementation - does nothing + } + @Override public boolean track(String eventType) { return false; diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index 9cef41e98..515836a1f 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -35,8 +35,10 @@ import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitFactory; -import io.split.android.client.api.EventMetadata; import io.split.android.client.api.Key; +import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SdkReadyFromCacheMetadata; +import io.split.android.client.events.SdkUpdateMetadata; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.network.HttpMethod; @@ -144,7 +146,7 @@ public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception { // And: a handler H is registered for sdkReadyFromCache AtomicInteger handlerInvocationCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); CountDownLatch cacheReadyLatch = new CountDownLatch(1); SplitClient client = factory.client(new Key("key_1")); @@ -158,14 +160,12 @@ public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception { // And: the metadata contains "freshInstall" with value false assertNotNull("Metadata should not be null", receivedMetadata.get()); - assertTrue("Metadata should contain freshInstall key", receivedMetadata.get().containsKey("freshInstall")); - assertFalse("freshInstall should be false for cache path", - (Boolean) receivedMetadata.get().get("freshInstall")); + Boolean freshInstall = receivedMetadata.get().isFreshInstall(); + assertNotNull("freshInstall should not be null", freshInstall); + assertFalse("freshInstall should be false for cache path", freshInstall); // And: the metadata contains "lastUpdateTimestamp" with a valid timestamp - assertTrue("Metadata should contain lastUpdateTimestamp key", - receivedMetadata.get().containsKey("lastUpdateTimestamp")); - Long lastUpdateTimestamp = (Long) receivedMetadata.get().get("lastUpdateTimestamp"); + Long lastUpdateTimestamp = receivedMetadata.get().getLastUpdateTimestamp(); assertNotNull("lastUpdateTimestamp should not be null", lastUpdateTimestamp); assertTrue("lastUpdateTimestamp should be valid", lastUpdateTimestamp > 0); @@ -192,7 +192,7 @@ public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exc // And: a handler H is registered for sdkReadyFromCache AtomicInteger handlerInvocationCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); CountDownLatch cacheReadyLatch = new CountDownLatch(1); SplitClient client = factory.client(new Key("key_1")); @@ -207,9 +207,9 @@ public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exc // And: the metadata contains "freshInstall" with value true assertNotNull("Metadata should not be null", receivedMetadata.get()); - assertTrue("Metadata should contain freshInstall key", receivedMetadata.get().containsKey("freshInstall")); - assertTrue("freshInstall should be true for sync path (fresh install)", - (Boolean) receivedMetadata.get().get("freshInstall")); + Boolean freshInstall = receivedMetadata.get().isFreshInstall(); + assertNotNull("freshInstall should not be null", freshInstall); + assertTrue("freshInstall should be true for sync path (fresh install)", freshInstall); factory.destroy(); } @@ -250,7 +250,7 @@ public void sdkReadyFiresAfterSdkReadyFromCacheAndRequiresSyncCompletion() throw // Register handlers immediately client.on(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { cacheHandlerCount.incrementAndGet(); cacheReadyLatch.countDown(); } @@ -258,7 +258,7 @@ public void onPostExecution(SplitClient client, EventMetadata metadata) { client.on(SplitEvent.SDK_READY, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { readyHandlerCount.incrementAndGet(); readyLatch.countDown(); } @@ -337,14 +337,14 @@ public void sdkUpdateEmittedOnlyAfterSdkReady() throws Exception { TestClientFixture fixture = createStreamingClient(new Key("key_1")); AtomicInteger updateHandlerCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); CountDownLatch readyLatch = new CountDownLatch(1); CountDownLatch updateLatch = new CountDownLatch(1); // Register handlers BEFORE SDK_READY fires - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { updateHandlerCount.incrementAndGet(); receivedMetadata.set(metadata); updateLatch.countDown(); @@ -353,7 +353,7 @@ public void onPostExecution(SplitClient client, EventMetadata metadata) { fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { readyLatch.countDown(); } }); @@ -397,12 +397,12 @@ public void sdkUpdateFiresOnAnyDataChangeEventAfterSdkReady() throws Exception { TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); AtomicInteger updateHandlerCount = new AtomicInteger(0); - AtomicReference lastMetadata = new AtomicReference<>(); + AtomicReference lastMetadata = new AtomicReference<>(); CountDownLatch updateLatch = new CountDownLatch(1); - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { updateHandlerCount.incrementAndGet(); lastMetadata.set(metadata); updateLatch.countDown(); @@ -445,9 +445,9 @@ public void sdkUpdateDoesNotReplayToLateSubscribers() throws Exception { AtomicReference secondUpdateLatchRef = new AtomicReference<>(null); // And: a handler H1 is registered for sdkUpdate - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler1Count.incrementAndGet(); firstUpdateLatch.countDown(); // Count down second latch if it exists (second update) @@ -474,9 +474,9 @@ public void onPostExecution(SplitClient client, EventMetadata metadata) { CountDownLatch secondUpdateLatch = new CountDownLatch(2); secondUpdateLatchRef.set(secondUpdateLatch); - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler2Count.incrementAndGet(); secondUpdateLatch.countDown(); } @@ -575,7 +575,7 @@ public MockResponse dispatch(RecordedRequest request) { SplitClient client = factory.client(new Key("key_1")); client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { timeoutHandlerCount.incrementAndGet(); timeoutLatch.countDown(); } @@ -583,7 +583,7 @@ public void onPostExecution(SplitClient client, EventMetadata metadata) { client.on(SplitEvent.SDK_READY, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { readyHandlerCount.incrementAndGet(); readyLatch.countDown(); } @@ -639,7 +639,7 @@ public void sdkReadyTimedOutSuppressedWhenSdkReadyFiresBeforeTimeout() throws Ex SplitClient client = factory.client(new Key("key_1")); client.on(SplitEvent.SDK_READY_TIMED_OUT, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { timeoutHandlerCount.incrementAndGet(); } }); @@ -726,18 +726,18 @@ public void handlersInvokedSequentiallyErrorsIsolated() throws Exception { // Given: three handlers H1, H2 and H3 are registered for sdkUpdate in that order // And: H2 throws an exception when invoked - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler1Count.incrementAndGet(); handler1Order.set(orderCounter.incrementAndGet()); updateLatch.countDown(); } }); - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler2Count.incrementAndGet(); handler2Order.set(orderCounter.incrementAndGet()); updateLatch.countDown(); @@ -745,9 +745,9 @@ public void onPostExecution(SplitClient client, EventMetadata metadata) { } }); - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handler3Count.incrementAndGet(); handler3Order.set(orderCounter.incrementAndGet()); updateLatch.countDown(); @@ -798,13 +798,13 @@ public void metadataCorrectlyPropagatedToHandlers() throws Exception { TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); AtomicInteger updateHandlerCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); CountDownLatch updateLatch = new CountDownLatch(1); // Given: a handler H is registered for sdkUpdate which inspects the received metadata - fixture.client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { updateHandlerCount.incrementAndGet(); receivedMetadata.set(metadata); updateLatch.countDown(); @@ -886,17 +886,17 @@ public void sdkScopedEventsFanOutToMultipleClients() throws Exception { CountDownLatch updateLatchB = new CountDownLatch(1); // And: handlers HA and HB are registered for sdkUpdate - fixture.clientA.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.clientA.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handlerACount.incrementAndGet(); updateLatchA.countDown(); } }); - fixture.clientB.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.clientB.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handlerBCount.incrementAndGet(); updateLatchB.countDown(); } @@ -940,17 +940,17 @@ public void clientScopedEventsDoNotFanOutToOtherClients() throws Exception { CountDownLatch updateLatchB = new CountDownLatch(1); // And: handlers HA and HB are registered for sdkUpdate - fixture.clientA.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.clientA.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handlerACount.incrementAndGet(); updateLatchA.countDown(); } }); - fixture.clientB.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + fixture.clientB.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { handlerBCount.incrementAndGet(); updateLatchB.countDown(); } @@ -982,7 +982,7 @@ private TestClientFixture createClientAndWaitForReady(SplitClientConfig config, client.on(SplitEvent.SDK_READY, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { readyLatch.countDown(); } }); @@ -1036,7 +1036,7 @@ private TestClientFixture createStreamingClientAndWaitForReady(Key key) throws I CountDownLatch readyLatch = new CountDownLatch(1); fixture.client.on(SplitEvent.SDK_READY, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { readyLatch.countDown(); } }); @@ -1125,11 +1125,11 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key * Registers a handler for SDK_READY_FROM_CACHE that captures metadata and counts invocations. */ private void registerCacheReadyHandler(SplitClient client, AtomicInteger count, - AtomicReference metadata, + AtomicReference metadata, CountDownLatch latch) { - client.on(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { + client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata eventMetadata) { + public void onReadyFromCache(SplitClient client, SdkReadyFromCacheMetadata eventMetadata) { count.incrementAndGet(); if (metadata != null) metadata.set(eventMetadata); if (latch != null) latch.countDown(); @@ -1141,10 +1141,10 @@ public void onPostExecution(SplitClient client, EventMetadata eventMetadata) { * Registers a handler for SDK_UPDATE that counts invocations and optionally captures metadata. */ private void registerUpdateHandler(SplitClient client, AtomicInteger count, - AtomicReference metadata) { - client.on(SplitEvent.SDK_UPDATE, new SplitEventTask() { + AtomicReference metadata) { + client.addEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata eventMetadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) { count.incrementAndGet(); if (metadata != null) metadata.set(eventMetadata); } @@ -1157,7 +1157,7 @@ public void onPostExecution(SplitClient client, EventMetadata eventMetadata) { private void registerReadyHandler(SplitClient client, AtomicInteger count, CountDownLatch latch) { client.on(SplitEvent.SDK_READY, new SplitEventTask() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onPostExecution(SplitClient client) { if (count != null) count.incrementAndGet(); if (latch != null) latch.countDown(); } diff --git a/main/src/androidTest/java/tests/service/EventsManagerTest.java b/main/src/androidTest/java/tests/service/EventsManagerTest.java index a7080874c..6865e1b05 100644 --- a/main/src/androidTest/java/tests/service/EventsManagerTest.java +++ b/main/src/androidTest/java/tests/service/EventsManagerTest.java @@ -17,7 +17,8 @@ import helper.TestingHelper; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; -import io.split.android.client.api.EventMetadata; +import io.split.android.client.events.SdkEventListener; +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.events.SplitEventsManager; @@ -186,7 +187,7 @@ public void testKilledSplitWithMetadata() throws InterruptedException { CountDownLatch readyLatch = new CountDownLatch(1); CountDownLatch updateLatch = new CountDownLatch(1); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); // Wait for SDK_READY first eventManager.register(SplitEvent.SDK_READY, new SplitEventTask() { @@ -196,10 +197,10 @@ public void onPostExecutionView(SplitClient client) { } }); - // Register for SDK_UPDATE with metadata callback - eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + // Register for SDK_UPDATE with metadata callback using SdkEventListener + eventManager.registerEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { receivedMetadata.set(metadata); updateLatch.countDown(); } @@ -210,15 +211,14 @@ public void onPostExecution(SplitClient client, EventMetadata metadata) { eventManager.notifyInternalEvent(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); Assert.assertTrue("SDK_READY should fire", readyLatch.await(5, TimeUnit.SECONDS)); - EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata( - Collections.singletonList("killed_flag")); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION, metadata); + 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()); - Assert.assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); - List metadataList = (List) receivedMetadata.get().get("updatedFlags"); - Assert.assertTrue("Metadata should contain only killed_flag", metadataList.size() == 1 && metadataList.contains("killed_flag")); + List updatedFlags = receivedMetadata.get().getUpdatedFlags(); + Assert.assertNotNull("Updated flags should not be null", updatedFlags); + Assert.assertTrue("Metadata should contain only killed_flag", updatedFlags.size() == 1 && updatedFlags.contains("killed_flag")); } @Test @@ -288,26 +288,22 @@ public void testTimeoutMySegmentsUpdated() throws InterruptedException { } @Test - public void testAllFourCallbackMethodsAreCalledWithCorrectThreadContext() throws InterruptedException { + 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(4); // Expect 4 calls + CountDownLatch allCalledLatch = new CountDownLatch(2); // Expect 2 calls (background and main thread) - AtomicBoolean backgroundMetadataCalled = new AtomicBoolean(false); - AtomicBoolean backgroundLegacyCalled = new AtomicBoolean(false); - AtomicBoolean mainThreadMetadataCalled = new AtomicBoolean(false); - AtomicBoolean mainThreadLegacyCalled = new AtomicBoolean(false); + AtomicBoolean backgroundCalled = new AtomicBoolean(false); + AtomicBoolean mainThreadCalled = new AtomicBoolean(false); - AtomicBoolean backgroundMetadataOnMainThread = new AtomicBoolean(true); // Should be false - AtomicBoolean backgroundLegacyOnMainThread = new AtomicBoolean(true); // Should be false - AtomicBoolean mainThreadMetadataOnMainThread = new AtomicBoolean(false); // Should be true - AtomicBoolean mainThreadLegacyOnMainThread = new AtomicBoolean(false); // Should be true + AtomicBoolean backgroundOnMainThread = new AtomicBoolean(true); // Should be false + AtomicBoolean mainThreadOnMainThread = new AtomicBoolean(false); // Should be true - AtomicReference backgroundMetadata = new AtomicReference<>(); - AtomicReference mainThreadMetadata = new AtomicReference<>(); + AtomicReference backgroundMetadata = new AtomicReference<>(); + AtomicReference mainThreadMetadata = new AtomicReference<>(); // Wait for SDK_READY first eventManager.register(SplitEvent.SDK_READY, new SplitEventTask() { @@ -317,37 +313,23 @@ public void onPostExecutionView(SplitClient client) { } }); - // Register a task that implements ALL FOUR methods - eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + // Register SdkEventListener to receive typed metadata + eventManager.registerEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { - backgroundMetadataCalled.set(true); - backgroundMetadataOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + backgroundCalled.set(true); + backgroundOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); backgroundMetadata.set(metadata); allCalledLatch.countDown(); } @Override - public void onPostExecution(SplitClient client) { - backgroundLegacyCalled.set(true); - backgroundLegacyOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); - allCalledLatch.countDown(); - } - - @Override - public void onPostExecutionView(SplitClient client, EventMetadata metadata) { - mainThreadMetadataCalled.set(true); - mainThreadMetadataOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { + mainThreadCalled.set(true); + mainThreadOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); mainThreadMetadata.set(metadata); allCalledLatch.countDown(); } - - @Override - public void onPostExecutionView(SplitClient client) { - mainThreadLegacyCalled.set(true); - mainThreadLegacyOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); - allCalledLatch.countDown(); - } }); // Make SDK_READY fire @@ -356,25 +338,25 @@ public void onPostExecutionView(SplitClient client) { Assert.assertTrue("SDK_READY should fire", readyLatch.await(5, TimeUnit.SECONDS)); // Trigger SDK_UPDATE with metadata - EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata( - Arrays.asList("flag1", "flag2")); - eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, metadata); + eventManager.notifyInternalEvent(SplitInternalEvent.SPLITS_UPDATED, + EventMetadataHelpers.createUpdatedFlagsMetadata(Arrays.asList("flag1", "flag2"))); - Assert.assertTrue("All four callbacks should be called", allCalledLatch.await(5, TimeUnit.SECONDS)); + Assert.assertTrue("Both callbacks should be called", allCalledLatch.await(5, TimeUnit.SECONDS)); - Assert.assertTrue("Background metadata method should be called", backgroundMetadataCalled.get()); - Assert.assertTrue("Background legacy method should be called", backgroundLegacyCalled.get()); - Assert.assertTrue("Main thread metadata method should be called", mainThreadMetadataCalled.get()); - Assert.assertTrue("Main thread legacy method should be called", mainThreadLegacyCalled.get()); + Assert.assertTrue("Background method should be called", backgroundCalled.get()); + Assert.assertTrue("Main thread method should be called", mainThreadCalled.get()); - Assert.assertFalse("Background metadata method should NOT run on main thread", backgroundMetadataOnMainThread.get()); - Assert.assertFalse("Background legacy method should NOT run on main thread", backgroundLegacyOnMainThread.get()); - Assert.assertTrue("Main thread metadata method SHOULD run on main thread", mainThreadMetadataOnMainThread.get()); - Assert.assertTrue("Main thread legacy method SHOULD run on main thread", mainThreadLegacyOnMainThread.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()); - Assert.assertTrue("Background metadata should contain updatedFlags", backgroundMetadata.get().containsKey("updatedFlags")); + List bgFlags = backgroundMetadata.get().getUpdatedFlags(); + Assert.assertNotNull("Background updatedFlags should not be null", bgFlags); + Assert.assertTrue("Background metadata should contain flag1", bgFlags.contains("flag1")); + Assert.assertNotNull("Main thread metadata should not be null", mainThreadMetadata.get()); - Assert.assertTrue("Main thread metadata should contain updatedFlags", mainThreadMetadata.get().containsKey("updatedFlags")); + List mtFlags = mainThreadMetadata.get().getUpdatedFlags(); + Assert.assertNotNull("Main thread updatedFlags should not be null", mtFlags); + Assert.assertTrue("Main thread metadata should contain flag1", mtFlags.contains("flag1")); } } 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..30bccc1b3 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.SdkEventListener; 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(SdkEventListener listener) { + // no-op + } + @Override public boolean track(String trafficType, String eventType) { return false; 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 fedd546ec..8257d89ce 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.SdkEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -202,6 +203,15 @@ public void on(SplitEvent event, SplitEventTask task) { mEventsManager.register(event, task); } + @Override + public void addEventListener(SdkEventListener listener) { + if (listener == null) { + Logger.w("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/localhost/LocalhostSplitClient.java b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java index 00c6a4f51..fefd5d373 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.SdkEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -257,18 +258,28 @@ 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(SdkEventListener listener) { + if (listener == null) { + Logger.w("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 ed6402229..de52a5503 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 @@ -20,7 +20,7 @@ import java.util.ArrayList; -import io.split.android.client.api.EventMetadata; +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; 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 53fe35a04..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 @@ -7,7 +7,7 @@ import java.util.List; -import io.split.android.client.api.EventMetadata; +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; 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 7754d1c0d..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 @@ -6,7 +6,7 @@ import java.util.Collections; -import io.split.android.client.api.EventMetadata; +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; 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 2baad9dd9..2f3ad81df 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 @@ -7,7 +7,7 @@ import java.util.List; -import io.split.android.client.api.EventMetadata; +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; 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 c19907ced..4030ea3c1 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 @@ -8,7 +8,7 @@ import java.util.List; -import io.split.android.client.api.EventMetadata; +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.events.metadata.EventMetadataHelpers; 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 49d5e91c3..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 @@ -5,7 +5,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import io.split.android.client.api.EventMetadata; +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; diff --git a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java index 508371213..99b237cc1 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java @@ -14,6 +14,7 @@ import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; +import io.split.android.client.events.SdkEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -126,4 +127,20 @@ public void sdkUpdateRegistersWhenNotAlreadyTriggered() { verify(eventsManager).register(eq(SplitEvent.SDK_UPDATE), eq(task)); } + + @Test + public void addEventListenerWithNullListenerDoesNotRegister() { + splitClient.addEventListener(null); + + verify(eventsManager, never()).registerEventListener(any(SdkEventListener.class)); + } + + @Test + public void addEventListenerWithValidListenerRegistersListener() { + SdkEventListener listener = mock(SdkEventListener.class); + + splitClient.addEventListener(listener); + + verify(eventsManager).registerEventListener(eq(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 eb8768aae..36f4211cf 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 @@ -6,8 +6,12 @@ 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; @@ -17,8 +21,9 @@ import java.util.Arrays; import java.util.List; -import io.split.android.client.api.EventMetadata; +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 { @@ -115,12 +120,9 @@ public void SPLITS_UPDATEDEventWithMetadataIsPassedDownToChildren() { verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(meta -> { if (meta == null) return false; - assertTrue(meta.containsKey("updatedFlags")); - Object flagsValue = meta.get("updatedFlags"); - assertNotNull(flagsValue); - assertTrue(flagsValue instanceof List); - @SuppressWarnings("unchecked") - List flags = (List) flagsValue; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(meta); + List flags = typedMeta.getUpdatedFlags(); + assertNotNull(flags); return flags.size() == 2 && flags.contains("flag1") && flags.contains("flag2"); })); } @@ -136,6 +138,43 @@ public void SPLITS_UPDATEDEventWithNullMetadataIsPassedDownToChildren() { 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() { boolean shouldStop = false; long maxExecutionTime = System.currentTimeMillis() + 1000; 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 5b4643f3a..0004e391a 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 @@ -21,7 +21,7 @@ import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; -import io.split.android.client.api.EventMetadata; +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; @@ -274,17 +274,17 @@ private static void execute(boolean shouldStop, long intervalExecutionTime, long } @Test - public void sdkUpdateWithMetadataCallsMetadataMethod() throws InterruptedException { + 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<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); waitForSdkReady(eventManager, readyLatch); - eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + eventManager.registerEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { receivedMetadata.set(metadata); updateLatch.countDown(); } @@ -296,21 +296,22 @@ public void onPostExecution(SplitClient client, EventMetadata metadata) { boolean updateAwait = updateLatch.await(3, TimeUnit.SECONDS); assertTrue("SDK_UPDATE callback should be called", updateAwait); assertNotNull("Metadata should not be null", receivedMetadata.get()); - assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); + assertNotNull("Metadata should contain updatedFlags", receivedMetadata.get().getUpdatedFlags()); + assertEquals(2, receivedMetadata.get().getUpdatedFlags().size()); } @Test - public void sdkUpdateWithMetadataCallsMetadataMethodOnMainThread() throws InterruptedException { + 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<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); waitForSdkReady(eventManager, readyLatch); - eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + eventManager.registerEventListener(new SdkEventListener() { @Override - public void onPostExecutionView(SplitClient client, EventMetadata metadata) { + public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { receivedMetadata.set(metadata); updateLatch.countDown(); } @@ -322,7 +323,7 @@ public void onPostExecutionView(SplitClient client, EventMetadata 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()); - assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); + assertNotNull("Metadata should contain updatedFlags", receivedMetadata.get().getUpdatedFlags()); } @Test @@ -351,27 +352,27 @@ public void onPostExecution(SplitClient client) { } @Test - public void sdkUpdateCallsBothMethodsWhenBothImplemented() throws InterruptedException { + public void sdkEventListenerCallsBothBackgroundAndMainThreadMethods() throws InterruptedException { SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); CountDownLatch readyLatch = new CountDownLatch(1); CountDownLatch bothCalledLatch = new CountDownLatch(2); - final boolean[] metadataMethodCalled = {false}; - final boolean[] legacyMethodCalled = {false}; - AtomicReference receivedMetadata = new AtomicReference<>(); + final boolean[] backgroundMethodCalled = {false}; + final boolean[] mainThreadMethodCalled = {false}; + AtomicReference receivedMetadata = new AtomicReference<>(); waitForSdkReady(eventManager, readyLatch); - eventManager.register(SplitEvent.SDK_UPDATE, new SplitEventTask() { + eventManager.registerEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { - metadataMethodCalled[0] = true; + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + backgroundMethodCalled[0] = true; receivedMetadata.set(metadata); bothCalledLatch.countDown(); } @Override - public void onPostExecution(SplitClient client) { - legacyMethodCalled[0] = true; + public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { + mainThreadMethodCalled[0] = true; bothCalledLatch.countDown(); } }); @@ -381,32 +382,25 @@ public void onPostExecution(SplitClient client) { boolean bothCalled = bothCalledLatch.await(3, TimeUnit.SECONDS); assertTrue("Both callbacks should be called", bothCalled); - assertTrue("Metadata method should be called", metadataMethodCalled[0]); - assertTrue("Legacy method should also be called", legacyMethodCalled[0]); - assertNotNull("Metadata should be passed to metadata method", receivedMetadata.get()); - assertTrue("Metadata should contain updatedFlags", receivedMetadata.get().containsKey("updatedFlags")); + 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 updatedFlags", receivedMetadata.get().getUpdatedFlags()); } @Test - public void sdkReadyFromCacheCallsBothMethodsWhenBothImplemented() throws InterruptedException { + public void sdkReadyFromCacheTypedTaskReceivesMetadata() throws InterruptedException { SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); - CountDownLatch bothCalledLatch = new CountDownLatch(2); // Expect 2 calls - final boolean[] metadataMethodCalled = {false}; - final boolean[] legacyMethodCalled = {false}; - - // Register a task that implements both versions - eventManager.register(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { - @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { - metadataMethodCalled[0] = true; - bothCalledLatch.countDown(); - } + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedMetadata = new AtomicReference<>(); + // Register an event listener + eventManager.registerEventListener(new SdkEventListener() { @Override - public void onPostExecution(SplitClient client) { - legacyMethodCalled[0] = true; - bothCalledLatch.countDown(); + public void onReadyFromCache(SplitClient client, SdkReadyFromCacheMetadata metadata) { + receivedMetadata.set(metadata); + latch.countDown(); } }); @@ -416,10 +410,9 @@ public void onPostExecution(SplitClient client) { eventManager.notifyInternalEvent(SplitInternalEvent.ATTRIBUTES_LOADED_FROM_STORAGE); eventManager.notifyInternalEvent(SplitInternalEvent.ENCRYPTION_MIGRATION_DONE); - boolean bothCalled = bothCalledLatch.await(3, TimeUnit.SECONDS); - assertTrue("Both callbacks should be called", bothCalled); - assertTrue("Metadata method should be called", metadataMethodCalled[0]); - assertTrue("Legacy method should also be called", legacyMethodCalled[0]); + 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 { diff --git a/main/src/test/java/io/split/android/client/events/SplitEventTaskMetadataTest.java b/main/src/test/java/io/split/android/client/events/SplitEventTaskMetadataTest.java deleted file mode 100644 index 03b125ae2..000000000 --- a/main/src/test/java/io/split/android/client/events/SplitEventTaskMetadataTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package io.split.android.client.events; - -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import io.split.android.client.SplitClient; -import io.split.android.client.api.EventMetadata; -import io.split.android.client.events.metadata.EventMetadataHelpers; - -public class SplitEventTaskMetadataTest { - - @Mock - private SplitClient mClient; - - @Mock - private EventMetadata mMetadata; - - @Before - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - public void onPostExecutionWithMetadataThrowsExceptionWhenNotImplemented() { - SplitEventTask task = new SplitEventTask(); - - assertThrows(SplitEventTaskMethodNotImplementedException.class, () -> { - task.onPostExecution(mClient, mMetadata); - }); - } - - @Test - public void onPostExecutionViewWithMetadataThrowsExceptionWhenNotImplemented() { - SplitEventTask task = new SplitEventTask(); - - assertThrows(SplitEventTaskMethodNotImplementedException.class, () -> { - task.onPostExecutionView(mClient, mMetadata); - }); - } - - @Test - public void onPostExecutionWithMetadataCanBeOverridden() { - EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata( - java.util.Arrays.asList("flag1", "flag2")); - - SplitEventTask task = new SplitEventTask() { - @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { - // Overridden implementation - } - }; - - // Should not throw exception - task.onPostExecution(mClient, metadata); - } - - @Test - public void onPostExecutionViewWithMetadataCanBeOverridden() { - EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(1234567890L, false); - - SplitEventTask task = new SplitEventTask() { - @Override - public void onPostExecutionView(SplitClient client, EventMetadata metadata) { - // Overridden implementation - } - }; - - // Should not throw exception - task.onPostExecutionView(mClient, metadata); - } - - @Test - public void onPostExecutionWithMetadataReceivesCorrectParameters() { - EventMetadata expectedMetadata = EventMetadataHelpers.createUpdatedFlagsMetadata( - java.util.Arrays.asList("flag1", "flag2")); - - final boolean[] metadataReceived = {false}; - final boolean[] hasUpdatedFlags = {false}; - - SplitEventTask task = new SplitEventTask() { - @Override - public void onPostExecution(SplitClient client, EventMetadata metadata) { - metadataReceived[0] = metadata != null; - hasUpdatedFlags[0] = metadata != null && metadata.containsKey("updatedFlags"); - } - }; - - task.onPostExecution(mClient, expectedMetadata); - - assertTrue("Metadata should be received", metadataReceived[0]); - assertTrue("Metadata should contain updatedFlags", hasUpdatedFlags[0]); - } - - @Test - public void onPostExecutionViewWithMetadataReceivesCorrectParameters() { - EventMetadata expectedMetadata = EventMetadataHelpers.createCacheReadyMetadata(1234567890L, false); - - final boolean[] metadataReceived = {false}; - final boolean[] hasTimestamp = {false}; - final boolean[] hasFreshInstall = {false}; - - SplitEventTask task = new SplitEventTask() { - @Override - public void onPostExecutionView(SplitClient client, EventMetadata metadata) { - metadataReceived[0] = metadata != null; - hasTimestamp[0] = metadata != null && metadata.containsKey("lastUpdateTimestamp"); - hasFreshInstall[0] = metadata != null && metadata.containsKey("freshInstall"); - } - }; - - task.onPostExecutionView(mClient, expectedMetadata); - - assertTrue("Metadata should be received", metadataReceived[0]); - assertTrue("Metadata should contain lastUpdateTimestamp", hasTimestamp[0]); - assertTrue("Metadata should contain freshInstall", hasFreshInstall[0]); - } -} - 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..6f2d537ce 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 @@ -34,6 +34,7 @@ import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; import io.split.android.client.attributes.AttributesMerger; +import io.split.android.client.events.SdkEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitEventsManager; @@ -440,6 +441,22 @@ public void onDoesNotRegisterEventTaskWhenEventAlreadyTriggered() { verify(mockEventsManager, never()).register(any(), any()); } + @Test + public void addEventListenerWithNullListenerDoesNotRegister() { + client.addEventListener(null); + + verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class)); + } + + @Test + public void addEventListenerWithValidListenerRegistersListener() { + SdkEventListener listener = mock(SdkEventListener.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 index 0d7fdc85a..f03dae3bf 100644 --- a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java @@ -24,7 +24,9 @@ import java.io.IOException; import java.util.List; -import io.split.android.client.api.EventMetadata; +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; @@ -100,12 +102,9 @@ public void loadLocalNotifiesSplitsUpdatedWithMetadataContainingUpdatedFlags() t EventMetadata metadata = metadataCaptor.getValue(); assertNotNull("Metadata should not be null", metadata); - assertTrue("Metadata should contain 'updatedFlags' key", metadata.containsKey("updatedFlags")); - Object flagsValue = metadata.get("updatedFlags"); - assertNotNull("updatedFlags value should not be null", flagsValue); - assertTrue("updatedFlags should be a List", flagsValue instanceof List); - @SuppressWarnings("unchecked") - List flags = (List) flagsValue; + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + List flags = typedMetadata.getUpdatedFlags(); + assertNotNull("updatedFlags value should not be null", flags); assertTrue("Metadata should contain 'split1' flag", flags.contains("split1")); } } 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 682bbf70b..610714d1e 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 @@ -20,7 +20,9 @@ import java.util.Arrays; import java.util.List; -import io.split.android.client.api.EventMetadata; +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; @@ -151,12 +153,9 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; - assertTrue(metadata.containsKey("updatedFlags")); - Object flagsValue = metadata.get("updatedFlags"); - assertNotNull(flagsValue); - assertTrue(flagsValue instanceof List); - @SuppressWarnings("unchecked") - List flags = (List) flagsValue; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List flags = typedMeta.getUpdatedFlags(); + assertNotNull(flags); assertEquals(2, flags.size()); assertTrue(flags.contains("test_split_1")); assertTrue(flags.contains("test_split_2")); @@ -178,12 +177,9 @@ public void splitsUpdatedIncludesArchivedSplitsInMetadata() { verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; - assertTrue(metadata.containsKey("updatedFlags")); - Object flagsValue = metadata.get("updatedFlags"); - assertNotNull(flagsValue); - assertTrue(flagsValue instanceof List); - @SuppressWarnings("unchecked") - List flags = (List) flagsValue; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List flags = typedMeta.getUpdatedFlags(); + assertNotNull(flags); assertEquals(1, flags.size()); assertTrue(flags.contains("archived_split")); 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 7f1c3de16..aee4e3e4f 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,12 +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.api.EventMetadata; +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; @@ -20,7 +21,6 @@ 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; @@ -79,8 +79,8 @@ public void correctExecution() throws HttpFetcherException { eq(SplitInternalEvent.SPLIT_KILLED_NOTIFICATION), metadataCaptor.capture()); EventMetadata metadata = metadataCaptor.getValue(); Assert.assertNotNull(metadata); - @SuppressWarnings("unchecked") - List updatedFlags = (List) metadata.get("updatedFlags"); + SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); + List updatedFlags = typedMetadata.getUpdatedFlags(); Assert.assertNotNull(updatedFlags); Assert.assertEquals(1, updatedFlags.size()); Assert.assertTrue(updatedFlags.contains("split1")); 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 9d9b76f2c..005d7756e 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 @@ -28,6 +28,8 @@ import java.util.List; import java.util.Map; +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; @@ -257,12 +259,9 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() throws HttpFetcherEx verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; - assertTrue(metadata.containsKey("updatedFlags")); - Object flagsValue = metadata.get("updatedFlags"); - assertNotNull(flagsValue); - assertTrue(flagsValue instanceof List); - @SuppressWarnings("unchecked") - List flags = (List) flagsValue; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List flags = typedMeta.getUpdatedFlags(); + assertNotNull(flags); assertEquals(3, flags.size()); assertTrue(flags.contains("split1")); assertTrue(flags.contains("split2")); @@ -288,12 +287,9 @@ public void splitsUpdatedIncludesEmptyMetadataWhenNoSplitsUpdated() throws HttpF verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; - assertTrue(metadata.containsKey("updatedFlags")); - Object flagsValue = metadata.get("updatedFlags"); - assertNotNull(flagsValue); - assertTrue(flagsValue instanceof List); - @SuppressWarnings("unchecked") - List flags = (List) flagsValue; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List flags = typedMeta.getUpdatedFlags(); + assertNotNull(flags); assertTrue(flags.isEmpty()); return true; })); 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 931643f08..97f976b04 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 @@ -21,6 +21,9 @@ import java.util.Arrays; import java.util.List; +import io.split.android.client.events.SdkReadyFromCacheMetadata; +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; @@ -105,10 +108,10 @@ public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSyncWithSyncMetad // Verify TARGETING_RULES_SYNC_COMPLETE is fired with sync metadata (freshInstall=true, lastUpdateTimestamp=null) verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), argThat(metadata -> { if (metadata == null) return false; - assertTrue(metadata.containsKey("freshInstall")); - assertEquals(true, metadata.get("freshInstall")); - // lastUpdateTimestamp should not be present (null) - return !metadata.containsKey("lastUpdateTimestamp") || metadata.get("lastUpdateTimestamp") == null; + SdkReadyFromCacheMetadata typedMeta = TypedTaskConverter.convertForSdkReadyFromCache(metadata); + assertEquals(Boolean.TRUE, typedMeta.isFreshInstall()); + // lastUpdateTimestamp should not be present (or should be null) + return typedMeta.getLastUpdateTimestamp() == null; })); } @@ -175,12 +178,9 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; - assertTrue(metadata.containsKey("updatedFlags")); - Object flagsValue = metadata.get("updatedFlags"); - assertNotNull(flagsValue); - assertTrue(flagsValue instanceof List); - @SuppressWarnings("unchecked") - List flags = (List) flagsValue; + SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); + List flags = typedMeta.getUpdatedFlags(); + assertNotNull(flags); assertEquals(2, flags.size()); assertTrue(flags.contains("flag1")); assertTrue(flags.contains("flag2")); 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 index adda882a1..252c29696 100644 --- 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 @@ -12,7 +12,7 @@ import org.junit.Test; import org.mockito.Mockito; -import io.split.android.client.api.EventMetadata; +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; 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 7fed3538a..bc276e320 100644 --- a/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java +++ b/main/src/test/java/io/split/android/fake/SplitEventsManagerStub.java @@ -2,9 +2,10 @@ import androidx.annotation.Nullable; -import io.split.android.client.api.EventMetadata; +import io.split.android.client.events.metadata.EventMetadata; import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.ListenableEventsManager; +import io.split.android.client.events.SdkEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; import io.split.android.client.events.SplitInternalEvent; @@ -41,4 +42,9 @@ public boolean eventAlreadyTriggered(SplitEvent event) { } return false; } + + @Override + public void registerEventListener(SdkEventListener listener) { + // Stub implementation - does nothing + } } From 05c64a2c5822a32bb6b4346a7705cea71d15941e Mon Sep 17 00:00:00 2001 From: gthea Date: Wed, 7 Jan 2026 17:04:43 -0300 Subject: [PATCH 17/24] Fix segments clearance (#847) --- .../client/storage/db/MyLargeSegmentDao.java | 4 +++ .../client/storage/db/MySegmentDao.java | 4 +++ .../android/client/storage/db/SegmentDao.java | 2 ++ .../MySegmentsStorageContainerImpl.java | 2 ++ .../PersistentMySegmentsStorage.java | 2 ++ .../SqLitePersistentMySegmentsStorage.java | 5 +++ .../MySegmentsStorageContainerImplTest.java | 31 +++++++++++++++++++ ...ePersistentMyLargeSegmentsStorageTest.java | 7 +++++ ...SqLitePersistentMySegmentsStorageTest.java | 7 +++++ 9 files changed, 64 insertions(+) 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..56a62eaec 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(); + // Set empty 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/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(); + } } From 1bacd60ebdcfcc5ff4a05e7f633da4188cd950d1 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Thu, 8 Jan 2026 14:38:12 -0300 Subject: [PATCH 18/24] Fix publishing --- build.gradle | 53 +++++++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/build.gradle b/build.gradle index 8ce6071dd..bd6d05d7f 100644 --- a/build.gradle +++ b/build.gradle @@ -362,34 +362,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 @@ -411,3 +383,28 @@ afterEvaluate { } } } + +// 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 sourcesArtifacts = pub.artifacts.findAll { it.classifier == "sources" && it.extension == "jar" } + if (sourcesArtifacts.size() > 1) { + // Keep the AGP one (merged_sources_jar), remove the vanniktech empty one (build/libs) + def toRemove = sourcesArtifacts.find { + it.file?.absolutePath?.contains("build/libs") || it.file?.name?.contains("android-client-sources") + } + if (toRemove) { + pub.artifacts.remove(toRemove) + println "Removed duplicate empty sources artifact: ${toRemove.file?.absolutePath}" + } + } + } + } + } +} From 19879daf829f9c7782b09fa9f2794104b5a1041e Mon Sep 17 00:00:00 2001 From: gthea Date: Thu, 8 Jan 2026 19:32:12 -0300 Subject: [PATCH 19/24] Add RBS metadata for update event (#848) --- .../io/split/android/client/SplitClient.java | 3 +- .../client/events/SdkEventListener.java | 5 +- .../client/events/SdkUpdateMetadata.java | 55 ++++++-- .../client/events/SdkUpdateMetadataTest.java | 72 +++++++--- .../events/metadata/EventMetadataHelpers.java | 24 +++- .../client/events/metadata/MetadataKeys.java | 15 +- .../events/metadata/TypedTaskConverter.java | 20 ++- .../events/TypedTaskConversionTest.java | 26 +++- .../metadata/EventMetadataBuilderTest.java | 4 +- .../metadata/EventMetadataHelpersTest.java | 30 +++- .../events/metadata/MetadataKeysTest.java | 9 +- .../events/SdkEventsIntegrationTest.java | 131 ++++++++++++++++++ .../RolloutCacheManagerIntegrationTest.java | 12 +- .../java/tests/service/EventsManagerTest.java | 21 +-- .../split/android/client/SplitClientImpl.java | 2 +- .../localhost/LocalhostSplitClient.java | 2 +- .../RuleBasedSegmentInPlaceUpdateTask.java | 13 +- .../RuleBasedSegmentInPlaceUpdateTask.java | 12 +- .../service/splits/SplitsSyncHelper.java | 49 +++++++ .../client/service/splits/SplitsSyncTask.java | 8 +- .../service/splits/SplitsUpdateTask.java | 8 +- .../MySegmentsStorageContainerImpl.java | 2 +- .../mysegments/MySegmentsStorageImpl.java | 1 - .../events/EventsManagerCoordinatorTest.java | 6 +- .../client/events/EventsManagerTest.java | 8 +- .../localhost/LocalhostSplitsStorageTest.java | 8 +- .../service/SplitInPlaceUpdateTaskTest.java | 20 +-- .../client/service/SplitKillTaskTest.java | 9 +- .../client/service/SplitSyncTaskTest.java | 22 +-- .../client/service/SplitUpdateTaskTest.java | 13 +- .../client/service/SplitsSyncHelperTest.java | 65 +++++++++ ...RuleBasedSegmentInPlaceUpdateTaskTest.java | 39 +++++- 32 files changed, 598 insertions(+), 116 deletions(-) diff --git a/api/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java index ba315e0e6..981294b60 100644 --- a/api/src/main/java/io/split/android/client/SplitClient.java +++ b/api/src/main/java/io/split/android/client/SplitClient.java @@ -191,7 +191,8 @@ public interface SplitClient extends AttributesManager { * client.addEventListener(new SdkEventListener() { * @Override * public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - * List flags = metadata.getUpdatedFlags(); + * SdkUpdateMetadata.Type type = metadata.getType(); // FLAGS_UPDATE or SEGMENTS_UPDATE + * List names = metadata.getNames(); // updated flag/segment names * // Handle on background thread * } * diff --git a/api/src/main/java/io/split/android/client/events/SdkEventListener.java b/api/src/main/java/io/split/android/client/events/SdkEventListener.java index d86946a16..eddccc204 100644 --- a/api/src/main/java/io/split/android/client/events/SdkEventListener.java +++ b/api/src/main/java/io/split/android/client/events/SdkEventListener.java @@ -17,8 +17,9 @@ * client.addEventListener(new SdkEventListener() { * @Override * public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { - * List flags = metadata.getUpdatedFlags(); - * // Handle updated flags on background thread + * SdkUpdateMetadata.Type type = metadata.getType(); // FLAGS_UPDATE or SEGMENTS_UPDATE + * List names = metadata.getNames(); // updated flag/segment names + * // Handle updates on background thread * } * * @Override 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 index a9e6772b6..58d6741a5 100644 --- a/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java +++ b/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java @@ -1,36 +1,71 @@ 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 flags that were updated in the event. + * 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. + */ + FLAGS_UPDATE, + + /** + * Rule-based segments were updated. + *

+ */ + SEGMENTS_UPDATE + } + @Nullable - private final List mUpdatedFlags; + private final Type mType; + + @NonNull + private final List mNames; /** * Creates a new SdkUpdateMetadata instance. * - * @param updatedFlags the list of flag names that were updated, or null if not available + * @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 List updatedFlags) { - mUpdatedFlags = updatedFlags; + public SdkUpdateMetadata(@Nullable Type type, @Nullable List names) { + mType = type; + mNames = names != null ? names : Collections.emptyList(); } /** - * Returns the list of flag names that changed in this update. + * Returns the type of update that triggered this event. * - * @return the list of updated flag names, or null if not available + * @return the update type, or null if not available */ @Nullable - public List getUpdatedFlags() { - return mUpdatedFlags; + 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. + * For {@link Type#SEGMENTS_UPDATE}, this contains rule-based segment names. + * + * @return the list of updated entity names, never null (empty list if none) + */ + @NonNull + public List getNames() { + return mNames; + } +} 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 index 143c2b259..fadd2fe09 100644 --- a/api/src/test/java/io/split/android/client/events/SdkUpdateMetadataTest.java +++ b/api/src/test/java/io/split/android/client/events/SdkUpdateMetadataTest.java @@ -12,35 +12,73 @@ public class SdkUpdateMetadataTest { @Test - public void getUpdatedFlagsReturnsNullWhenConstructedWithNull() { - SdkUpdateMetadata metadata = new SdkUpdateMetadata(null); + public void getNamesReturnsEmptyListWhenConstructedWithNull() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(null, null); - assertNull(metadata.getUpdatedFlags()); + assertEquals(Collections.emptyList(), metadata.getNames()); } @Test - public void getUpdatedFlagsReturnsEmptyListWhenConstructedWithEmptyList() { - SdkUpdateMetadata metadata = new SdkUpdateMetadata(Collections.emptyList()); + public void getNamesReturnsEmptyListWhenConstructedWithEmptyList() { + SdkUpdateMetadata metadata = new SdkUpdateMetadata(null, Collections.emptyList()); - assertEquals(Collections.emptyList(), metadata.getUpdatedFlags()); + assertEquals(Collections.emptyList(), metadata.getNames()); } @Test - public void getUpdatedFlagsReturnsListWhenConstructedWithList() { - List flags = Arrays.asList("flag1", "flag2", "flag3"); - SdkUpdateMetadata metadata = new SdkUpdateMetadata(flags); + public void getNamesReturnsListWhenConstructedWithList() { + List names = Arrays.asList("flag1", "flag2", "flag3"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.FLAGS_UPDATE, names); - assertEquals(flags, metadata.getUpdatedFlags()); + assertEquals(names, metadata.getNames()); } @Test - public void getUpdatedFlagsReturnsSingleItemList() { - List flags = Collections.singletonList("singleFlag"); - SdkUpdateMetadata metadata = new SdkUpdateMetadata(flags); + public void getNamesReturnsSingleItemList() { + List names = Collections.singletonList("singleFlag"); + SdkUpdateMetadata metadata = new SdkUpdateMetadata(SdkUpdateMetadata.Type.FLAGS_UPDATE, names); - assertEquals(flags, metadata.getUpdatedFlags()); - assertEquals(1, metadata.getUpdatedFlags().size()); - assertEquals("singleFlag", metadata.getUpdatedFlags().get(0)); + 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/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 index 67dda836e..4116de596 100644 --- 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 @@ -17,9 +17,29 @@ private EventMetadataHelpers() { // Utility class } - public static EventMetadata createUpdatedFlagsMetadata(List updatedSplitNames) { + /** + * 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 rule-based segments are updated. + * + * @param updatedSegmentNames the list of rule-based segment names that were updated + * @return the event metadata with TYPE=SEGMENTS_UPDATE and NAMES containing the segment names + */ + public static EventMetadata createUpdatedSegmentsMetadata(List updatedSegmentNames) { return new EventMetadataBuilder() - .put(MetadataKeys.UPDATED_FLAGS, new ArrayList<>(new HashSet<>(updatedSplitNames))) + .put(MetadataKeys.TYPE, MetadataKeys.TYPE_SEGMENTS_UPDATE) + .put(MetadataKeys.NAMES, new ArrayList<>(new HashSet<>(updatedSegmentNames))) .build(); } 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 index c76dd578f..b4763d1bc 100644 --- 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 @@ -14,9 +14,20 @@ private MetadataKeys() { // SDK_UPDATE event keys /** - * Names of flags that changed in this update. + * The type of update (FLAGS_UPDATE or SEGMENTS_UPDATE). */ - static final String UPDATED_FLAGS = "updatedFlags"; + 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_FROM_CACHE event keys 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 index a93123ef6..1885c1faa 100644 --- 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 @@ -26,11 +26,25 @@ private TypedTaskConverter() { @NonNull @SuppressWarnings("unchecked") public static SdkUpdateMetadata convertForSdkUpdate(@Nullable EventMetadata metadata) { - List updatedFlags = null; + SdkUpdateMetadata.Type type = null; + List names = null; + if (metadata != null) { - updatedFlags = (List) metadata.get(MetadataKeys.UPDATED_FLAGS); + // 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(updatedFlags); + + return new SdkUpdateMetadata(type, names); } /** 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 index f1f93f347..b74fd2dc6 100644 --- 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 @@ -20,7 +20,7 @@ public class TypedTaskConversionTest { @Test - public void convertForSdkUpdateConvertsMetadataCorrectly() { + public void convertForSdkUpdateConvertsFlagsMetadataCorrectly() { List expectedFlags = Arrays.asList("flag1", "flag2"); EventMetadata eventMetadata = EventMetadataHelpers.createUpdatedFlagsMetadata(expectedFlags); @@ -29,8 +29,24 @@ public void convertForSdkUpdateConvertsMetadataCorrectly() { SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(eventMetadata); assertNotNull(converted); - assertEquals(expectedFlags.size(), converted.getUpdatedFlags().size()); - assertTrue(converted.getUpdatedFlags().containsAll(expectedFlags)); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, converted.getType()); + assertEquals(expectedFlags.size(), converted.getNames().size()); + assertTrue(converted.getNames().containsAll(expectedFlags)); + } + + @Test + public void convertForSdkUpdateConvertsSegmentsMetadataCorrectly() { + List expectedSegments = Arrays.asList("segment1", "segment2"); + + EventMetadata eventMetadata = EventMetadataHelpers.createUpdatedSegmentsMetadata(expectedSegments); + + // Call conversion method + SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(eventMetadata); + + assertNotNull(converted); + assertEquals(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, converted.getType()); + assertEquals(expectedSegments.size(), converted.getNames().size()); + assertTrue(converted.getNames().containsAll(expectedSegments)); } @Test @@ -52,7 +68,8 @@ public void convertForSdkUpdateHandlesNullMetadata() { SdkUpdateMetadata converted = TypedTaskConverter.convertForSdkUpdate(null); assertNotNull(converted); - assertNull(converted.getUpdatedFlags()); + assertNull(converted.getType()); + assertTrue(converted.getNames().isEmpty()); } @Test @@ -64,4 +81,3 @@ public void convertForSdkReadyFromCacheHandlesNullMetadata() { assertNull(converted.getLastUpdateTimestamp()); } } - 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 index 8f561e0c2..c9d638dee 100644 --- 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 @@ -154,10 +154,10 @@ public void putListOfStringsAddsValue() { List flags = Arrays.asList("flag_1", "flag_2", "flag_3"); EventMetadata metadata = new EventMetadataBuilder() - .put("updatedFlags", flags) + .put("names", flags) .build(); - assertEquals(flags, metadata.get(MetadataKeys.UPDATED_FLAGS)); + assertEquals(flags, metadata.get(MetadataKeys.NAMES)); } @Test 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 index 7fe8d577d..9a3dcda84 100644 --- 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 @@ -11,21 +11,43 @@ public class EventMetadataHelpersTest { - // Tests for createUpdatedFlagsMetadata (existing) + // Tests for createUpdatedFlagsMetadata @Test @SuppressWarnings("unchecked") - public void createUpdatedFlagsMetadataContainsFlags() { + public void createUpdatedFlagsMetadataContainsTypeAndNames() { List flags = Arrays.asList("flag1", "flag2", "flag3"); EventMetadata metadata = EventMetadataHelpers.createUpdatedFlagsMetadata(flags); - assertTrue(metadata.containsKey(MetadataKeys.UPDATED_FLAGS)); - List result = (List) metadata.get(MetadataKeys.UPDATED_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 createUpdatedSegmentsMetadataContainsTypeAndNames() { + List segments = Arrays.asList("segment1", "segment2"); + EventMetadata metadata = EventMetadataHelpers.createUpdatedSegmentsMetadata(segments); + + assertTrue(metadata.containsKey(MetadataKeys.TYPE)); + assertEquals(MetadataKeys.TYPE_SEGMENTS_UPDATE, metadata.get(MetadataKeys.TYPE)); + + // Check names + assertTrue(metadata.containsKey(MetadataKeys.NAMES)); + List result = (List) metadata.get(MetadataKeys.NAMES); + assertEquals(2, result.size()); + assertTrue(result.contains("segment1")); + assertTrue(result.contains("segment2")); + } + // Tests for createCacheReadyMetadata @Test public void createCacheReadyMetadataWithTimestampAndFreshInstallFalse() { 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 index 530dfd6a2..b7968cd36 100644 --- 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 @@ -11,8 +11,13 @@ public class MetadataKeysTest { @Test - public void updatedFlagsKeyHasCorrectValue() { - assertEquals("updatedFlags", MetadataKeys.UPDATED_FLAGS); + public void typeKeyHasCorrectValue() { + assertEquals("type", MetadataKeys.TYPE); + } + + @Test + public void namesKeyHasCorrectValue() { + assertEquals("names", MetadataKeys.NAMES); } @Test diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index 515836a1f..179d1ddd2 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -971,6 +971,90 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { 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")); + + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch updateLatch = new CountDownLatch(1); + + fixture.client.addEventListener(new SdkEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + fixture.pushSplitUpdate(); + + boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire", updateFired); + + 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()); + assertFalse("Names should not be empty", receivedMetadata.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() containing the updated RBS names + *

+ * Note: SEGMENTS_UPDATE is for rule-based segments (RBS) ONLY, not for memberships. + */ + @Test + public void sdkUpdateMetadataContainsTypeForSegmentsUpdate() throws Exception { + TestClientFixture fixture = createStreamingClientWithRbsAndWaitForReady(new Key("key_1")); + + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch updateLatch = new CountDownLatch(1); + + fixture.client.addEventListener(new SdkEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { + receivedMetadata.set(metadata); + updateLatch.countDown(); + } + }); + + fixture.pushRbsUpdate(); + + boolean updateFired = updateLatch.await(10, TimeUnit.SECONDS); + assertTrue("SDK_UPDATE should fire for RBS update", updateFired); + + assertNotNull("Metadata should not be null", receivedMetadata.get()); + assertEquals("Type should be SEGMENTS_UPDATE", + SdkUpdateMetadata.Type.SEGMENTS_UPDATE, receivedMetadata.get().getType()); + + assertNotNull("Names should not be null", receivedMetadata.get().getNames()); + assertFalse("Names should not be empty", receivedMetadata.get().getNames().isEmpty()); + assertTrue("Names should contain rbs_test", + receivedMetadata.get().getNames().contains("rbs_test")); + + fixture.destroy(); + } + /** * Creates a client and waits for SDK_READY to fire. * Returns a TestClientFixture containing the factory, client, and ready latch. @@ -1217,6 +1301,18 @@ void pushSplitKill(String splitName) { } } + void pushRbsUpdate() { + pushRbsUpdate("2000", "1000"); + } + + void pushRbsUpdate(String changeNumber, String previousChangeNumber) { + if (streamingData != null) { + // RBS payload: {"name":"rbs_test","status":"ACTIVE","trafficTypeName":"user","excluded":{"keys":[],"segments":[]},"conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"user"},"matcherType":"ALL_KEYS","negate":false}]}}]} + String RBS_UPDATE_PAYLOAD = "eyJuYW1lIjoicmJzX3Rlc3QiLCJzdGF0dXMiOiJBQ1RJVkUiLCJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiZXhjbHVkZWQiOnsia2V5cyI6W10sInNlZ21lbnRzIjpbXX0sImNvbmRpdGlvbnMiOlt7Im1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX19XX0="; + pushMessage(streamingData, IntegrationHelper.rbsChange(changeNumber, previousChangeNumber, RBS_UPDATE_PAYLOAD)); + } + } + void destroy() { factory.destroy(); } @@ -1295,4 +1391,39 @@ private void populateDatabaseWithCacheData(long timestamp) { 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); + } + + /** + * Populates the database with RBS change number for instant update testing. + */ + private void populateDatabaseWithRbsData() { + // Set RBS change number so streaming notifications trigger in-place updates + mDatabase.generalInfoDao().update(new GeneralInfoEntity("rbsChangeNumber", 1000L)); + } } 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 updatedFlags = receivedMetadata.get().getUpdatedFlags(); - Assert.assertNotNull("Updated flags should not be null", updatedFlags); - Assert.assertTrue("Metadata should contain only killed_flag", updatedFlags.size() == 1 && updatedFlags.contains("killed_flag")); + 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 @@ -350,13 +351,15 @@ public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { Assert.assertTrue("Main thread method SHOULD run on main thread", mainThreadOnMainThread.get()); Assert.assertNotNull("Background metadata should not be null", backgroundMetadata.get()); - List bgFlags = backgroundMetadata.get().getUpdatedFlags(); - Assert.assertNotNull("Background updatedFlags should not be null", bgFlags); - Assert.assertTrue("Background metadata should contain flag1", bgFlags.contains("flag1")); + 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 mtFlags = mainThreadMetadata.get().getUpdatedFlags(); - Assert.assertNotNull("Main thread updatedFlags should not be null", mtFlags); - Assert.assertTrue("Main thread metadata should contain flag1", mtFlags.contains("flag1")); + 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/SplitClientImpl.java b/main/src/main/java/io/split/android/client/SplitClientImpl.java index 8257d89ce..1cbfdb317 100644 --- a/main/src/main/java/io/split/android/client/SplitClientImpl.java +++ b/main/src/main/java/io/split/android/client/SplitClientImpl.java @@ -206,7 +206,7 @@ public void on(SplitEvent event, SplitEventTask task) { @Override public void addEventListener(SdkEventListener listener) { if (listener == null) { - Logger.w("Listener cannot be null"); + Logger.w("SDK Event Listener cannot be null"); return; } mEventsManager.registerEventListener(listener); 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 fefd5d373..5944162a6 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 @@ -274,7 +274,7 @@ public void on(SplitEvent event, SplitEventTask task) { @Override public void addEventListener(SdkEventListener listener) { if (listener == null) { - Logger.w("Event listener cannot be null"); + Logger.w("SDK Event Listener cannot be null"); return; } mEventsManager.registerEventListener(listener); 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..0ca9b88dc 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 @@ -1,12 +1,17 @@ package io.split.android.client.service.rules; +import static io.split.android.client.service.splits.SplitsSyncHelper.extractRbsNames; import static io.split.android.client.utils.Utils.checkNotNull; import androidx.annotation.NonNull; +import java.util.List; + 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.EventMetadata; +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 +46,8 @@ public SplitTaskExecutionInfo execute() { boolean triggerSdkUpdate = mRuleBasedSegmentStorage.update(processedChange.getActive(), processedChange.getArchived(), mChangeNumber, null); if (triggerSdkUpdate) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + EventMetadata metadata = createUpdatedRbsMetadata(processedChange); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, metadata); } Logger.v("Updated rule based segment"); @@ -52,4 +58,9 @@ public SplitTaskExecutionInfo execute() { return SplitTaskExecutionInfo.error(SplitTaskType.RULE_BASED_SEGMENT_SYNC); } } + + private EventMetadata createUpdatedRbsMetadata(ProcessedRuleBasedSegmentChange processedChange) { + List updatedRbsNames = extractRbsNames(processedChange); + return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); + } } 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..20b261651 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 @@ -4,9 +4,13 @@ import androidx.annotation.NonNull; +import java.util.List; + 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.EventMetadata; +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 +47,8 @@ public SplitTaskExecutionInfo execute() { boolean triggerSdkUpdate = mRuleBasedSegmentStorage.update(processedChange.getActive(), processedChange.getArchived(), mChangeNumber, null); if (triggerSdkUpdate) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + EventMetadata metadata = createUpdatedRbsMetadata(processedChange); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, metadata); } Logger.v("Updated rule based segment"); @@ -54,4 +59,9 @@ public SplitTaskExecutionInfo execute() { return SplitTaskExecutionInfo.error(SplitTaskType.RULE_BASED_SEGMENT_SYNC); } } + + private EventMetadata createUpdatedRbsMetadata(ProcessedRuleBasedSegmentChange processedChange) { + List updatedRbsNames = SplitsSyncHelper.extractRbsNames(processedChange); + return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); + } } 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 bf203a469..42075e5b2 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 @@ -17,6 +17,7 @@ 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; @@ -59,6 +60,7 @@ public class SplitsSyncHelper { 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; @@ -376,8 +378,55 @@ public static List extractFlagNames(@Nullable ProcessedSplitChange proce return updatedNames; } + /** + * Gets the list of updated rule-based segment names from the last sync operation. + * This includes both active (added/modified) and archived (removed) segments. + * + * @return list of updated RBS names, or empty list if no updates occurred + */ + @NonNull + public List getLastUpdatedRbsNames() { + ProcessedRuleBasedSegmentChange lastChange = mLastProcessedRbsChange.get(); + if (lastChange == null) { + return Collections.emptyList(); + } + return extractRbsNames(lastChange); + } + + /** + * Extracts rule-based segment names from a ProcessedRuleBasedSegmentChange. + * This includes both active (added/modified) and archived (removed) segments. + * + * @param processedChange the processed RBS change + * @return list of RBS names, or empty list if change is null + */ + @NonNull + public static List extractRbsNames(@Nullable ProcessedRuleBasedSegmentChange processedChange) { + if (processedChange == null) { + return Collections.emptyList(); + } + + List updatedNames = new ArrayList<>(); + if (processedChange.getActive() != null) { + for (RuleBasedSegment segment : processedChange.getActive()) { + if (segment != null && segment.getName() != null) { + updatedNames.add(segment.getName()); + } + } + } + if (processedChange.getArchived() != null) { + for (RuleBasedSegment segment : processedChange.getArchived()) { + if (segment != null && segment.getName() != null) { + updatedNames.add(segment.getName()); + } + } + } + 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); } 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 2f3ad81df..67a5615c6 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 @@ -112,7 +112,8 @@ private void notifyInternalEvent(long storedChangeNumber) { } if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + EventMetadata rbsMetadata = createUpdatedRbsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, rbsMetadata); } // Fire sync complete AFTER update events. This ensures SDK_READY triggers after @@ -127,6 +128,11 @@ private EventMetadata createUpdatedFlagsMetadata() { return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); } + private EventMetadata createUpdatedRbsMetadata() { + List updatedRbsNames = mSplitsSyncHelper.getLastUpdatedRbsNames(); + return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); + } + private boolean splitsFilterHasChanged(String storedSplitsFilterQueryString) { return !sanitizeString(mSplitsFilterQueryStringFromConfig).equals(sanitizeString(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 4030ea3c1..ca7d29bf8 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 @@ -80,7 +80,8 @@ public SplitTaskExecutionInfo execute() { } if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + EventMetadata rbsMetadata = createUpdatedRbsMetadata(); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, rbsMetadata); } // Fire sync complete AFTER update events @@ -95,6 +96,11 @@ private EventMetadata createUpdatedFlagsMetadata() { return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); } + private EventMetadata createUpdatedRbsMetadata() { + List updatedRbsNames = mSplitsSyncHelper.getLastUpdatedRbsNames(); + return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); + } + @VisibleForTesting public void setChangeChecker(SplitsChangeChecker changeChecker) { mChangeChecker = changeChecker; 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 56a62eaec..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 @@ -51,7 +51,7 @@ public void loadLocal() { public void clear() { synchronized (lock) { mPersistentMySegmentsStorage.clear(); - // Set empty segments for keys in mStorageMap + // 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/MySegmentsStorageImpl.java b/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageImpl.java index 4478a9a81..fd0e81a84 100644 --- a/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageImpl.java +++ b/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageImpl.java @@ -63,7 +63,6 @@ public long getChangeNumber() { public void clear() { mInMemoryMySegments.clear(); mTill.set(DEFAULT_CHANGE_NUMBER); - mPersistentStorage.set(mMatchingKey, SegmentsChange.createEmpty()); } @NonNull 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 36f4211cf..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 @@ -121,9 +121,9 @@ public void SPLITS_UPDATEDEventWithMetadataIsPassedDownToChildren() { verify(mMockChildEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(meta -> { if (meta == null) return false; SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(meta); - List flags = typedMeta.getUpdatedFlags(); - assertNotNull(flags); - return flags.size() == 2 && flags.contains("flag1") && flags.contains("flag2"); + List names = typedMeta.getNames(); + assertNotNull(names); + return names.size() == 2 && names.contains("flag1") && names.contains("flag2"); })); } 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 0004e391a..5be88e19f 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 @@ -296,8 +296,8 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata 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 updatedFlags", receivedMetadata.get().getUpdatedFlags()); - assertEquals(2, receivedMetadata.get().getUpdatedFlags().size()); + assertNotNull("Metadata should contain names", receivedMetadata.get().getNames()); + assertEquals(2, receivedMetadata.get().getNames().size()); } @Test @@ -323,7 +323,7 @@ public void onUpdateView(SplitClient client, SdkUpdateMetadata 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 updatedFlags", receivedMetadata.get().getUpdatedFlags()); + assertNotNull("Metadata should contain names", receivedMetadata.get().getNames()); } @Test @@ -385,7 +385,7 @@ public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { 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 updatedFlags", receivedMetadata.get().getUpdatedFlags()); + assertNotNull("Metadata should contain names", receivedMetadata.get().getNames()); } @Test 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 index f03dae3bf..aad284982 100644 --- a/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitsStorageTest.java @@ -1,5 +1,6 @@ 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; @@ -103,9 +104,10 @@ public void loadLocalNotifiesSplitsUpdatedWithMetadataContainingUpdatedFlags() t EventMetadata metadata = metadataCaptor.getValue(); assertNotNull("Metadata should not be null", metadata); SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); - List flags = typedMetadata.getUpdatedFlags(); - assertNotNull("updatedFlags value should not be null", flags); - assertTrue("Metadata should contain 'split1' flag", flags.contains("split1")); + 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/service/SplitInPlaceUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitInPlaceUpdateTaskTest.java index 610714d1e..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 @@ -154,11 +154,12 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); - List flags = typedMeta.getUpdatedFlags(); - assertNotNull(flags); - assertEquals(2, flags.size()); - assertTrue(flags.contains("test_split_1")); - assertTrue(flags.contains("test_split_2")); + 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; })); } @@ -178,10 +179,11 @@ public void splitsUpdatedIncludesArchivedSplitsInMetadata() { verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); - List flags = typedMeta.getUpdatedFlags(); - assertNotNull(flags); - assertEquals(1, flags.size()); - assertTrue(flags.contains("archived_split")); + 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 aee4e3e4f..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 @@ -80,10 +80,11 @@ public void correctExecution() throws HttpFetcherException { EventMetadata metadata = metadataCaptor.getValue(); Assert.assertNotNull(metadata); SdkUpdateMetadata typedMetadata = TypedTaskConverter.convertForSdkUpdate(metadata); - List updatedFlags = typedMetadata.getUpdatedFlags(); - Assert.assertNotNull(updatedFlags); - Assert.assertEquals(1, updatedFlags.size()); - Assert.assertTrue(updatedFlags.contains("split1")); + 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 005d7756e..348e8c815 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 @@ -260,12 +260,13 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() throws HttpFetcherEx verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); - List flags = typedMeta.getUpdatedFlags(); - assertNotNull(flags); - assertEquals(3, flags.size()); - assertTrue(flags.contains("split1")); - assertTrue(flags.contains("split2")); - assertTrue(flags.contains("split3")); + 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; })); } @@ -288,9 +289,10 @@ public void splitsUpdatedIncludesEmptyMetadataWhenNoSplitsUpdated() throws HttpF verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); - List flags = typedMeta.getUpdatedFlags(); - assertNotNull(flags); - assertTrue(flags.isEmpty()); + List names = typedMeta.getNames(); + assertNotNull(names); + assertTrue(names.isEmpty()); + assertEquals(SdkUpdateMetadata.Type.FLAGS_UPDATE, typedMeta.getType()); return true; })); } @@ -306,7 +308,7 @@ public void ruleBasedSegmentsUpdatedIsFiredWhenRbsChanged() { mTask.execute(); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } 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 97f976b04..9cdef917e 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 @@ -142,7 +142,7 @@ public void splitsUpdatedIsFiredWhenRbsDataChanged() { mTask.execute(); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED)); + verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED), any()); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE), any()); } @@ -179,11 +179,12 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.SPLITS_UPDATED), argThat(metadata -> { if (metadata == null) return false; SdkUpdateMetadata typedMeta = TypedTaskConverter.convertForSdkUpdate(metadata); - List flags = typedMeta.getUpdatedFlags(); - assertNotNull(flags); - assertEquals(2, flags.size()); - assertTrue(flags.contains("flag1")); - assertTrue(flags.contains("flag2")); + 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; })); } 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 fb195df7b..0f8105c7c 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 @@ -10,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; @@ -33,6 +34,7 @@ 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; @@ -46,6 +48,7 @@ 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; @@ -777,4 +780,66 @@ public void getLastUpdatedFlagNamesIncludesArchivedSplits() throws HttpFetcherEx assertEquals(1, result.size()); assertTrue(result.contains("archived_split")); } + + @Test + public void getLastUpdatedRbsNamesReturnsSegmentNamesAfterSync() throws HttpFetcherException { + RuleBasedSegment activeSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("active_segment"); + RuleBasedSegment archivedSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("archived_segment"); + SplitChange splitChange = SplitChange.create(-1, 100L, Collections.emptyList()); + RuleBasedSegmentChange rbsChange = RuleBasedSegmentChange.create(-1, 100L, Collections.singletonList(activeSegment)); + // Create ProcessedRuleBasedSegmentChange with both active and archived segments + ProcessedRuleBasedSegmentChange processedChange = new ProcessedRuleBasedSegmentChange( + Set.of(activeSegment), Set.of(archivedSegment), 100L, System.currentTimeMillis()); + + doReturn(processedChange).when(mRuleBasedSegmentChangeProcessor).process(any(List.class), anyLong()); + + 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); + + List result = mSplitsSyncHelper.getLastUpdatedRbsNames(); + assertEquals(2, result.size()); + assertTrue(result.contains("active_segment")); + assertTrue(result.contains("archived_segment")); + } + + @Test + public void getLastUpdatedRbsNamesReturnsEmptyListWhenNoSyncPerformed() { + List result = mSplitsSyncHelper.getLastUpdatedRbsNames(); + assertTrue(result.isEmpty()); + } + + @Test + public void extractRbsNamesReturnsActiveAndArchivedSegmentNames() { + RuleBasedSegment activeSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("active_segment"); + RuleBasedSegment archivedSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("archived_segment"); + ProcessedRuleBasedSegmentChange processedChange = new ProcessedRuleBasedSegmentChange( + Set.of(activeSegment), Set.of(archivedSegment), 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractRbsNames(processedChange); + + assertEquals(2, result.size()); + assertTrue(result.contains("active_segment")); + assertTrue(result.contains("archived_segment")); + } + + @Test + public void extractRbsNamesReturnsEmptyListForNullChange() { + List result = SplitsSyncHelper.extractRbsNames(null); + assertTrue(result.isEmpty()); + } + + @Test + public void extractRbsNamesHandlesNullActiveAndArchivedSets() { + ProcessedRuleBasedSegmentChange processedChange = new ProcessedRuleBasedSegmentChange( + null, null, 100L, System.currentTimeMillis()); + + List result = SplitsSyncHelper.extractRbsNames(processedChange); + + assertTrue(result.isEmpty()); + } } 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..713ba5cfa 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,32 @@ public void updateIsCalledOnStorage() { verify(mRuleBasedSegmentStorage).update(Set.of(ruleBasedSegment), Set.of(), changeNumber, null); } + @Test + public void segmentsUpdatedIncludesMetadataWithActiveAndArchivedSegmentNames() { + 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(); + + 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); + assertEquals(2, names.size()); + assertTrue(names.contains("active_segment")); + assertTrue(names.contains("archived_segment")); + assertEquals(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, typedMeta.getType()); + return true; + })); + } + @NonNull private RuleBasedSegmentInPlaceUpdateTask getTask(RuleBasedSegment ruleBasedSegment, long changeNumber) { return new RuleBasedSegmentInPlaceUpdateTask(mRuleBasedSegmentStorage, From a79dd8755478e801b8777f744feba44aea7d6c76 Mon Sep 17 00:00:00 2001 From: gthea Date: Tue, 13 Jan 2026 10:36:24 -0300 Subject: [PATCH 20/24] Add metadata to SDK ready (#849) --- .../io/split/android/client/SplitClient.java | 12 +- .../client/events/SdkEventListener.java | 40 +++- .../events/SdkReadyFromCacheMetadata.java | 49 ----- .../client/events/SdkReadyMetadata.java | 52 +++++ .../events/SdkReadyFromCacheMetadataTest.java | 66 ------- .../client/events/SdkReadyMetadataTest.java | 66 +++++++ .../events/EventsManagerCoordinator.java | 16 ++ .../client/events/ISplitEventsManager.java | 8 + .../client/events/SplitEventsManager.java | 29 ++- .../client/events/metadata/EventMetadata.java | 2 +- .../events/metadata/EventMetadataHelpers.java | 23 ++- .../client/events/metadata/MetadataKeys.java | 6 +- .../events/metadata/TypedTaskConverter.java | 14 +- .../events/TypedTaskConversionTest.java | 14 +- .../metadata/EventMetadataHelpersTest.java | 67 ++++--- .../events/metadata/MetadataKeysTest.java | 4 +- .../events/SdkEventsIntegrationTest.java | 177 ++++++++++++++++-- .../localhost/LocalhostSplitsStorage.java | 4 +- .../client/service/splits/SplitsSyncTask.java | 4 +- .../service/splits/SplitsUpdateTask.java | 7 +- .../FeatureFlagsSynchronizerImpl.java | 5 +- .../client/events/EventsManagerTest.java | 4 +- .../service/MySegmentsSyncTaskTest.java | 2 +- .../client/service/SplitSyncTaskTest.java | 47 +++++ .../client/service/SplitUpdateTaskTest.java | 8 +- .../FeatureFlagsSynchronizerImplTest.java | 53 ++++++ 26 files changed, 579 insertions(+), 200 deletions(-) delete mode 100644 api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java create mode 100644 api/src/main/java/io/split/android/client/events/SdkReadyMetadata.java delete mode 100644 api/src/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.java create mode 100644 api/src/test/java/io/split/android/client/events/SdkReadyMetadataTest.java diff --git a/api/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java index 981294b60..51f02dcaa 100644 --- a/api/src/main/java/io/split/android/client/SplitClient.java +++ b/api/src/main/java/io/split/android/client/SplitClient.java @@ -183,13 +183,19 @@ public interface SplitClient extends AttributesManager { /** * Registers an event listener for SDK events that provide typed metadata. *

- * This method provides type-safe callbacks for SDK_UPDATE and SDK_READY_FROM_CACHE events. + * 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. *

* 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
@@ -197,9 +203,9 @@ public interface SplitClient extends AttributesManager {
      *     }
      *
      *     @Override
-     *     public void onReadyFromCacheView(SplitClient client, SdkReadyFromCacheMetadata metadata) {
+     *     public void onReadyFromCacheView(SplitClient client, SdkReadyMetadata metadata) {
      *         // Handle on main/UI thread
-     *         Boolean freshInstall = metadata.isFreshInstall();
+     *         Boolean initialCacheLoad = metadata.isInitialCacheLoad();
      *     }
      * });
      * }
diff --git a/api/src/main/java/io/split/android/client/events/SdkEventListener.java b/api/src/main/java/io/split/android/client/events/SdkEventListener.java index eddccc204..c6a7a4409 100644 --- a/api/src/main/java/io/split/android/client/events/SdkEventListener.java +++ b/api/src/main/java/io/split/android/client/events/SdkEventListener.java @@ -16,6 +16,12 @@ *
{@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
@@ -23,15 +29,40 @@
  *     }
  *
  *     @Override
- *     public void onReadyFromCacheView(SplitClient client, SdkReadyFromCacheMetadata metadata) {
+ *     public void onReadyFromCacheView(SplitClient client, SdkReadyMetadata metadata) {
  *         // Handle cache ready on main/UI thread
- *         Boolean freshInstall = metadata.isFreshInstall();
+ *         Boolean initialCacheLoad = metadata.isInitialCacheLoad();
  *     }
  * });
  * }
*/ public abstract class SdkEventListener { + /** + * 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. *

@@ -52,7 +83,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { * @param client the Split client instance * @param metadata the typed metadata containing cache information */ - public void onReadyFromCache(SplitClient client, SdkReadyFromCacheMetadata metadata) { + public void onReadyFromCache(SplitClient client, SdkReadyMetadata metadata) { // Default empty implementation } @@ -78,8 +109,7 @@ public void onUpdateView(SplitClient client, SdkUpdateMetadata metadata) { * @param client the Split client instance * @param metadata the typed metadata containing cache information */ - public void onReadyFromCacheView(SplitClient client, SdkReadyFromCacheMetadata metadata) { + public void onReadyFromCacheView(SplitClient client, SdkReadyMetadata metadata) { // Default empty implementation } } - diff --git a/api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java b/api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java deleted file mode 100644 index 3f1a883ed..000000000 --- a/api/src/main/java/io/split/android/client/events/SdkReadyFromCacheMetadata.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.split.android.client.events; - -import androidx.annotation.Nullable; - -/** - * Typed metadata for SDK_READY_FROM_CACHE events. - *

- * Contains information about the cache state when the SDK is ready from cache. - */ -public final class SdkReadyFromCacheMetadata { - - @Nullable - private final Boolean mFreshInstall; - - @Nullable - private final Long mLastUpdateTimestamp; - - /** - * Creates a new SdkReadyFromCacheMetadata instance. - * - * @param freshInstall true if this is a fresh install 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 SdkReadyFromCacheMetadata(@Nullable Boolean freshInstall, @Nullable Long lastUpdateTimestamp) { - mFreshInstall = freshInstall; - mLastUpdateTimestamp = lastUpdateTimestamp; - } - - /** - * Returns whether this is a fresh install with no usable cache. - * - * @return true if fresh install, false otherwise, or null if not available - */ - @Nullable - public Boolean isFreshInstall() { - return mFreshInstall; - } - - /** - * 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/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/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.java b/api/src/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.java deleted file mode 100644 index 64fd003f4..000000000 --- a/api/src/test/java/io/split/android/client/events/SdkReadyFromCacheMetadataTest.java +++ /dev/null @@ -1,66 +0,0 @@ -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 SdkReadyFromCacheMetadataTest { - - @Test - public void isFreshInstallReturnsNullWhenConstructedWithNull() { - SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(null, null); - - assertNull(metadata.isFreshInstall()); - } - - @Test - public void isFreshInstallReturnsTrueWhenConstructedWithTrue() { - SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(true, null); - - assertTrue(metadata.isFreshInstall()); - } - - @Test - public void isFreshInstallReturnsFalseWhenConstructedWithFalse() { - SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(false, null); - - assertFalse(metadata.isFreshInstall()); - } - - @Test - public void getLastUpdateTimestampReturnsNullWhenConstructedWithNull() { - SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(null, null); - - assertNull(metadata.getLastUpdateTimestamp()); - } - - @Test - public void getLastUpdateTimestampReturnsValueWhenConstructedWithValue() { - long timestamp = 1704067200000L; - SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(null, timestamp); - - assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); - } - - @Test - public void bothValuesReturnCorrectlyWhenBothAreSet() { - long timestamp = 1704067200000L; - SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(true, timestamp); - - assertTrue(metadata.isFreshInstall()); - assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); - } - - @Test - public void bothValuesReturnCorrectlyWhenFreshInstallIsFalse() { - long timestamp = 1704067200000L; - SdkReadyFromCacheMetadata metadata = new SdkReadyFromCacheMetadata(false, timestamp); - - assertFalse(metadata.isFreshInstall()); - assertEquals(Long.valueOf(timestamp), metadata.getLastUpdateTimestamp()); - } -} - 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/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java index 4fba4b4d4..7d8061224 100644 --- a/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java +++ b/events-domain/src/main/java/io/split/android/client/events/EventsManagerCoordinator.java @@ -127,4 +127,20 @@ private void propagateTriggeredEvents(ISplitEventsManager splitEventsManager) { } } } + + /** + * 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/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 index d6dd48859..d350b35d3 100644 --- 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 @@ -15,4 +15,12 @@ public interface ISplitEventsManager { * @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/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java index 5cf91b3e0..5977fbbb2 100644 --- a/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java +++ b/events-domain/src/main/java/io/split/android/client/events/SplitEventsManager.java @@ -116,6 +116,14 @@ public void register(SplitEvent event, SplitEventTask task) { public void registerEventListener(SdkEventListener 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, @@ -181,6 +189,23 @@ private EventHandler createMainThreadHandler(final Sp }; } + // SdkEventListener handlers for SDK_READY + private EventHandler createReadyBackgroundHandler(final SdkEventListener listener) { + return (event, metadata) -> { + SplitClient client = mResources.getSplitClient(); + SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata); + executeMethod(() -> listener.onReady(client, typedMetadata)); + }; + } + + private EventHandler createReadyMainThreadHandler(final SdkEventListener 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 SdkEventListener listener) { return (event, metadata) -> { @@ -202,7 +227,7 @@ private EventHandler createUpdateMainThreadHandler(fi private EventHandler createReadyFromCacheBackgroundHandler(final SdkEventListener listener) { return (event, metadata) -> { SplitClient client = mResources.getSplitClient(); - SdkReadyFromCacheMetadata typedMetadata = TypedTaskConverter.convertForSdkReadyFromCache(metadata); + SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata); executeMethod(() -> listener.onReadyFromCache(client, typedMetadata)); }; } @@ -210,7 +235,7 @@ private EventHandler createReadyFromCacheBackgroundHa private EventHandler createReadyFromCacheMainThreadHandler(final SdkEventListener listener) { return (event, metadata) -> { SplitClient client = mResources.getSplitClient(); - SdkReadyFromCacheMetadata typedMetadata = TypedTaskConverter.convertForSdkReadyFromCache(metadata); + SdkReadyMetadata typedMetadata = TypedTaskConverter.convertForSdkReady(metadata); executeMethod(() -> listener.onReadyFromCacheView(client, typedMetadata)); }; } 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 index 1d531b131..b98c96f1e 100644 --- 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 @@ -10,7 +10,7 @@ *

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

* Values are sanitized to only allow String, Number, Boolean, or List<String>. */ 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 index 4116de596..863799cb2 100644 --- 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 @@ -44,15 +44,15 @@ public static EventMetadata createUpdatedSegmentsMetadata(List updatedSe } /** - * Creates metadata for the SDK_READY_FROM_CACHE event. + * 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 freshInstall true if this is a fresh install (no prior cache), false if loaded from cache + * @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 createCacheReadyMetadata(@Nullable Long lastUpdateTimestamp, boolean freshInstall) { + public static EventMetadata createReadyMetadata(@Nullable Long lastUpdateTimestamp, boolean initialCacheLoad) { EventMetadataBuilder builder = new EventMetadataBuilder() - .put(MetadataKeys.FRESH_INSTALL, freshInstall); + .put(MetadataKeys.INITIAL_CACHE_LOAD, initialCacheLoad); if (lastUpdateTimestamp != null) { builder.put(MetadataKeys.LAST_UPDATE_TIMESTAMP, lastUpdateTimestamp); @@ -60,4 +60,19 @@ public static EventMetadata createCacheReadyMetadata(@Nullable Long lastUpdateTi 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/MetadataKeys.java b/events-domain/src/main/java/io/split/android/client/events/metadata/MetadataKeys.java index b4763d1bc..73ff243e7 100644 --- 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 @@ -29,12 +29,12 @@ private MetadataKeys() { */ static final String NAMES = "names"; - // SDK_READY_FROM_CACHE event keys + // SDK_READY and SDK_READY_FROM_CACHE event keys /** - * True if this is a fresh install with no usable cache. + * True if this is an initial cache load with no usable cache. */ - static final String FRESH_INSTALL = "freshInstall"; + static final String INITIAL_CACHE_LOAD = "initialCacheLoad"; /** * Last successful cache timestamp in milliseconds since epoch. 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 index 1885c1faa..9c2e2b526 100644 --- 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 @@ -5,7 +5,7 @@ import java.util.List; -import io.split.android.client.events.SdkReadyFromCacheMetadata; +import io.split.android.client.events.SdkReadyMetadata; import io.split.android.client.events.SdkUpdateMetadata; /** @@ -48,19 +48,19 @@ public static SdkUpdateMetadata convertForSdkUpdate(@Nullable EventMetadata meta } /** - * Converts EventMetadata to SdkReadyFromCacheMetadata. + * Converts EventMetadata to SdkReadyMetadata. * * @param metadata the event metadata, may be null - * @return the typed metadata for SDK_READY_FROM_CACHE events + * @return the typed metadata for SDK_READY and SDK_READY_FROM_CACHE events */ @NonNull - public static SdkReadyFromCacheMetadata convertForSdkReadyFromCache(@Nullable EventMetadata metadata) { - Boolean freshInstall = null; + public static SdkReadyMetadata convertForSdkReady(@Nullable EventMetadata metadata) { + Boolean initialCacheLoad = null; Long lastUpdateTimestamp = null; if (metadata != null) { - freshInstall = (Boolean) metadata.get(MetadataKeys.FRESH_INSTALL); + initialCacheLoad = (Boolean) metadata.get(MetadataKeys.INITIAL_CACHE_LOAD); lastUpdateTimestamp = (Long) metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP); } - return new SdkReadyFromCacheMetadata(freshInstall, lastUpdateTimestamp); + return new SdkReadyMetadata(initialCacheLoad, lastUpdateTimestamp); } } 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 index b74fd2dc6..fbca03876 100644 --- 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 @@ -50,16 +50,16 @@ public void convertForSdkUpdateConvertsSegmentsMetadataCorrectly() { } @Test - public void convertForSdkReadyFromCacheConvertsMetadataCorrectly() { + public void convertForSdkReadyConvertsMetadataCorrectly() { long expectedTimestamp = 1704067200000L; - EventMetadata eventMetadata = EventMetadataHelpers.createCacheReadyMetadata(expectedTimestamp, true); + EventMetadata eventMetadata = EventMetadataHelpers.createReadyMetadata(expectedTimestamp, true); // Call conversion method - SdkReadyFromCacheMetadata converted = TypedTaskConverter.convertForSdkReadyFromCache(eventMetadata); + SdkReadyMetadata converted = TypedTaskConverter.convertForSdkReady(eventMetadata); assertNotNull(converted); - assertTrue(converted.isFreshInstall()); + assertTrue(converted.isInitialCacheLoad()); assertEquals(Long.valueOf(expectedTimestamp), converted.getLastUpdateTimestamp()); } @@ -73,11 +73,11 @@ public void convertForSdkUpdateHandlesNullMetadata() { } @Test - public void convertForSdkReadyFromCacheHandlesNullMetadata() { - SdkReadyFromCacheMetadata converted = TypedTaskConverter.convertForSdkReadyFromCache(null); + public void convertForSdkReadyHandlesNullMetadata() { + SdkReadyMetadata converted = TypedTaskConverter.convertForSdkReady(null); assertNotNull(converted); - assertNull(converted.isFreshInstall()); + assertNull(converted.isInitialCacheLoad()); assertNull(converted.getLastUpdateTimestamp()); } } 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 index 9a3dcda84..0343c418f 100644 --- 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 @@ -48,56 +48,81 @@ public void createUpdatedSegmentsMetadataContainsTypeAndNames() { assertTrue(result.contains("segment2")); } - // Tests for createCacheReadyMetadata + // Tests for createReadyMetadata @Test - public void createCacheReadyMetadataWithTimestampAndFreshInstallFalse() { - EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(1234567890L, false); + 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.FRESH_INSTALL)); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); } @Test - public void createCacheReadyMetadataWithNullTimestampAndFreshInstallTrue() { - EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + public void createReadyMetadataWithNullTimestampAndInitialCacheLoadTrue() { + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(null, true); assertNull(metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); - assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.FRESH_INSTALL)); + assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); } @Test - public void createCacheReadyMetadataKeysAreCorrect() { - EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(123L, false); + public void createReadyMetadataKeysAreCorrect() { + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(123L, false); assertTrue(metadata.containsKey(MetadataKeys.LAST_UPDATE_TIMESTAMP)); - assertTrue(metadata.containsKey(MetadataKeys.FRESH_INSTALL)); + assertTrue(metadata.containsKey(MetadataKeys.INITIAL_CACHE_LOAD)); assertEquals(2, metadata.size()); } @Test - public void createCacheReadyMetadataWithZeroTimestamp() { - EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(0L, false); + 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.FRESH_INSTALL)); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); } @Test - public void createCacheReadyMetadataForCachePath() { - // Cache path: freshInstall=false, timestamp from storage + public void createReadyMetadataForCachePath() { + // Cache path: initialCacheLoad=false, timestamp from storage long storedTimestamp = 1700000000000L; - EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(storedTimestamp, false); + EventMetadata metadata = EventMetadataHelpers.createReadyMetadata(storedTimestamp, false); - assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.FRESH_INSTALL)); + assertEquals(Boolean.FALSE, metadata.get(MetadataKeys.INITIAL_CACHE_LOAD)); assertEquals(Long.valueOf(storedTimestamp), metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); } @Test - public void createCacheReadyMetadataForSyncPath() { - // Sync path: freshInstall=true, timestamp=null - EventMetadata metadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + 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); - assertEquals(Boolean.TRUE, metadata.get(MetadataKeys.FRESH_INSTALL)); assertNull(metadata.get(MetadataKeys.LAST_UPDATE_TIMESTAMP)); } } 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 index b7968cd36..bf5d6db35 100644 --- 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 @@ -21,8 +21,8 @@ public void namesKeyHasCorrectValue() { } @Test - public void freshInstallKeyHasCorrectValue() { - assertEquals("freshInstall", MetadataKeys.FRESH_INSTALL); + public void initialCacheLoadKeyHasCorrectValue() { + assertEquals("initialCacheLoad", MetadataKeys.INITIAL_CACHE_LOAD); } @Test diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index 179d1ddd2..aaf25576c 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -7,6 +7,7 @@ import android.content.Context; +import androidx.annotation.NonNull; import androidx.test.platform.app.InstrumentationRegistry; import org.junit.After; @@ -37,7 +38,7 @@ import io.split.android.client.SplitFactory; import io.split.android.client.api.Key; import io.split.android.client.events.SdkEventListener; -import io.split.android.client.events.SdkReadyFromCacheMetadata; +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; @@ -47,6 +48,7 @@ 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; @@ -71,6 +73,7 @@ private ServiceEndpoints endpoints() { 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) @@ -88,7 +91,7 @@ private SplitFactory buildFactory(SplitClientConfig config) { @Before public void setup() { mWebServer = new MockWebServer(); - mCurSplitReqId = 1; + mCurSplitReqId = 1003; final Dispatcher dispatcher = new Dispatcher() { @Override public MockResponse dispatch(RecordedRequest request) { @@ -132,7 +135,7 @@ public void tearDown() throws Exception { * "attributesLoadedFromStorage" and "encryptionMigrationDone" are notified * Then sdkReadyFromCache is emitted exactly once * And handler H is invoked once - * And the metadata contains "freshInstall" with value false + * And the metadata contains "initialCacheLoad" with value false * And the metadata contains "lastUpdateTimestamp" with a valid timestamp */ @Test @@ -146,7 +149,7 @@ public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception { // And: a handler H is registered for sdkReadyFromCache AtomicInteger handlerInvocationCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); CountDownLatch cacheReadyLatch = new CountDownLatch(1); SplitClient client = factory.client(new Key("key_1")); @@ -158,11 +161,11 @@ public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception { assertTrue("SDK_READY_FROM_CACHE should fire", fired); assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get()); - // And: the metadata contains "freshInstall" with value false + // And: the metadata contains "initialCacheLoad" with value false assertNotNull("Metadata should not be null", receivedMetadata.get()); - Boolean freshInstall = receivedMetadata.get().isFreshInstall(); - assertNotNull("freshInstall should not be null", freshInstall); - assertFalse("freshInstall should be false for cache path", freshInstall); + Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad(); + assertNotNull("initialCacheLoad should not be null", initialCacheLoad); + assertFalse("initialCacheLoad should be false for cache path", initialCacheLoad); // And: the metadata contains "lastUpdateTimestamp" with a valid timestamp Long lastUpdateTimestamp = receivedMetadata.get().getLastUpdateTimestamp(); @@ -180,7 +183,7 @@ public void sdkReadyFromCacheFiresWhenCacheLoadingCompletes() throws Exception { * When internal events "targetingRulesSyncComplete" and "membershipsSyncComplete" are notified * Then sdkReadyFromCache is emitted exactly once * And handler H is invoked once - * And the metadata contains "freshInstall" with value true + * And the metadata contains "initialCacheLoad" with value true */ @Test public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exception { @@ -192,7 +195,7 @@ public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exc // And: a handler H is registered for sdkReadyFromCache AtomicInteger handlerInvocationCount = new AtomicInteger(0); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); CountDownLatch cacheReadyLatch = new CountDownLatch(1); SplitClient client = factory.client(new Key("key_1")); @@ -205,11 +208,153 @@ public void sdkReadyFromCacheFiresWhenSyncCompletesFreshInstallPath() throws Exc assertTrue("SDK_READY_FROM_CACHE should fire", fired); assertEquals("Handler should be invoked exactly once", 1, handlerInvocationCount.get()); - // And: the metadata contains "freshInstall" with value true + // And: the metadata contains "initialCacheLoad" with value true assertNotNull("Metadata should not be null", receivedMetadata.get()); - Boolean freshInstall = receivedMetadata.get().isFreshInstall(); - assertNotNull("freshInstall should not be null", freshInstall); - assertTrue("freshInstall should be true for sync path (fresh install)", freshInstall); + Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad(); + assertNotNull("initialCacheLoad should not be null", initialCacheLoad); + assertTrue("initialCacheLoad should be true for sync path (fresh install)", initialCacheLoad); + + 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 + long testTimestamp = System.currentTimeMillis(); + populateDatabaseWithCacheData(testTimestamp); + + SplitClientConfig config = buildConfig(); + SplitFactory factory = buildFactory(config); + + AtomicInteger onReadyCount = new AtomicInteger(0); + AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedClient = new AtomicReference<>(); + CountDownLatch readyLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + + // And: a handler H is registered using addEventListener with onReady + client.addEventListener(new SdkEventListener() { + @Override + public void onReady(SplitClient client, SdkReadyMetadata metadata) { + onReadyCount.incrementAndGet(); + receivedMetadata.set(metadata); + receivedClient.set(client); + readyLatch.countDown(); + } + }); + + // When: SDK_READY fires + boolean fired = readyLatch.await(10, TimeUnit.SECONDS); + + // Then: onReady is invoked exactly once + assertTrue("onReady should fire", fired); + assertEquals("onReady should be invoked exactly once", 1, onReadyCount.get()); + + // And: the handler receives the SplitClient and SdkReadyMetadata + assertNotNull("Received client should not be null", receivedClient.get()); + assertNotNull("Received metadata should not be null", receivedMetadata.get()); + + // And: the metadata contains "initialCacheLoad" with value false + Boolean initialCacheLoad = receivedMetadata.get().isInitialCacheLoad(); + assertNotNull("initialCacheLoad should not be null", initialCacheLoad); + assertFalse("initialCacheLoad should be false for cache path", initialCacheLoad); + + // And: the metadata contains "lastUpdateTimestamp" with a valid timestamp + Long lastUpdateTimestamp = receivedMetadata.get().getLastUpdateTimestamp(); + assertNotNull("lastUpdateTimestamp should not be null", lastUpdateTimestamp); + assertTrue("lastUpdateTimestamp should be valid", lastUpdateTimestamp > 0); + + 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 + AtomicInteger onReadyCount = new AtomicInteger(0); + AtomicReference receivedMetadata = new AtomicReference<>(); + CountDownLatch lateReadyLatch = new CountDownLatch(1); + + fixture.client.addEventListener(new SdkEventListener() { + @Override + public void onReady(SplitClient client, SdkReadyMetadata metadata) { + onReadyCount.incrementAndGet(); + receivedMetadata.set(metadata); + lateReadyLatch.countDown(); + } + }); + + // Then: onReady handler H is invoked exactly once immediately (replay) + boolean replayFired = lateReadyLatch.await(5, TimeUnit.SECONDS); + assertTrue("Late onReady handler should receive replay", replayFired); + assertEquals("Late onReady handler should be invoked exactly once", 1, onReadyCount.get()); + assertNotNull("Metadata should not be null on replay", receivedMetadata.get()); + + // And: onReady is not emitted again (verify no additional invocations) + Thread.sleep(500); + assertEquals("Late handler should not be invoked again", 1, onReadyCount.get()); + + 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 + long testTimestamp = System.currentTimeMillis(); + populateDatabaseWithCacheData(testTimestamp); + + SplitClientConfig config = buildConfig(); + SplitFactory factory = buildFactory(config); + + AtomicInteger onReadyViewCount = new AtomicInteger(0); + CountDownLatch readyViewLatch = new CountDownLatch(1); + + SplitClient client = factory.client(new Key("key_1")); + + // And: a handler H is registered using addEventListener with onReadyView + client.addEventListener(new SdkEventListener() { + @Override + public void onReadyView(SplitClient client, SdkReadyMetadata metadata) { + onReadyViewCount.incrementAndGet(); + readyViewLatch.countDown(); + } + }); + + // When: SDK_READY fires + boolean fired = readyViewLatch.await(10, TimeUnit.SECONDS); + + // Then: onReadyView is invoked + assertTrue("onReadyView should fire", fired); + assertEquals("onReadyView should be invoked exactly once", 1, onReadyViewCount.get()); factory.destroy(); } @@ -1209,11 +1354,11 @@ private TwoClientFixture createTwoStreamingClientsAndWaitForReady(Key keyA, Key * Registers a handler for SDK_READY_FROM_CACHE that captures metadata and counts invocations. */ private void registerCacheReadyHandler(SplitClient client, AtomicInteger count, - AtomicReference metadata, + AtomicReference metadata, CountDownLatch latch) { client.addEventListener(new SdkEventListener() { @Override - public void onReadyFromCache(SplitClient client, SdkReadyFromCacheMetadata eventMetadata) { + public void onReadyFromCache(SplitClient client, SdkReadyMetadata eventMetadata) { count.incrementAndGet(); if (metadata != null) metadata.set(eventMetadata); if (latch != null) latch.countDown(); 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 de52a5503..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 @@ -219,8 +219,8 @@ private void loadSplits() { } } if (!content.equals(mLastContentLoaded)) { - // Cache path metadata: freshInstall=false (loaded from file), timestamp=null for localhost - EventMetadata cacheMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, false); + // 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(); 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 67a5615c6..3a9e426e9 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 @@ -119,7 +119,9 @@ private void notifyInternalEvent(long storedChangeNumber) { // 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). - EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + boolean cacheAlreadyLoaded = mEventsManager.eventAlreadyTriggered(SplitEvent.SDK_READY_FROM_CACHE); + EventMetadata syncMetadata = EventMetadataHelpers.createSyncCompleteMetadata( + cacheAlreadyLoaded, mSplitsStorage.getUpdateTimestamp()); mEventsManager.notifyInternalEvent(SplitInternalEvent.TARGETING_RULES_SYNC_COMPLETE, syncMetadata); } 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 ca7d29bf8..8ba5da985 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 @@ -10,6 +10,7 @@ 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; @@ -84,8 +85,10 @@ public SplitTaskExecutionInfo execute() { mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, rbsMetadata); } - // Fire sync complete AFTER update events - EventMetadata syncMetadata = EventMetadataHelpers.createCacheReadyMetadata(null, true); + // 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; 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 0c0746dad..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 @@ -95,9 +95,10 @@ public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { mSplitsSyncRetryTimer.setTask(mSplitTaskFactory.createSplitsSyncTask(true), mSplitsSyncListener); - // Create metadata provider for cache path (freshInstall=false, lastUpdateTimestamp from storage) + // 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.createCacheReadyMetadata(splitsStorage.getUpdateTimestamp(), false) + ? () -> EventMetadataHelpers.createReadyMetadata(splitsStorage.getUpdateTimestamp(), false) : null; mLoadLocalSplitsListener = new LoadLocalDataListener( 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 5be88e19f..6861d84c4 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 @@ -393,12 +393,12 @@ public void sdkReadyFromCacheTypedTaskReceivesMetadata() throws InterruptedExcep SplitEventsManager eventManager = new SplitEventsManager(new SplitTaskExecutorStub(), 0); CountDownLatch latch = new CountDownLatch(1); - AtomicReference receivedMetadata = new AtomicReference<>(); + AtomicReference receivedMetadata = new AtomicReference<>(); // Register an event listener eventManager.registerEventListener(new SdkEventListener() { @Override - public void onReadyFromCache(SplitClient client, SdkReadyFromCacheMetadata metadata) { + public void onReadyFromCache(SplitClient client, SdkReadyMetadata metadata) { receivedMetadata.set(metadata); latch.countDown(); } 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 4651eaa9a..70faedd68 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 @@ -242,7 +242,7 @@ public void membershipsSyncCompleteIsAlwaysFiredOnSuccessfulSync() throws HttpFe 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(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MY_SEGMENTS_UPDATED)); } 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 348e8c815..3516c38f1 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 @@ -28,7 +28,9 @@ 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; @@ -327,6 +329,51 @@ public void ruleBasedSegmentsUpdatedIsNotFiredWhenRbsUnchanged() { 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/SplitUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java index 9cdef917e..07089657d 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 @@ -21,7 +21,7 @@ import java.util.Arrays; import java.util.List; -import io.split.android.client.events.SdkReadyFromCacheMetadata; +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; @@ -105,11 +105,11 @@ public void targetingRulesSyncCompleteIsAlwaysFiredOnSuccessfulSyncWithSyncMetad mTask.execute(); - // Verify TARGETING_RULES_SYNC_COMPLETE is fired with sync metadata (freshInstall=true, lastUpdateTimestamp=null) + // 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; - SdkReadyFromCacheMetadata typedMeta = TypedTaskConverter.convertForSdkReadyFromCache(metadata); - assertEquals(Boolean.TRUE, typedMeta.isFreshInstall()); + SdkReadyMetadata typedMeta = TypedTaskConverter.convertForSdkReady(metadata); + assertEquals(Boolean.TRUE, typedMeta.isInitialCacheLoad()); // lastUpdateTimestamp should not be present (or should be null) return typedMeta.getLastUpdateTimestamp() == null; })); 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")); + } } From bf6701e2ee9868661539299c79f8d479707f5d96 Mon Sep 17 00:00:00 2001 From: gthea Date: Wed, 14 Jan 2026 08:19:48 -0300 Subject: [PATCH 21/24] Expose flag names in metadata (#850) --- .../client/events/SdkUpdateMetadata.java | 12 +- build.gradle | 2 +- .../events/metadata/EventMetadataHelpers.java | 12 +- .../events/TypedTaskConversionTest.java | 8 +- .../metadata/EventMetadataHelpersTest.java | 11 +- .../events/SdkEventsIntegrationTest.java | 328 +++++++++++++++++- .../android/client/EvaluationResult.java | 1 + .../mysegments/MySegmentsSyncTask.java | 16 +- .../mysegments/MySegmentsUpdateTask.java | 3 +- .../RuleBasedSegmentInPlaceUpdateTask.java | 13 +- .../RuleBasedSegmentInPlaceUpdateTask.java | 12 +- .../service/splits/SplitsSyncHelper.java | 46 --- .../client/service/splits/SplitsSyncTask.java | 16 +- .../service/splits/SplitsUpdateTask.java | 16 +- .../synchronizer/MySegmentsChangeChecker.java | 34 +- .../mysegments/MySegmentsStorageImpl.java | 1 + .../service/MySegmentsChangesCheckerTest.java | 60 ++-- .../service/MySegmentsSyncTaskTest.java | 27 +- .../service/MySegmentsUpdateTaskTest.java | 3 +- .../client/service/SplitSyncTaskTest.java | 22 ++ .../client/service/SplitUpdateTaskTest.java | 22 ++ .../client/service/SplitsSyncHelperTest.java | 62 ---- ...RuleBasedSegmentInPlaceUpdateTaskTest.java | 7 +- 23 files changed, 492 insertions(+), 242 deletions(-) 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 index 58d6741a5..83dea400a 100644 --- a/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java +++ b/api/src/main/java/io/split/android/client/events/SdkUpdateMetadata.java @@ -19,12 +19,16 @@ public final class SdkUpdateMetadata { public enum Type { /** * Feature flags were updated. + *

+ * {@link #getNames()} returns the list of flag names that changed. */ FLAGS_UPDATE, /** - * Rule-based segments were updated. + * 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 } @@ -59,10 +63,10 @@ public Type getType() { /** * Returns the list of entity names that changed in this update. *

- * For {@link Type#FLAGS_UPDATE}, this contains flag names. - * For {@link Type#SEGMENTS_UPDATE}, this contains rule-based segment names. + * 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 if none) + * @return the list of updated entity names, never null (empty list for SEGMENTS_UPDATE or if none) */ @NonNull public List getNames() { diff --git a/build.gradle b/build.gradle index bd6d05d7f..0209da0a1 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ apply plugin: 'com.vanniktech.maven.publish' apply from: "$rootDir/gradle/jacoco-root.gradle" ext { - splitVersion = '5.5.0-rc5' + splitVersion = '5.5.0-rc6' jacocoVersion = '0.8.8' } 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 index 863799cb2..16018110b 100644 --- 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 @@ -3,6 +3,7 @@ import androidx.annotation.Nullable; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -31,15 +32,16 @@ public static EventMetadata createUpdatedFlagsMetadata(List updatedFlagN } /** - * Creates metadata for SDK_UPDATE events when rule-based segments are updated. + * Creates metadata for SDK_UPDATE events when segments are updated. + *

+ * SEGMENTS_UPDATE always has empty names - segment names are not included in the metadata. * - * @param updatedSegmentNames the list of rule-based segment names that were updated - * @return the event metadata with TYPE=SEGMENTS_UPDATE and NAMES containing the segment names + * @return the event metadata with TYPE=SEGMENTS_UPDATE and empty NAMES list */ - public static EventMetadata createUpdatedSegmentsMetadata(List updatedSegmentNames) { + public static EventMetadata createUpdatedSegmentsMetadata() { return new EventMetadataBuilder() .put(MetadataKeys.TYPE, MetadataKeys.TYPE_SEGMENTS_UPDATE) - .put(MetadataKeys.NAMES, new ArrayList<>(new HashSet<>(updatedSegmentNames))) + .put(MetadataKeys.NAMES, Collections.emptyList()) .build(); } 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 index fbca03876..926a755c5 100644 --- 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 @@ -36,17 +36,15 @@ public void convertForSdkUpdateConvertsFlagsMetadataCorrectly() { @Test public void convertForSdkUpdateConvertsSegmentsMetadataCorrectly() { - List expectedSegments = Arrays.asList("segment1", "segment2"); - - EventMetadata eventMetadata = EventMetadataHelpers.createUpdatedSegmentsMetadata(expectedSegments); + // 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()); - assertEquals(expectedSegments.size(), converted.getNames().size()); - assertTrue(converted.getNames().containsAll(expectedSegments)); + assertTrue("Names should be empty for SEGMENTS_UPDATE", converted.getNames().isEmpty()); } @Test 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 index 0343c418f..9dca0abcf 100644 --- 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 @@ -33,19 +33,16 @@ public void createUpdatedFlagsMetadataContainsTypeAndNames() { // Tests for createUpdatedSegmentsMetadata @Test @SuppressWarnings("unchecked") - public void createUpdatedSegmentsMetadataContainsTypeAndNames() { - List segments = Arrays.asList("segment1", "segment2"); - EventMetadata metadata = EventMetadataHelpers.createUpdatedSegmentsMetadata(segments); + public void createUpdatedSegmentsMetadataContainsTypeAndEmptyNames() { + EventMetadata metadata = EventMetadataHelpers.createUpdatedSegmentsMetadata(); assertTrue(metadata.containsKey(MetadataKeys.TYPE)); assertEquals(MetadataKeys.TYPE_SEGMENTS_UPDATE, metadata.get(MetadataKeys.TYPE)); - // Check names + // Check names - should always be empty assertTrue(metadata.containsKey(MetadataKeys.NAMES)); List result = (List) metadata.get(MetadataKeys.NAMES); - assertEquals(2, result.size()); - assertTrue(result.contains("segment1")); - assertTrue(result.contains("segment2")); + assertTrue("Names should be empty for SEGMENTS_UPDATE", result.isEmpty()); } // Tests for createReadyMetadata diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index aaf25576c..ccb1eea8b 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -44,6 +44,7 @@ import io.split.android.client.events.SplitEventTask; import io.split.android.client.network.HttpMethod; import io.split.android.client.storage.db.GeneralInfoEntity; +import io.split.android.client.storage.db.MyLargeSegmentEntity; import io.split.android.client.storage.db.MySegmentEntity; import io.split.android.client.storage.db.SplitEntity; import io.split.android.client.storage.db.SplitRoomDatabase; @@ -391,7 +392,7 @@ public void sdkReadyFiresAfterSdkReadyFromCacheAndRequiresSyncCompletion() throw CountDownLatch readyLatch = new CountDownLatch(1); SplitClient client = factory.client(new Key("key_1")); - + // Register handlers immediately client.on(SplitEvent.SDK_READY_FROM_CACHE, new SplitEventTask() { @Override @@ -422,10 +423,10 @@ public void onPostExecution(SplitClient client) { // Then: sdkReady is emitted exactly once assertTrue("SDK_READY should fire after SDK_READY_FROM_CACHE and sync completion. " + - "Cache fired: " + cacheHandlerCount.get() + ", Ready fired: " + readyHandlerCount.get(), + "Cache fired: " + cacheHandlerCount.get() + ", Ready fired: " + readyHandlerCount.get(), readyFired); assertEquals("Ready handler should be invoked exactly once", 1, readyHandlerCount.get()); - + // Verify both events fired assertEquals("SDK_READY_FROM_CACHE should fire", 1, cacheHandlerCount.get()); assertEquals("SDK_READY should fire after SDK_READY_FROM_CACHE", 1, readyHandlerCount.get()); @@ -618,7 +619,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { // When: a second handler H2 is registered for sdkUpdate after one sdkUpdate has already fired CountDownLatch secondUpdateLatch = new CountDownLatch(2); secondUpdateLatchRef.set(secondUpdateLatch); - + fixture.client.addEventListener(new SdkEventListener() { @Override public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { @@ -633,7 +634,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { // Ensure handlers are registered and first update is fully processed before pushing second update Thread.sleep(500); - + // Send keep-alive to ensure SSE connection is still active if (fixture.streamingData != null) { TestingHelper.pushKeepAlive(fixture.streamingData); @@ -645,8 +646,8 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { // Then: both H1 and H2 are invoked for that second sdkUpdate boolean secondUpdateFired = secondUpdateLatch.await(15, TimeUnit.SECONDS); - assertTrue("Second SDK_UPDATE should fire. H1 count: " + handler1Count.get() + - ", H2 count: " + handler2Count.get() + + assertTrue("Second SDK_UPDATE should fire. H1 count: " + handler1Count.get() + + ", H2 count: " + handler2Count.get() + ", secondUpdateLatch count: " + secondUpdateLatch.getCount(), secondUpdateFired); // H1 should now have 2 total invocations (1 from first + 1 from second) @@ -916,10 +917,10 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { // 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", + 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); @@ -1070,7 +1071,7 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { * 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 + * 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. */ @@ -1164,9 +1165,9 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { * 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() containing the updated RBS names + * And handler H receives metadata with getNames() returning an empty list *

- * Note: SEGMENTS_UPDATE is for rule-based segments (RBS) ONLY, not for memberships. + * Note: SEGMENTS_UPDATE always has empty names (segment names are not included). */ @Test public void sdkUpdateMetadataContainsTypeForSegmentsUpdate() throws Exception { @@ -1193,13 +1194,309 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { SdkUpdateMetadata.Type.SEGMENTS_UPDATE, receivedMetadata.get().getType()); assertNotNull("Names should not be null", receivedMetadata.get().getNames()); - assertFalse("Names should not be empty", receivedMetadata.get().getNames().isEmpty()); - assertTrue("Names should contain rbs_test", - receivedMetadata.get().getNames().contains("rbs_test")); + assertTrue("Names should be empty for SEGMENTS_UPDATE", receivedMetadata.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 { + // Track number of /splitChanges calls + AtomicInteger splitChangesHitCount = new AtomicInteger(0); + + final Dispatcher pollingDispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + return new MockResponse().setResponseCode(200).setBody(IntegrationHelper.dummyAllSegments()); + } else if (path.contains("/splitChanges")) { + int count = splitChangesHitCount.incrementAndGet(); + if (count <= 1) { + // Initial sync: empty + return new MockResponse().setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(1000, 1000)); + } else { + // Polling sync: return BOTH flag and RBS changes + // s and t must be equal to signal end of sync loop + String responseWithBothChanges = "{\"ff\":{\"s\":2000,\"t\":2000,\"d\":[" + + "{\"trafficTypeName\":\"user\",\"name\":\"test_split\",\"status\":\"ACTIVE\"," + + "\"killed\":false,\"defaultTreatment\":\"off\",\"changeNumber\":2000," + + "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\"," + + "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}," + + "\"partitions\":[{\"treatment\":\"on\",\"size\":100}]}]}" + + "]},\"rbs\":{\"s\":2000,\"t\":2000,\"d\":[" + + "{\"name\":\"test_rbs\",\"status\":\"ACTIVE\",\"trafficTypeName\":\"user\"," + + "\"excluded\":{\"keys\":[],\"segments\":[]}," + + "\"conditions\":[{\"matcherGroup\":{\"combiner\":\"AND\"," + + "\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}}]}" + + "]}}"; + return new MockResponse().setResponseCode(200).setBody(responseWithBothChanges); + } + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(pollingDispatcher); + + // 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 SdkEventListener() { + @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( + // Initial sync: segment1, segment2 + "{\"ms\":{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":1000},\"ls\":{\"k\":[],\"cn\":1000}}", + // Polling: segment1 removed, segment3 added + "{\"ms\":{\"k\":[{\"n\":\"segment2\"},{\"n\":\"segment3\"}],\"cn\":2000},\"ls\":{\"k\":[],\"cn\":1000}}" + ); + } + + /** + * 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( + // Initial sync: large_segment1, large_segment2 + "{\"ms\":{\"k\":[],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment1\"},{\"n\":\"large_segment2\"}],\"cn\":1000}}", + // Polling: large_segment1 removed, large_segment3 added + "{\"ms\":{\"k\":[],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment2\"},{\"n\":\"large_segment3\"}],\"cn\":2000}}" + ); + } + + /** + * 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 { + // Initial sync: segment1, segment2 in ms; large_segment1, large_segment2 in ls + String initialResponse = "{\"ms\":{\"k\":[{\"n\":\"segment1\"},{\"n\":\"segment2\"}],\"cn\":1000},\"ls\":{\"k\":[{\"n\":\"large_segment1\"},{\"n\":\"large_segment2\"}],\"cn\":1000}}"; + // Polling: both ms and ls change + String pollingResponse = "{\"ms\":{\"k\":[{\"n\":\"segment2\"},{\"n\":\"segment3\"}],\"cn\":2000},\"ls\":{\"k\":[{\"n\":\"large_segment2\"},{\"n\":\"large_segment3\"}],\"cn\":2000}}"; + + 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 SdkEventListener() { + @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; + } + /** * Creates a client and waits for SDK_READY to fire. * Returns a TestClientFixture containing the factory, client, and ready latch. @@ -1571,4 +1868,5 @@ private void populateDatabaseWithRbsData() { // Set RBS change number so streaming notifications trigger in-place updates mDatabase.generalInfoDao().update(new GeneralInfoEntity("rbsChangeNumber", 1000L)); } + } 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/service/mysegments/MySegmentsSyncTask.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java index a5f0759bd..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 @@ -16,9 +16,10 @@ import io.split.android.client.dtos.AllSegmentsChange; import io.split.android.client.dtos.SegmentsChange; -import io.split.android.client.events.SplitEvent; 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; @@ -268,17 +269,18 @@ private void fireMySegmentsUpdatedIfNeeded(UpdateSegmentsResult segmentsResult, // 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. - boolean segmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(segmentsResult.oldSegments, segmentsResult.newSegments); - boolean largeSegmentsHaveChanged = mMySegmentsChangeChecker.mySegmentsHaveChanged(largeSegmentsResult.oldSegments, largeSegmentsResult.newSegments); + 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); + mEventsManager.notifyInternalEvent(mUpdateEvent, EventMetadataHelpers.createUpdatedSegmentsMetadata()); } - if (largeSegmentsHaveChanged) { + if (!changedLargeSegments.isEmpty()) { Logger.v("New large segments: " + largeSegmentsResult.newSegments); - mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED); + mEventsManager.notifyInternalEvent(SplitInternalEvent.MY_LARGE_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } // Fire sync complete AFTER update events. This ensures SDK_READY triggers after 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 0ca9b88dc..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 @@ -1,16 +1,12 @@ package io.split.android.client.service.rules; -import static io.split.android.client.service.splits.SplitsSyncHelper.extractRbsNames; import static io.split.android.client.utils.Utils.checkNotNull; import androidx.annotation.NonNull; -import java.util.List; - 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.EventMetadata; import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -46,8 +42,8 @@ public SplitTaskExecutionInfo execute() { boolean triggerSdkUpdate = mRuleBasedSegmentStorage.update(processedChange.getActive(), processedChange.getArchived(), mChangeNumber, null); if (triggerSdkUpdate) { - EventMetadata metadata = createUpdatedRbsMetadata(processedChange); - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, metadata); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } Logger.v("Updated rule based segment"); @@ -58,9 +54,4 @@ public SplitTaskExecutionInfo execute() { return SplitTaskExecutionInfo.error(SplitTaskType.RULE_BASED_SEGMENT_SYNC); } } - - private EventMetadata createUpdatedRbsMetadata(ProcessedRuleBasedSegmentChange processedChange) { - List updatedRbsNames = extractRbsNames(processedChange); - return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); - } } 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 20b261651..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 @@ -4,12 +4,9 @@ import androidx.annotation.NonNull; -import java.util.List; - 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.EventMetadata; import io.split.android.client.events.metadata.EventMetadataHelpers; import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; @@ -47,8 +44,8 @@ public SplitTaskExecutionInfo execute() { boolean triggerSdkUpdate = mRuleBasedSegmentStorage.update(processedChange.getActive(), processedChange.getArchived(), mChangeNumber, null); if (triggerSdkUpdate) { - EventMetadata metadata = createUpdatedRbsMetadata(processedChange); - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, metadata); + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } Logger.v("Updated rule based segment"); @@ -59,9 +56,4 @@ public SplitTaskExecutionInfo execute() { return SplitTaskExecutionInfo.error(SplitTaskType.RULE_BASED_SEGMENT_SYNC); } } - - private EventMetadata createUpdatedRbsMetadata(ProcessedRuleBasedSegmentChange processedChange) { - List updatedRbsNames = SplitsSyncHelper.extractRbsNames(processedChange); - return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); - } } 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 42075e5b2..0ea6127c8 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 @@ -378,52 +378,6 @@ public static List extractFlagNames(@Nullable ProcessedSplitChange proce return updatedNames; } - /** - * Gets the list of updated rule-based segment names from the last sync operation. - * This includes both active (added/modified) and archived (removed) segments. - * - * @return list of updated RBS names, or empty list if no updates occurred - */ - @NonNull - public List getLastUpdatedRbsNames() { - ProcessedRuleBasedSegmentChange lastChange = mLastProcessedRbsChange.get(); - if (lastChange == null) { - return Collections.emptyList(); - } - return extractRbsNames(lastChange); - } - - /** - * Extracts rule-based segment names from a ProcessedRuleBasedSegmentChange. - * This includes both active (added/modified) and archived (removed) segments. - * - * @param processedChange the processed RBS change - * @return list of RBS names, or empty list if change is null - */ - @NonNull - public static List extractRbsNames(@Nullable ProcessedRuleBasedSegmentChange processedChange) { - if (processedChange == null) { - return Collections.emptyList(); - } - - List updatedNames = new ArrayList<>(); - if (processedChange.getActive() != null) { - for (RuleBasedSegment segment : processedChange.getActive()) { - if (segment != null && segment.getName() != null) { - updatedNames.add(segment.getName()); - } - } - } - if (processedChange.getArchived() != null) { - for (RuleBasedSegment segment : processedChange.getArchived()) { - if (segment != null && segment.getName() != null) { - updatedNames.add(segment.getName()); - } - } - } - return updatedNames; - } - private void updateRbsStorage(RuleBasedSegmentChange ruleBasedSegmentChange) { ProcessedRuleBasedSegmentChange change = mRuleBasedSegmentChangeProcessor.process(ruleBasedSegmentChange.getSegments(), ruleBasedSegmentChange.getTill()); mLastProcessedRbsChange.set(change); 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 3a9e426e9..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 @@ -106,14 +106,15 @@ private void notifyInternalEvent(long storedChangeNumber) { // 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); - } - - if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { - EventMetadata rbsMetadata = createUpdatedRbsMetadata(); - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, rbsMetadata); + } 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 @@ -130,11 +131,6 @@ private EventMetadata createUpdatedFlagsMetadata() { return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); } - private EventMetadata createUpdatedRbsMetadata() { - List updatedRbsNames = mSplitsSyncHelper.getLastUpdatedRbsNames(); - return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); - } - private boolean splitsFilterHasChanged(String storedSplitsFilterQueryString) { return !sanitizeString(mSplitsFilterQueryStringFromConfig).equals(sanitizeString(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 8ba5da985..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 @@ -75,14 +75,15 @@ public SplitTaskExecutionInfo execute() { // 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); - } - - if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { - EventMetadata rbsMetadata = createUpdatedRbsMetadata(); - mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, rbsMetadata); + } else if (mSplitsSyncHelper.ruleBasedSegmentsHaveChanged()) { + mEventsManager.notifyInternalEvent(SplitInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + EventMetadataHelpers.createUpdatedSegmentsMetadata()); } // Fire sync complete AFTER update events. @@ -99,11 +100,6 @@ private EventMetadata createUpdatedFlagsMetadata() { return EventMetadataHelpers.createUpdatedFlagsMetadata(updatedSplitNames); } - private EventMetadata createUpdatedRbsMetadata() { - List updatedRbsNames = mSplitsSyncHelper.getLastUpdatedRbsNames(); - return EventMetadataHelpers.createUpdatedSegmentsMetadata(updatedRbsNames); - } - @VisibleForTesting public void setChangeChecker(SplitsChangeChecker changeChecker) { mChangeChecker = changeChecker; 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/storage/mysegments/MySegmentsStorageImpl.java b/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageImpl.java index fd0e81a84..4478a9a81 100644 --- a/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageImpl.java +++ b/main/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageImpl.java @@ -63,6 +63,7 @@ public long getChangeNumber() { public void clear() { mInMemoryMySegments.clear(); mTill.set(DEFAULT_CHANGE_NUMBER); + mPersistentStorage.set(mMatchingKey, SegmentsChange.createEmpty()); } @NonNull 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 70faedd68..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(); } @@ -223,61 +224,61 @@ public void addTillParameterToRequestWhenResponseCnDoesNotMatchTargetAndRetryLim @Test public void syncCompleteEventIsEmittedWhenNoChangesInSegments() throws HttpFetcherException { - when(mMySegmentsChangeChecker.mySegmentsHaveChanged(any(), any())).thenReturn(false); + 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.MEMBERSHIPS_SYNC_COMPLETE); - verify(mEventsManager, never()).notifyInternalEvent(SplitInternalEvent.MY_SEGMENTS_UPDATED); + 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.mySegmentsHaveChanged(any(), any())).thenReturn(true); + 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(SplitInternalEvent.MEMBERSHIPS_SYNC_COMPLETE); - verify(mEventsManager).notifyInternalEvent(eq(SplitInternalEvent.MY_SEGMENTS_UPDATED)); + 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.mySegmentsHaveChanged(any(), any())).thenReturn(true); + 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)); + 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/SplitSyncTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java index 3516c38f1..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 @@ -329,6 +329,28 @@ public void ruleBasedSegmentsUpdatedIsNotFiredWhenRbsUnchanged() { 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, 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 07089657d..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 @@ -189,6 +189,28 @@ public void splitsUpdatedIncludesMetadataWithUpdatedFlags() { })); } + @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 0f8105c7c..e60147600 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 @@ -780,66 +780,4 @@ public void getLastUpdatedFlagNamesIncludesArchivedSplits() throws HttpFetcherEx assertEquals(1, result.size()); assertTrue(result.contains("archived_split")); } - - @Test - public void getLastUpdatedRbsNamesReturnsSegmentNamesAfterSync() throws HttpFetcherException { - RuleBasedSegment activeSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("active_segment"); - RuleBasedSegment archivedSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("archived_segment"); - SplitChange splitChange = SplitChange.create(-1, 100L, Collections.emptyList()); - RuleBasedSegmentChange rbsChange = RuleBasedSegmentChange.create(-1, 100L, Collections.singletonList(activeSegment)); - // Create ProcessedRuleBasedSegmentChange with both active and archived segments - ProcessedRuleBasedSegmentChange processedChange = new ProcessedRuleBasedSegmentChange( - Set.of(activeSegment), Set.of(archivedSegment), 100L, System.currentTimeMillis()); - - doReturn(processedChange).when(mRuleBasedSegmentChangeProcessor).process(any(List.class), anyLong()); - - 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); - - List result = mSplitsSyncHelper.getLastUpdatedRbsNames(); - assertEquals(2, result.size()); - assertTrue(result.contains("active_segment")); - assertTrue(result.contains("archived_segment")); - } - - @Test - public void getLastUpdatedRbsNamesReturnsEmptyListWhenNoSyncPerformed() { - List result = mSplitsSyncHelper.getLastUpdatedRbsNames(); - assertTrue(result.isEmpty()); - } - - @Test - public void extractRbsNamesReturnsActiveAndArchivedSegmentNames() { - RuleBasedSegment activeSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("active_segment"); - RuleBasedSegment archivedSegment = RuleBasedSegmentStorageImplTest.createRuleBasedSegment("archived_segment"); - ProcessedRuleBasedSegmentChange processedChange = new ProcessedRuleBasedSegmentChange( - Set.of(activeSegment), Set.of(archivedSegment), 100L, System.currentTimeMillis()); - - List result = SplitsSyncHelper.extractRbsNames(processedChange); - - assertEquals(2, result.size()); - assertTrue(result.contains("active_segment")); - assertTrue(result.contains("archived_segment")); - } - - @Test - public void extractRbsNamesReturnsEmptyListForNullChange() { - List result = SplitsSyncHelper.extractRbsNames(null); - assertTrue(result.isEmpty()); - } - - @Test - public void extractRbsNamesHandlesNullActiveAndArchivedSets() { - ProcessedRuleBasedSegmentChange processedChange = new ProcessedRuleBasedSegmentChange( - null, null, 100L, System.currentTimeMillis()); - - List result = SplitsSyncHelper.extractRbsNames(processedChange); - - assertTrue(result.isEmpty()); - } } 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 713ba5cfa..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 @@ -99,7 +99,7 @@ public void updateIsCalledOnStorage() { } @Test - public void segmentsUpdatedIncludesMetadataWithActiveAndArchivedSegmentNames() { + public void segmentsUpdatedIncludesMetadataWithEmptyNames() { RuleBasedSegment activeSegment = createRuleBasedSegment("active_segment"); RuleBasedSegment archivedSegment = createRuleBasedSegment("archived_segment"); long changeNumber = 123L; @@ -111,14 +111,13 @@ public void segmentsUpdatedIncludesMetadataWithActiveAndArchivedSegmentNames() { 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); - assertEquals(2, names.size()); - assertTrue(names.contains("active_segment")); - assertTrue(names.contains("archived_segment")); + assertTrue("Names should be empty for SEGMENTS_UPDATE", names.isEmpty()); assertEquals(SdkUpdateMetadata.Type.SEGMENTS_UPDATE, typedMeta.getType()); return true; })); From 65dae904c809a467862dd202594fc16897a2bb0b Mon Sep 17 00:00:00 2001 From: gthea Date: Fri, 16 Jan 2026 10:25:30 -0300 Subject: [PATCH 22/24] Javadoc and sources fix (#851) * Fix sources and javadoc * Dynamic dep list for javadoc --- build.gradle | 86 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/build.gradle b/build.gradle index 0209da0a1..b27746e92 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ apply plugin: 'com.vanniktech.maven.publish' apply from: "$rootDir/gradle/jacoco-root.gradle" ext { - splitVersion = '5.5.0-rc6' + splitVersion = '5.5.0-rc7' jacocoVersion = '0.8.8' } @@ -145,6 +145,68 @@ dependencies { 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 = { name = 'Split Android SDK' description = 'Official Split Android SDK' @@ -377,6 +439,8 @@ 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 } @@ -393,16 +457,18 @@ gradle.taskGraph.whenReady { graph -> 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" } - if (sourcesArtifacts.size() > 1) { - // Keep the AGP one (merged_sources_jar), remove the vanniktech empty one (build/libs) - def toRemove = sourcesArtifacts.find { - it.file?.absolutePath?.contains("build/libs") || it.file?.name?.contains("android-client-sources") - } - if (toRemove) { - pub.artifacts.remove(toRemove) - println "Removed duplicate empty sources artifact: ${toRemove.file?.absolutePath}" - } + 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}" } } } From a230c01fd888de5d4d9ec93fd5f2cd5945ded747 Mon Sep 17 00:00:00 2001 From: gthea Date: Mon, 19 Jan 2026 14:19:00 -0300 Subject: [PATCH 23/24] Increase test coverage (#852) --- .../io/split/android/client/SplitClient.java | 6 +- .../events/SdkEventsIntegrationTest.java | 263 +++++++++++++++++- .../split/android/client/SplitClientImpl.java | 6 +- .../localhost/LocalhostSplitClient.java | 6 +- .../SplitClientImplEventRegistrationTest.java | 25 +- .../localhost/LocalhostSplitClientTest.java | 24 +- 6 files changed, 309 insertions(+), 21 deletions(-) diff --git a/api/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java index 51f02dcaa..007303091 100644 --- a/api/src/main/java/io/split/android/client/SplitClient.java +++ b/api/src/main/java/io/split/android/client/SplitClient.java @@ -186,6 +186,8 @@ public interface SplitClient extends AttributesManager { * 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() {
@@ -210,9 +212,9 @@ public interface SplitClient extends AttributesManager {
      * });
      * }
* - * @param listener the event listener to register + * @param listener the event listener to register. Must not be null. */ - void addEventListener(SdkEventListener listener); + void addEventListener(@NonNull SdkEventListener listener); /** * Enqueue a new event to be sent to Split data collection services. diff --git a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java index ccb1eea8b..24d3283f5 100644 --- a/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java +++ b/main/src/androidTest/java/tests/integration/events/SdkEventsIntegrationTest.java @@ -977,33 +977,39 @@ public void onUpdate(SplitClient client, SdkUpdateMetadata metadata) { * 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 for that client - * Then no external events are emitted - * And handler H is never invoked + * 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 + * And H2 is never invoked even when another update is pushed */ @Test public void destroyingClientStopsEventsAndClearsHandlers() throws Exception { - // Given: sdkReady has already been emitted - TestClientFixture fixture = createClientAndWaitForReady(new Key("key_1")); + // Given: sdkReady has already been emitted (with streaming support) + TestClientFixture fixture = createStreamingClientAndWaitForReady(new Key("key_1")); AtomicInteger handler1Count = new AtomicInteger(0); AtomicInteger handler2Count = new AtomicInteger(0); - // Given: a handler H registered for sdkUpdate - registerUpdateHandler(fixture.client, handler1Count, null); + // Given: a handler H registered for sdkUpdate before destroy + fixture.client.addEventListener(createOnUpdateListener(handler1Count, null, null)); // When: the client is destroyed fixture.client.destroy(); - // When: registering a new handler H2 for sdkUpdate after destroy - registerUpdateHandler(fixture.client, handler2Count, null); + fixture.pushSplitUpdate("3000", "2000"); - // Then: handlers are not invoked (client is destroyed) + // Handler H is never invoked (handlers were cleared on destroy) Thread.sleep(1000); assertEquals("Handler H1 should not be invoked after destroy", 0, handler1Count.get()); + + // When: registering a new handler H2 for sdkUpdate after destroy + fixture.client.addEventListener(createOnUpdateListener(handler2Count, null, null)); + + fixture.pushSplitUpdate("4000", "3000"); + + Thread.sleep(1000); + assertEquals("Handler H1 should still be 0", 0, handler1Count.get()); assertEquals("Handler H2 should not be invoked after destroy", 0, handler2Count.get()); fixture.destroy(); @@ -1497,6 +1503,188 @@ public void onPostExecution(SplitClient c) { return receivedMetadataList; } + + + /** + * Scenario: Multiple listeners with onUpdate are both invoked + *

+ * Given sdkReady has already been emitted + * And two different SdkEventListener instances (L1 and L2) with onUpdate handlers are registered + * 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")); + + AtomicInteger listener1Count = new AtomicInteger(0); + AtomicInteger listener2Count = new AtomicInteger(0); + AtomicReference listener1Metadata = new AtomicReference<>(); + AtomicReference listener2Metadata = new AtomicReference<>(); + CountDownLatch updateLatch = new CountDownLatch(2); + + fixture.client.addEventListener(createOnUpdateListener(listener1Count, listener1Metadata, updateLatch)); + fixture.client.addEventListener(createOnUpdateListener(listener2Count, listener2Metadata, updateLatch)); + + fixture.pushSplitUpdate(); + + assertTrue("Both listeners should be invoked", updateLatch.await(10, TimeUnit.SECONDS)); + assertEquals("Listener 1 should be invoked exactly once", 1, listener1Count.get()); + assertEquals("Listener 2 should be invoked exactly once", 1, listener2Count.get()); + assertNotNull("Listener 1 should receive metadata", listener1Metadata.get()); + assertNotNull("Listener 2 should receive metadata", listener2Metadata.get()); + + fixture.destroy(); + } + + /** + * Scenario: Multiple listeners with onReady are both invoked + *

+ * Given the SDK is starting + * And two different SdkEventListener instances (L1 and L2) with onReady handlers are registered + * 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")); + + AtomicInteger listener1Count = new AtomicInteger(0); + AtomicInteger listener2Count = new AtomicInteger(0); + AtomicReference listener1Metadata = new AtomicReference<>(); + AtomicReference listener2Metadata = new AtomicReference<>(); + CountDownLatch readyLatch = new CountDownLatch(2); + + client.addEventListener(createOnReadyListener(listener1Count, listener1Metadata, readyLatch)); + client.addEventListener(createOnReadyListener(listener2Count, listener2Metadata, readyLatch)); + + assertTrue("Both listeners should be invoked", readyLatch.await(10, TimeUnit.SECONDS)); + assertEquals("Listener 1 should be invoked exactly once", 1, listener1Count.get()); + assertEquals("Listener 2 should be invoked exactly once", 1, listener2Count.get()); + assertNotNull("Listener 1 should receive metadata", listener1Metadata.get()); + assertNotNull("Listener 2 should receive metadata", listener2Metadata.get()); + + factory.destroy(); + } + + /** + * Scenario: Listeners with different callbacks (onReady and onUpdate) each invoked on correct event + *

+ * Given the SDK is starting + * And a SdkEventListener L1 with onReady handler is registered + * And a SdkEventListener L2 with onUpdate handler is registered + * 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")); + + AtomicInteger onReadyCount = new AtomicInteger(0); + AtomicInteger onUpdateCount = new AtomicInteger(0); + CountDownLatch readyLatch = new CountDownLatch(1); + CountDownLatch updateLatch = new CountDownLatch(1); + + fixture.client.addEventListener(createOnReadyListener(onReadyCount, null, readyLatch)); + fixture.client.addEventListener(createOnUpdateListener(onUpdateCount, null, updateLatch)); + + assertTrue("SDK_READY should fire", readyLatch.await(10, TimeUnit.SECONDS)); + assertEquals("onReady should be invoked exactly once", 1, onReadyCount.get()); + assertEquals("onUpdate should NOT be invoked on SDK_READY", 0, onUpdateCount.get()); + + fixture.waitForSseConnection(); + fixture.pushSplitUpdate(); + + assertTrue("SDK_UPDATE should fire", updateLatch.await(10, TimeUnit.SECONDS)); + assertEquals("onUpdate should be invoked exactly once", 1, onUpdateCount.get()); + assertEquals("onReady should still be 1 (not invoked again)", 1, onReadyCount.get()); + + fixture.destroy(); + } + + /** + * Scenario: Multiple listeners with both onReady and onUpdate in same listener + *

+ * Given the SDK is starting + * And two SdkEventListener instances (L1 and L2) each with both onReady and onUpdate handlers + * 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 listener1ReadyCount = new AtomicInteger(0); + AtomicInteger listener1UpdateCount = new AtomicInteger(0); + AtomicInteger listener2ReadyCount = new AtomicInteger(0); + AtomicInteger listener2UpdateCount = new AtomicInteger(0); + CountDownLatch readyLatch = new CountDownLatch(2); + CountDownLatch updateLatch = new CountDownLatch(2); + + fixture.client.addEventListener(createDualListener(listener1ReadyCount, readyLatch, listener1UpdateCount, updateLatch)); + fixture.client.addEventListener(createDualListener(listener2ReadyCount, readyLatch, listener2UpdateCount, updateLatch)); + + assertTrue("Both onReady handlers should be invoked", readyLatch.await(10, TimeUnit.SECONDS)); + assertEquals("Listener 1 onReady should be invoked once", 1, listener1ReadyCount.get()); + assertEquals("Listener 2 onReady should be invoked once", 1, listener2ReadyCount.get()); + assertEquals("Listener 1 onUpdate should NOT be invoked on SDK_READY", 0, listener1UpdateCount.get()); + assertEquals("Listener 2 onUpdate should NOT be invoked on SDK_READY", 0, listener2UpdateCount.get()); + + fixture.waitForSseConnection(); + fixture.pushSplitUpdate(); + + assertTrue("Both onUpdate handlers should be invoked", updateLatch.await(10, TimeUnit.SECONDS)); + assertEquals("Listener 1 onUpdate should be invoked once", 1, listener1UpdateCount.get()); + assertEquals("Listener 2 onUpdate should be invoked once", 1, listener2UpdateCount.get()); + assertEquals("Listener 1 onReady should still be 1", 1, listener1ReadyCount.get()); + assertEquals("Listener 2 onReady should still be 1", 1, listener2ReadyCount.get()); + + fixture.destroy(); + } + + /** + * Scenario: Multiple listeners with onReady replay to late subscribers + *

+ * Given SDK_READY has already been emitted + * And a SdkEventListener L1 with onReady was registered before SDK_READY and was invoked + * When a new SdkEventListener L2 with onReady is registered after SDK_READY has fired + * 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")); + + AtomicInteger listener1Count = new AtomicInteger(0); + AtomicInteger listener2Count = new AtomicInteger(0); + CountDownLatch listener1Latch = new CountDownLatch(1); + CountDownLatch listener2Latch = new CountDownLatch(1); + + fixture.client.addEventListener(createOnReadyListener(listener1Count, null, listener1Latch)); + assertTrue("Listener 1 should receive replay", listener1Latch.await(5, TimeUnit.SECONDS)); + assertEquals("Listener 1 should be invoked once (replay)", 1, listener1Count.get()); + + fixture.client.addEventListener(createOnReadyListener(listener2Count, null, listener2Latch)); + assertTrue("Listener 2 should receive replay", listener2Latch.await(5, TimeUnit.SECONDS)); + assertEquals("Listener 2 should be invoked once (replay)", 1, listener2Count.get()); + + Thread.sleep(500); + assertEquals("Listener 1 should still be 1 (not invoked again)", 1, listener1Count.get()); + + fixture.destroy(); + } + /** * Creates a client and waits for SDK_READY to fire. * Returns a TestClientFixture containing the factory, client, and ready latch. @@ -1690,6 +1878,58 @@ public void onPostExecution(SplitClient client) { }); } + /** + * Creates a SdkEventListener that counts onReady invocations and captures metadata. + */ + private SdkEventListener createOnReadyListener(AtomicInteger count, + AtomicReference metadata, + CountDownLatch latch) { + return new SdkEventListener() { + @Override + public void onReady(SplitClient client, SdkReadyMetadata eventMetadata) { + if (count != null) count.incrementAndGet(); + if (metadata != null) metadata.set(eventMetadata); + if (latch != null) latch.countDown(); + } + }; + } + + /** + * Creates a SdkEventListener that counts onUpdate invocations and captures metadata. + */ + private SdkEventListener createOnUpdateListener(AtomicInteger count, + AtomicReference metadata, + CountDownLatch latch) { + return new SdkEventListener() { + @Override + public void onUpdate(SplitClient client, SdkUpdateMetadata eventMetadata) { + if (count != null) count.incrementAndGet(); + if (metadata != null) metadata.set(eventMetadata); + if (latch != null) latch.countDown(); + } + }; + } + + /** + * Creates a SdkEventListener with both onReady and onUpdate handlers. + */ + private SdkEventListener createDualListener(AtomicInteger readyCount, CountDownLatch readyLatch, + AtomicInteger updateCount, CountDownLatch updateLatch) { + return new SdkEventListener() { + @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(); + } + }; + } + private static final String SPLIT_UPDATE_PAYLOAD = "eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwiaWQiOiJkNDMxY2RkMC1iMGJlLTExZWEtOGE4MC0xNjYwYWRhOWNlMzkiLCJuYW1lIjoibWF1cm9famF2YSIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6LTkyMzkxNDkxLCJzZWVkIjotMTc2OTM3NzYwNCwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTY4NDMyOTg1NDM4NSwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiV0hJVEVMSVNUIiwibWF0Y2hlckdyb3VwIjp7ImNvbWJpbmVyIjoiQU5EIiwibWF0Y2hlcnMiOlt7Im1hdGNoZXJUeXBlIjoiV0hJVEVMSVNUIiwibmVnYXRlIjpmYWxzZSwid2hpdGVsaXN0TWF0Y2hlckRhdGEiOnsid2hpdGVsaXN0IjpbImFkbWluIiwibWF1cm8iLCJuaWNvIl19fV19LCJwYXJ0aXRpb25zIjpbeyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9XSwibGFiZWwiOiJ3aGl0ZWxpc3RlZCJ9LHsiY29uZGl0aW9uVHlwZSI6IlJPTExPVVQiLCJtYXRjaGVyR3JvdXAiOnsiY29tYmluZXIiOiJBTkQiLCJtYXRjaGVycyI6W3sia2V5U2VsZWN0b3IiOnsidHJhZmZpY1R5cGUiOiJ1c2VyIn0sIm1hdGNoZXJUeXBlIjoiSU5fU0VHTUVOVCIsIm5lZ2F0ZSI6ZmFsc2UsInVzZXJEZWZpbmVkU2VnbWVudE1hdGNoZXJEYXRhIjp7InNlZ21lbnROYW1lIjoibWF1ci0yIn19XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImluIHNlZ21lbnQgbWF1ci0yIn0seyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJrZXlTZWxlY3RvciI6eyJ0cmFmZmljVHlwZSI6InVzZXIifSwibWF0Y2hlclR5cGUiOiJBTExfS0VZUyIsIm5lZ2F0ZSI6ZmFsc2V9XX0sInBhcnRpdGlvbnMiOlt7InRyZWF0bWVudCI6Im9uIiwic2l6ZSI6MH0seyJ0cmVhdG1lbnQiOiJvZmYiLCJzaXplIjoxMDB9LHsidHJlYXRtZW50IjoiVjQiLCJzaXplIjowfSx7InRyZWF0bWVudCI6InY1Iiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0="; /** @@ -1868,5 +2108,4 @@ private void populateDatabaseWithRbsData() { // Set RBS change number so streaming notifications trigger in-place updates mDatabase.generalInfoDao().update(new GeneralInfoEntity("rbsChangeNumber", 1000L)); } - } 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 1cbfdb317..307618238 100644 --- a/main/src/main/java/io/split/android/client/SplitClientImpl.java +++ b/main/src/main/java/io/split/android/client/SplitClientImpl.java @@ -204,7 +204,11 @@ public void on(SplitEvent event, SplitEventTask task) { } @Override - public void addEventListener(SdkEventListener listener) { + public void addEventListener(@NonNull SdkEventListener 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; 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 5944162a6..0b5b6a0c3 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 @@ -272,7 +272,11 @@ public void on(SplitEvent event, SplitEventTask task) { } @Override - public void addEventListener(SdkEventListener listener) { + public void addEventListener(@NonNull SdkEventListener 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; diff --git a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java index 99b237cc1..c76b3c9a0 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java @@ -3,6 +3,7 @@ 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; @@ -10,6 +11,7 @@ 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; @@ -20,6 +22,7 @@ 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; @@ -129,10 +132,13 @@ public void sdkUpdateRegistersWhenNotAlreadyTriggered() { } @Test - public void addEventListenerWithNullListenerDoesNotRegister() { - splitClient.addEventListener(null); + public void addEventListenerWithNullListenerDoesNotRegisterAndLogsWarning() { + try (MockedStatic logger = mockStatic(Logger.class)) { + splitClient.addEventListener(null); - verify(eventsManager, never()).registerEventListener(any(SdkEventListener.class)); + verify(eventsManager, never()).registerEventListener(any(SdkEventListener.class)); + logger.verify(() -> Logger.w("SDK Event Listener cannot be null")); + } } @Test @@ -143,4 +149,17 @@ public void addEventListenerWithValidListenerRegistersListener() { verify(eventsManager).registerEventListener(eq(listener)); } + + @Test + public void addEventListenerDoesNotRegisterWhenClientIsDestroyedAndLogsWarning() { + try (MockedStatic logger = mockStatic(Logger.class)) { + splitClient.destroy(); + + SdkEventListener listener = mock(SdkEventListener.class); + splitClient.addEventListener(listener); + + verify(eventsManager, never()).registerEventListener(any(SdkEventListener.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/localhost/LocalhostSplitClientTest.java b/main/src/test/java/io/split/android/client/localhost/LocalhostSplitClientTest.java index 6f2d537ce..1b8b7a38c 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; @@ -43,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; @@ -443,9 +447,25 @@ public void onDoesNotRegisterEventTaskWhenEventAlreadyTriggered() { @Test public void addEventListenerWithNullListenerDoesNotRegister() { - client.addEventListener(null); + try (MockedStatic logger = mockStatic(Logger.class)) { + client.addEventListener(null); + + verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class)); + logger.verify(() -> Logger.w("SDK Event Listener cannot be null")); + } + } + + @Test + public void addEventListenerDoesNotRegisterWhenClientIsDestroyed() { + try (MockedStatic logger = mockStatic(Logger.class)) { + client.destroy(); + SdkEventListener listener = mock(SdkEventListener.class); + + client.addEventListener(listener); - verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class)); + verify(mockEventsManager, never()).registerEventListener(any(SdkEventListener.class)); + logger.verify(() -> Logger.w("Client has already been destroyed. Cannot add event listener")); + } } @Test From 141e298f9abd0ea819c24f6ba69c447414f3cb75 Mon Sep 17 00:00:00 2001 From: gthea Date: Wed, 21 Jan 2026 17:45:33 -0300 Subject: [PATCH 24/24] Metadata source in events manager (#856) --- LICENSE.txt | 2 +- .../io/split/android/client/SplitClient.java | 4 +- ...tListener.java => SplitEventListener.java} | 2 +- .../android/client/events/SplitEventTask.java | 2 +- build.gradle | 3 - .../events/EventsManagerCoordinator.java | 6 +- .../events/ListenableEventsManager.java | 2 +- .../client/events/SplitEventsManager.java | 14 +- .../SplitEventsManagerConfigFactory.java | 9 + events/README.md | 5 + .../harness/events/EventsManagerConfig.java | 62 +- .../io/harness/events/EventsManagerCore.java | 74 +- .../events/EventsManagerConfigTest.java | 25 + .../events/EventsManagerMetadataTest.java | 90 ++ .../java/fake/SplitClientStub.java | 4 +- .../java/helper/IntegrationHelper.java | 115 +- .../events/SdkEventsIntegrationTest.java | 1333 ++++++++--------- .../java/tests/service/EventsManagerTest.java | 6 +- .../AlwaysReturnControlSplitClient.java | 4 +- .../split/android/client/SplitClientImpl.java | 4 +- .../localhost/LocalhostSplitClient.java | 4 +- .../client/network/HttpClientImpl.java | 11 +- .../service/splits/SplitsSyncHelper.java | 16 +- .../SplitClientImplEventRegistrationTest.java | 10 +- .../client/events/EventsManagerTest.java | 8 +- .../localhost/LocalhostSplitClientTest.java | 10 +- .../client/network/HttpClientTest.java | 36 + .../client/service/SplitsSyncHelperTest.java | 26 + .../android/fake/SplitEventsManagerStub.java | 4 +- 29 files changed, 1122 insertions(+), 769 deletions(-) rename api/src/main/java/io/split/android/client/events/{SdkEventListener.java => SplitEventListener.java} (99%) create mode 100644 events/src/test/java/io/harness/events/EventsManagerMetadataTest.java diff --git a/LICENSE.txt b/LICENSE.txt index df08de3fb..b6579621e 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright © 2025 Split Software, Inc. +Copyright © 2026 Split Software, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/src/main/java/io/split/android/client/SplitClient.java b/api/src/main/java/io/split/android/client/SplitClient.java index 007303091..5a553d281 100644 --- a/api/src/main/java/io/split/android/client/SplitClient.java +++ b/api/src/main/java/io/split/android/client/SplitClient.java @@ -7,7 +7,7 @@ import java.util.Map; import io.split.android.client.attributes.AttributesManager; -import io.split.android.client.events.SdkEventListener; +import io.split.android.client.events.SplitEventListener; import io.split.android.client.events.SplitEvent; import io.split.android.client.events.SplitEventTask; @@ -214,7 +214,7 @@ public interface SplitClient extends AttributesManager { * * @param listener the event listener to register. Must not be null. */ - void addEventListener(@NonNull SdkEventListener listener); + void addEventListener(@NonNull SplitEventListener listener); /** * Enqueue a new event to be sent to Split data collection services. diff --git a/api/src/main/java/io/split/android/client/events/SdkEventListener.java b/api/src/main/java/io/split/android/client/events/SplitEventListener.java similarity index 99% rename from api/src/main/java/io/split/android/client/events/SdkEventListener.java rename to api/src/main/java/io/split/android/client/events/SplitEventListener.java index c6a7a4409..424f5503b 100644 --- a/api/src/main/java/io/split/android/client/events/SdkEventListener.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventListener.java @@ -36,7 +36,7 @@ * }); * } */ -public abstract class SdkEventListener { +public abstract class SplitEventListener { /** * Called when SDK_READY event occurs, executed on a background thread. diff --git a/api/src/main/java/io/split/android/client/events/SplitEventTask.java b/api/src/main/java/io/split/android/client/events/SplitEventTask.java index c2b704cf5..7c053b55f 100644 --- a/api/src/main/java/io/split/android/client/events/SplitEventTask.java +++ b/api/src/main/java/io/split/android/client/events/SplitEventTask.java @@ -14,7 +14,7 @@ * *

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

* Example usage: *

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

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

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

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

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

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

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

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

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

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

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

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