diff --git a/build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt b/build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt index 7b00a9fd1bc..6739d8c99fd 100644 --- a/build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt +++ b/build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt @@ -14,5 +14,6 @@ object ThunderbirdProjectConfig { object Compiler { val javaCompatibility = JavaVersion.VERSION_11 val jvmTarget = JvmTarget.JVM_11 + val javaVersion = JavaVersion.VERSION_11 } } diff --git a/feature/notification/api/build.gradle.kts b/feature/notification/api/build.gradle.kts index 475c6cf3c81..bf008551de4 100644 --- a/feature/notification/api/build.gradle.kts +++ b/feature/notification/api/build.gradle.kts @@ -18,6 +18,10 @@ kotlin { implementation(projects.core.ui.compose.designsystem) implementation(projects.core.ui.compose.theme2.common) } + jvmTest.dependencies { + implementation(libs.kotlinx.coroutines.test) + implementation(libs.bundles.shared.jvm.test) + } } sourceSets.all { @@ -32,6 +36,11 @@ android { namespace = "net.thunderbird.feature.notification.api" } +java { + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaVersion + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaVersion +} + compose.resources { publicResClass = false packageOfResClass = "net.thunderbird.feature.notification.resources.api" diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompat.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompat.kt new file mode 100644 index 00000000000..b5a75ba8036 --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompat.kt @@ -0,0 +1,50 @@ +package net.thunderbird.feature.notification.api.sender.compat + +import androidx.annotation.Discouraged +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure +import net.thunderbird.feature.notification.api.command.NotificationCommand.Success +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.sender.NotificationSender + +/** + * A compatibility layer for sending notifications from Java code. + * + * This class wraps [NotificationSender] and provides a Java-friendly API for sending notifications + * and receiving results via a callback interface. + * + * It is marked as [Discouraged] because it is intended only for use within Java classes. + * Kotlin code should use [NotificationSender] directly. + * + * @property notificationSender The underlying [NotificationSender] instance. + * @property mainImmediateDispatcher The [CoroutineDispatcher] used for launching coroutines. + */ +@Discouraged("Only for usage within a Java class. Use NotificationSender instead.") +class NotificationSenderCompat @JvmOverloads constructor( + private val notificationSender: NotificationSender, + mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, +) : DisposableHandle { + private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher) + + fun send(notification: Notification, onResultListener: OnResultListener) { + notificationSender.send(notification) + .onEach { outcome -> onResultListener.onResult(outcome) } + .launchIn(scope) + } + + override fun dispose() { + scope.cancel() + } + + fun interface OnResultListener { + fun onResult(outcome: Outcome, Failure>) + } +} diff --git a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompatTest.kt b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompatTest.kt new file mode 100644 index 00000000000..08e3259be18 --- /dev/null +++ b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompatTest.kt @@ -0,0 +1,58 @@ +package net.thunderbird.feature.notification.api.sender.compat + +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import dev.mokkery.matcher.any +import dev.mokkery.spy +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode.Companion.exactly +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure +import net.thunderbird.feature.notification.api.command.NotificationCommand.Success +import net.thunderbird.feature.notification.api.command.NotificationCommandException +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.testing.fake.FakeNotification +import net.thunderbird.feature.notification.testing.fake.command.FakeInAppNotificationCommand +import net.thunderbird.feature.notification.testing.fake.command.FakeSystemNotificationCommand +import net.thunderbird.feature.notification.testing.fake.sender.FakeNotificationSender + +class NotificationSenderCompatTest { + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `send should call listener callback whenever a result is received`() { + // Arrange + val expectedResults = listOf, Failure>>( + Outcome.success(Success(FakeInAppNotificationCommand())), + Outcome.success(Success(FakeSystemNotificationCommand())), + Outcome.failure( + error = Failure( + command = FakeSystemNotificationCommand(), + throwable = NotificationCommandException("What an issue?"), + ), + ), + ) + val sender = FakeNotificationSender(results = expectedResults) + val actualResults = mutableListOf, Failure>>() + val listener = spy( + NotificationSenderCompat.OnResultListener { outcome -> + actualResults += outcome + }, + ) + val testSubject = NotificationSenderCompat( + notificationSender = sender, + mainImmediateDispatcher = UnconfinedTestDispatcher(), + ) + + // Act + testSubject.send(notification = FakeNotification(), listener) + + // Assert + verify(exactly(expectedResults.size)) { + listener.onResult(outcome = any()) + } + assertThat(actualResults).containsExactlyInAnyOrder(elements = expectedResults.toTypedArray()) + } +} diff --git a/feature/notification/api/src/jvmTest/java/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompatJavaTest.java b/feature/notification/api/src/jvmTest/java/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompatJavaTest.java new file mode 100644 index 00000000000..94c16247703 --- /dev/null +++ b/feature/notification/api/src/jvmTest/java/net/thunderbird/feature/notification/api/sender/compat/NotificationSenderCompatJavaTest.java @@ -0,0 +1,96 @@ +package net.thunderbird.feature.notification.api.sender.compat; + + +import java.util.ArrayList; +import java.util.List; + +import kotlinx.coroutines.Dispatchers; +import kotlinx.coroutines.test.TestCoroutineDispatchersKt; +import kotlinx.coroutines.test.TestDispatcher; +import kotlinx.coroutines.test.TestDispatchers; +import net.thunderbird.core.outcome.Outcome; +import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure; +import net.thunderbird.feature.notification.api.command.NotificationCommand.Success; +import net.thunderbird.feature.notification.api.command.NotificationCommandException; +import net.thunderbird.feature.notification.api.content.Notification; +import net.thunderbird.feature.notification.testing.fake.FakeNotification; +import net.thunderbird.feature.notification.testing.fake.command.FakeInAppNotificationCommand; +import net.thunderbird.feature.notification.testing.fake.command.FakeSystemNotificationCommand; +import net.thunderbird.feature.notification.testing.fake.sender.FakeNotificationSender; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + + +public class NotificationSenderCompatJavaTest { + private TestDispatcher testDispatcher; + + @Before + public void setUp() { + testDispatcher = TestCoroutineDispatchersKt.UnconfinedTestDispatcher(null, null); + TestDispatchers.setMain(Dispatchers.INSTANCE, testDispatcher); + } + + @After + public void tearDown() { + // restore original Main + TestDispatchers.resetMain(Dispatchers.INSTANCE); + } + + @Test + public void send_shouldCallListenerCallback_wheneverAResultIsReceived() { + // Arrange + @SuppressWarnings("unchecked") final List< + Outcome< + ? extends @NotNull Success, + ? extends @NotNull Failure + > + > expectedResults = List.of( + Outcome.Companion.success(new Success<>(new FakeInAppNotificationCommand())), + Outcome.Companion.success(new Success<>(new FakeSystemNotificationCommand())), + Outcome.Companion.failure( + new Failure<>( + new FakeSystemNotificationCommand(), + new NotificationCommandException("What an issue?") + ) + ) + ); + + + final FakeNotificationSender sender = new FakeNotificationSender(expectedResults); + + final ResultListener listener = new ResultListener(); + final NotificationSenderCompat.OnResultListener spyListener = spy(listener); + + final NotificationSenderCompat testSubject = new NotificationSenderCompat(sender, testDispatcher); + + // Act + testSubject.send(new FakeNotification(), spyListener); + + // Assert + verify(spyListener, times(expectedResults.size())).onResult(any()); + assertEquals(expectedResults, listener.actualResults); + } + + private static class ResultListener implements NotificationSenderCompat.OnResultListener { + final ArrayList< + Outcome< + ? extends @NotNull Success, + ? extends @NotNull Failure + > + > actualResults = new ArrayList<>(); + + @Override + public void onResult( + @NotNull Outcome, ? extends @NotNull Failure> outcome) { + actualResults.add(outcome); + } + } +} diff --git a/feature/notification/testing/build.gradle.kts b/feature/notification/testing/build.gradle.kts index 81d994c18c9..ae882ad1554 100644 --- a/feature/notification/testing/build.gradle.kts +++ b/feature/notification/testing/build.gradle.kts @@ -9,6 +9,7 @@ android { kotlin { sourceSets { commonMain.dependencies { + implementation(projects.core.outcome) api(projects.feature.notification.api) } } diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/command/FakeInAppNotificationCommand.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/command/FakeInAppNotificationCommand.kt new file mode 100644 index 00000000000..61dcba410df --- /dev/null +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/command/FakeInAppNotificationCommand.kt @@ -0,0 +1,16 @@ +package net.thunderbird.feature.notification.testing.fake.command + +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.command.NotificationCommand +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification +import net.thunderbird.feature.notification.testing.fake.receiver.FakeInAppNotificationNotifier + +class FakeInAppNotificationCommand( + notification: InAppNotification = FakeInAppOnlyNotification(), + notifier: NotificationNotifier = FakeInAppNotificationNotifier(), +) : NotificationCommand(notification, notifier) { + override suspend fun execute(): Outcome, Failure> = + error("not implemented") +} diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/command/FakeSystemNotificationCommand.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/command/FakeSystemNotificationCommand.kt new file mode 100644 index 00000000000..57decdbf2fa --- /dev/null +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/command/FakeSystemNotificationCommand.kt @@ -0,0 +1,16 @@ +package net.thunderbird.feature.notification.testing.fake.command + +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.command.NotificationCommand +import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +import net.thunderbird.feature.notification.testing.fake.FakeSystemOnlyNotification +import net.thunderbird.feature.notification.testing.fake.receiver.FakeSystemNotificationNotifier + +class FakeSystemNotificationCommand( + notification: SystemNotification = FakeSystemOnlyNotification(), + notifier: NotificationNotifier = FakeSystemNotificationNotifier(), +) : NotificationCommand(notification, notifier) { + override suspend fun execute(): Outcome, Failure> = + error("not implemented") +} diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeSystemNotificationNotifier.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeSystemNotificationNotifier.kt new file mode 100644 index 00000000000..1c4ce381c99 --- /dev/null +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeSystemNotificationNotifier.kt @@ -0,0 +1,14 @@ +package net.thunderbird.feature.notification.testing.fake.receiver + +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier + +class FakeSystemNotificationNotifier : NotificationNotifier { + override suspend fun show( + id: NotificationId, + notification: SystemNotification, + ) = Unit + + override fun dispose() = Unit +} diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/sender/FakeNotificationSender.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/sender/FakeNotificationSender.kt new file mode 100644 index 00000000000..3f2c272dcef --- /dev/null +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/sender/FakeNotificationSender.kt @@ -0,0 +1,16 @@ +package net.thunderbird.feature.notification.testing.fake.sender + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure +import net.thunderbird.feature.notification.api.command.NotificationCommand.Success +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.sender.NotificationSender + +class FakeNotificationSender( + private val results: List, Failure>>, +) : NotificationSender { + override fun send(notification: Notification): Flow, Failure>> = + results.asFlow() +}