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