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" }