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")); + } }