Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
In-App Error Notifications - Create NotificationSender
As we are going to have at least two different notification providers (system and in-app), it is important to have a manager to simplify the notification triggering.

The `NotificationSender` will be responsible for receiving a notification object and triggering the notification using the appropriate provider.


Discussion hints:
Verify if the current implementation of [`NotificationDataStore`](https://github.com/thunderbird/thunderbird-android/blob/main/legacy/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt), [`NotificationController`](https://github.com/thunderbird/thunderbird-android/blob/main/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt) and related code isn't enough to achieve such

Pull request: feat(notification): trigger in-app notification using `InAppNotificationNotifier`
Depends on #9500, #9506 
Changeset split from #9415.
Resolve #9245.

- Implement the `InAppNotificationCommand` and `InAppNotificationNotifier`
- Add `InAppNotificationEventBus` and `InAppNotificationReceiver` to broadcast the notification over the application without using Android `BroadcastReceiver`.
- Add `InAppNotificationReceiverCompat` to enable Java classes to consume the `InAppNotificationReceiver` without requiring RxJava or other `Flow` converters.

Difference between #9245 and this PR:
- Dropped the usage of `BroadcastReceiver` in favour of `InAppNotificationEventBus`
- "Show" in-app notification within the Debug secret screen via the "Notification status log" section.


https://github.com/user-attachments/assets/e1dbbfa5-e059-4135-8062-16dc2d2a5ad7




Test command: ./gradlew :feature:notification:api:testDebugUnitTest --tests "net.thunderbird.feature.notification.api.receiver.compat.InAppNotificationReceiverCompatTest" :feature:notification:impl:testDebugUnitTest --tests "net.thunderbird.feature.notification.impl.command.InAppNotificationCommandTest" --tests "net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifierTest"

Fix patch:
diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt
index 3a8145fed7f..3040ad5bdb9 100644
--- a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt
+++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt
@@ -7,6 +7,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
 import app.k9mail.core.ui.compose.common.koin.koinPreview
 import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.flow.flowOf
 import net.thunderbird.core.common.resources.StringsResourceManager
 import net.thunderbird.core.outcome.Outcome
@@ -16,6 +17,8 @@ import net.thunderbird.feature.mail.account.api.BaseAccount
 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.receiver.InAppNotificationEvent
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
 import net.thunderbird.feature.notification.api.sender.NotificationSender
 
 @PreviewLightDark
@@ -47,6 +50,10 @@ private fun SecretDebugSettingsScreenPreview() {
                     ): Flow<Outcome<Success<Notification>, Failure<Notification>>> =
                         error("not implemented")
                 },
+                notificationReceiver = object : InAppNotificationReceiver {
+                    override val events: SharedFlow<InAppNotificationEvent>
+                        get() = error("not implemented")
+                },
             )
         }
     } WithContent {
diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt
index d0a1dcbf52a..84da33cb522 100644
--- a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt
+++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt
@@ -13,6 +13,7 @@ val featureDebugSettingsModule = module {
             stringsResourceManager = get(),
             accountManager = get(),
             notificationSender = get(),
+            notificationReceiver = get(),
         )
     }
 }
diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt
index 867d0aeb9b5..b3554185cab 100644
--- a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt
+++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt
@@ -8,6 +8,7 @@ import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toPersistentList
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import net.thunderbird.core.common.resources.StringsResourceManager
@@ -27,12 +28,14 @@ import net.thunderbird.feature.notification.api.content.MailNotification
 import net.thunderbird.feature.notification.api.content.Notification
 import net.thunderbird.feature.notification.api.content.PushServiceNotification
 import net.thunderbird.feature.notification.api.content.SystemNotification
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
 import net.thunderbird.feature.notification.api.sender.NotificationSender
 
 internal class DebugNotificationSectionViewModel(
     private val stringsResourceManager: StringsResourceManager,
     private val accountManager: AccountManager<BaseAccount>,
     private val notificationSender: NotificationSender,
+    private val notificationReceiver: InAppNotificationReceiver,
     private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
     ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
 ) : BaseViewModel<State, Event, Effect>(initialState = State()), DebugNotificationSectionContract.ViewModel {
@@ -76,6 +79,18 @@ internal class DebugNotificationSectionViewModel(
                 }
             }
         }
+
+        viewModelScope.launch {
+            notificationReceiver
+                .events
+                .collectLatest { event ->
+                    updateState { state ->
+                        state.copy(
+                            notificationStatusLog = state.notificationStatusLog + " In-app notification event: $event",
+                        )
+                    }
+                }
+        }
     }
 
     override fun event(event: Event) {
diff --git a/feature/notification/api/build.gradle.kts b/feature/notification/api/build.gradle.kts
index d7aba818af2..01fac014062 100644
--- a/feature/notification/api/build.gradle.kts
+++ b/feature/notification/api/build.gradle.kts
@@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.internal.config.LanguageFeature
 
 plugins {
     id(ThunderbirdPlugins.Library.kmpCompose)
+    alias(libs.plugins.dev.mokkery)
 }
 
 kotlin {
diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt
new file mode 100644
index 00000000000..0f021255f7c
--- /dev/null
+++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt
@@ -0,0 +1,19 @@
+package net.thunderbird.feature.notification.api.receiver
+
+import kotlinx.coroutines.flow.SharedFlow
+import net.thunderbird.feature.notification.api.content.InAppNotification
+
+/**
+ * Interface for receiving in-app notification events.
+ *
+ * This interface provides a [SharedFlow] of [InAppNotificationEvent]s that can be observed
+ * by UI components or other parts of the application to react to in-app notifications.
+ */
+interface InAppNotificationReceiver {
+    val events: SharedFlow<InAppNotificationEvent>
+}
+
+sealed interface InAppNotificationEvent {
+    data class Show(val notification: InAppNotification) : InAppNotificationEvent
+    data class Dismiss(val notification: InAppNotification) : InAppNotificationEvent
+}
diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompat.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompat.kt
new file mode 100644
index 00000000000..52b31d13fca
--- /dev/null
+++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompat.kt
@@ -0,0 +1,46 @@
+package net.thunderbird.feature.notification.api.receiver.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.launch
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
+
+/**
+ * A compatibility class for [InAppNotificationReceiver] that allows Java classes to observe notification events.
+ *
+ * This class is discouraged for use in Kotlin code. Use [InAppNotificationReceiver] directly instead.
+ *
+ * @param notificationReceiver The [InAppNotificationReceiver] instance to delegate to.
+ * @param listener A callback function that will be invoked when a new [InAppNotificationEvent] is received.
+ * @param mainImmediateDispatcher The [CoroutineDispatcher] to use for observing events.
+ */
+@Discouraged("Only for usage within a Java class. Use InAppNotificationReceiver instead.")
+class InAppNotificationReceiverCompat(
+    private val notificationReceiver: InAppNotificationReceiver,
+    listener: OnReceiveEventListener,
+    mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
+) : InAppNotificationReceiver by notificationReceiver, DisposableHandle {
+    private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher)
+
+    init {
+        scope.launch {
+            events.collect { event ->
+                listener.onReceiveEvent(event)
+            }
+        }
+    }
+
+    override fun dispose() {
+        scope.cancel()
+    }
+
+    fun interface OnReceiveEventListener {
+        fun onReceiveEvent(event: InAppNotificationEvent)
+    }
+}
diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt
index 1c2d23a5d34..207c19d6f33 100644
--- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt
+++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt
@@ -1,11 +1,18 @@
 package net.thunderbird.feature.notification.impl.command
 
+import net.thunderbird.core.featureflag.FeatureFlagKey
+import net.thunderbird.core.featureflag.FeatureFlagProvider
+import net.thunderbird.core.featureflag.FeatureFlagResult
 import net.thunderbird.core.logging.Logger
 import net.thunderbird.core.outcome.Outcome
+import net.thunderbird.feature.notification.api.NotificationRegistry
 import net.thunderbird.feature.notification.api.command.NotificationCommand
+import net.thunderbird.feature.notification.api.command.NotificationCommandException
 import net.thunderbird.feature.notification.api.content.InAppNotification
 import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
 
+private const val TAG = "InAppNotificationCommand"
+
 /**
  * A command that handles in-app notifications.
  *
@@ -16,13 +23,42 @@ import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
  */
 internal class InAppNotificationCommand(
     private val logger: Logger,
+    private val featureFlagProvider: FeatureFlagProvider,
+    private val notificationRegistry: NotificationRegistry,
     notification: InAppNotification,
     notifier: NotificationNotifier<InAppNotification>,
 ) : NotificationCommand<InAppNotification>(notification, notifier) {
+    private val isFeatureFlagEnabled: Boolean
+        get() = featureFlagProvider
+            .provide(FeatureFlagKey.DisplayInAppNotifications) == FeatureFlagResult.Enabled
+
     override suspend fun execute(): Outcome<Success<InAppNotification>, Failure<InAppNotification>> {
-        logger.debug {
-            "TODO: Implementation on GitHub Issue #9245. Notification = $notification."
+        logger.debug(TAG) { "execute() called with: notification = $notification" }
+        return when {
+            isFeatureFlagEnabled.not() ->
+                Outcome.failure(
+                    error = Failure(
+                        command = this,
+                        throwable = NotificationCommandException(
+                            message = "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled",
+                        ),
+                    ),
+                )
+
+            canExecuteCommand() -> {
+                notifier.show(id = notificationRegistry.register(notification), notification = notification)
+                Outcome.success(Success(command = this))
+            }
+
+            else -> {
+                Outcome.failure(Failure(command = this, throwable = Exception("Can't execute command.")))
+            }
         }
-        return Outcome.success(data = Success(command = this))
     }
+
+    // TODO(#9392): Verify if the app is on foreground. IF it isn't, then should fail
+    //  executing the command
+    // TODO(#9420): If the app is on background and the severity is Fatal or Critical, we should
+    //  let the command execute, but store it in a database instead of triggering the show notification logic.
+    private fun canExecuteCommand(): Boolean = true
 }
diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt
index dc0cc830222..e9d43664ef0 100644
--- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt
+++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt
@@ -8,7 +8,6 @@ import net.thunderbird.feature.notification.api.content.InAppNotification
 import net.thunderbird.feature.notification.api.content.Notification
 import net.thunderbird.feature.notification.api.content.SystemNotification
 import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
-import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier
 
 /**
  * A factory for creating a set of notification commands based on a given notification.
@@ -18,7 +17,7 @@ internal class NotificationCommandFactory(
     private val featureFlagProvider: FeatureFlagProvider,
     private val notificationRegistry: NotificationRegistry,
     private val systemNotificationNotifier: NotificationNotifier<SystemNotification>,
-    private val inAppNotificationNotifier: InAppNotificationNotifier,
+    private val inAppNotificationNotifier: NotificationNotifier<InAppNotification>,
 ) {
     /**
      * Creates a set of [NotificationCommand]s for the given [notification].
@@ -47,6 +46,8 @@ internal class NotificationCommandFactory(
             commands.add(
                 InAppNotificationCommand(
                     logger = logger,
+                    featureFlagProvider = featureFlagProvider,
+                    notificationRegistry = notificationRegistry,
                     notification = notification,
                     notifier = inAppNotificationNotifier,
                 ),
diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt
index 4c2ed01619d..f40587fca4c 100644
--- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt
+++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt
@@ -1,14 +1,19 @@
 package net.thunderbird.feature.notification.impl.inject
 
 import net.thunderbird.feature.notification.api.NotificationRegistry
+import net.thunderbird.feature.notification.api.content.InAppNotification
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
+import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
 import net.thunderbird.feature.notification.api.sender.NotificationSender
 import net.thunderbird.feature.notification.impl.DefaultNotificationRegistry
 import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory
+import net.thunderbird.feature.notification.impl.receiver.InAppNotificationEventBus
 import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier
 import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier
 import net.thunderbird.feature.notification.impl.sender.DefaultNotificationSender
 import org.koin.core.module.Module
 import org.koin.core.qualifier.named
+import org.koin.dsl.bind
 import org.koin.dsl.module
 
 internal expect val platformFeatureNotificationModule: Module
@@ -16,15 +21,26 @@ internal expect val platformFeatureNotificationModule: Module
 val featureNotificationModule = module {
     includes(platformFeatureNotificationModule)
 
-    factory { InAppNotificationNotifier() }
+    single<NotificationRegistry> { DefaultNotificationRegistry() }
+
+    single { InAppNotificationEventBus() }
+        .bind(InAppNotificationReceiver::class)
+
+    single<NotificationNotifier<InAppNotification>>(named<InAppNotificationNotifier>()) {
+        InAppNotificationNotifier(
+            logger = get(),
+            notificationRegistry = get(),
+            inAppNotificationEventBus = get(),
+        )
+    }
 
     factory<NotificationCommandFactory> {
         NotificationCommandFactory(
             logger = get(),
-            notificationRegistry = get(),
             featureFlagProvider = get(),
+            notificationRegistry = get(),
             systemNotificationNotifier = get(named<SystemNotificationNotifier>()),
-            inAppNotificationNotifier = get(),
+            inAppNotificationNotifier = get(named<InAppNotificationNotifier>()),
         )
     }
 
@@ -33,6 +49,4 @@ val featureNotificationModule = module {
             commandFactory = get(),
         )
     }
-
-    single<NotificationRegistry> { DefaultNotificationRegistry() }
 }
diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationEventBus.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationEventBus.kt
new file mode 100644
index 00000000000..6f11de08f76
--- /dev/null
+++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationEventBus.kt
@@ -0,0 +1,31 @@
+package net.thunderbird.feature.notification.impl.receiver
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
+
+/**
+ * An event bus for in-app notifications.
+ *
+ * This interface extends [InAppNotificationReceiver] to allow listening for notification events,
+ * and adds a [publish] method to send new notification events.
+ */
+internal interface InAppNotificationEventBus : InAppNotificationReceiver {
+    /**
+     * Publishes an in-app notification event to the event bus.
+     *
+     * @param event The [InAppNotificationEvent] to be published.
+     */
+    suspend fun publish(event: InAppNotificationEvent)
+}
+
+internal fun InAppNotificationEventBus(): InAppNotificationEventBus = object : InAppNotificationEventBus {
+    private val _events = MutableSharedFlow<InAppNotificationEvent>(replay = 1)
+    override val events: SharedFlow<InAppNotificationEvent> = _events.asSharedFlow()
+
+    override suspend fun publish(event: InAppNotificationEvent) {
+        _events.emit(event)
+    }
+}
diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt
index 62185c42b04..41c7f2541f3 100644
--- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt
+++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt
@@ -1,22 +1,34 @@
 package net.thunderbird.feature.notification.impl.receiver
 
+import net.thunderbird.core.logging.Logger
 import net.thunderbird.feature.notification.api.NotificationId
+import net.thunderbird.feature.notification.api.NotificationRegistry
 import net.thunderbird.feature.notification.api.content.InAppNotification
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
 import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
 
+private const val TAG = "InAppNotificationNotifier"
+
 /**
  * This notifier is responsible for taking a [InAppNotification] data object and
  * presenting it to the user in a suitable way.
- *
- * **Note:** The current implementation is a placeholder and needs to be completed
- * as part of GitHub Issue #9245.
  */
-internal class InAppNotificationNotifier : NotificationNotifier<InAppNotification> {
+internal class InAppNotificationNotifier(
+    private val logger: Logger,
+    private val notificationRegistry: NotificationRegistry,
+    private val inAppNotificationEventBus: InAppNotificationEventBus,
+) : NotificationNotifier<InAppNotification> {
+
     override suspend fun show(id: NotificationId, notification: InAppNotification) {
-        TODO("Implementation on GitHub Issue #9245")
+        logger.debug(TAG) { "show() called with: id = $id, notification = $notification" }
+        if (notificationRegistry.registrar.containsKey(id)) {
+            inAppNotificationEventBus.publish(
+                event = InAppNotificationEvent.Show(notification),
+            )
+        }
     }
 
     override fun dispose() {
-        TODO("Implementation on GitHub Issue #9245")
+        logger.debug(TAG) { "dispose() called" }
     }
 }


Test patch:
diff --git a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt
new file mode 100644
index 00000000000..d281cfc78d1
--- /dev/null
+++ b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt
@@ -0,0 +1,86 @@
+package net.thunderbird.feature.notification.api.receiver.compat
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+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.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import net.thunderbird.feature.notification.api.NotificationSeverity
+import net.thunderbird.feature.notification.api.content.AppNotification
+import net.thunderbird.feature.notification.api.content.InAppNotification
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
+import net.thunderbird.feature.notification.api.receiver.compat.InAppNotificationReceiverCompat.OnReceiveEventListener
+import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
+
+class InAppNotificationReceiverCompatTest {
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun `onReceiveEvent should be triggered when an event is received into InAppNotificationReceiver`() = runTest {
+        // Arrange
+        val inAppNotificationReceiver = FakeInAppNotificationReceiver()
+        val eventsTriggered = mutableListOf<InAppNotificationEvent>()
+        val expectedEvents = List(size = 100) { index ->
+            val title = "notification $index"
+            when {
+                index % 2 == 0 -> {
+                    InAppNotificationEvent.Show(FakeNotification(title = title))
+                }
+
+                else -> InAppNotificationEvent.Dismiss(FakeNotification(title = title))
+            }
+        }.toTypedArray()
+        val onReceiveEventListener = spy<OnReceiveEventListener>(
+            OnReceiveEventListener { event ->
+                eventsTriggered += event
+            },
+        )
+        InAppNotificationReceiverCompat(
+            notificationReceiver = inAppNotificationReceiver,
+            listener = onReceiveEventListener,
+            mainImmediateDispatcher = UnconfinedTestDispatcher(),
+        )
+
+        // Act
+        expectedEvents.forEach { event -> inAppNotificationReceiver.trigger(event) }
+
+        // Assert
+        verify(exactly(100)) {
+            onReceiveEventListener.onReceiveEvent(event = any())
+        }
+        assertThat(eventsTriggered).containsExactlyInAnyOrder(elements = expectedEvents)
+    }
+
+    private class FakeInAppNotificationReceiver : InAppNotificationReceiver {
+        private val _events = MutableSharedFlow<InAppNotificationEvent>()
+        override val events: SharedFlow<InAppNotificationEvent> = _events.asSharedFlow()
+
+        suspend fun trigger(event: InAppNotificationEvent) {
+            _events.emit(event)
+        }
+    }
+
+    data class FakeNotification(
+        override val title: String = "fake title",
+        override val contentText: String? = "fake content",
+        override val severity: NotificationSeverity = NotificationSeverity.Information,
+        override val icon: NotificationIcon = NotificationIcon(
+            inAppNotificationIcon = ImageVector.Builder(
+                defaultWidth = 0.dp,
+                defaultHeight = 0.dp,
+                viewportWidth = 0f,
+                viewportHeight = 0f,
+            ).build(),
+        ),
+    ) : AppNotification(), InAppNotification
+}
diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommandTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommandTest.kt
new file mode 100644
index 00000000000..a98494d4740
--- /dev/null
+++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommandTest.kt
@@ -0,0 +1,141 @@
+package net.thunderbird.feature.notification.impl.command
+
+import assertk.all
+import assertk.assertThat
+import assertk.assertions.hasMessage
+import assertk.assertions.isEqualTo
+import assertk.assertions.isInstanceOf
+import assertk.assertions.prop
+import dev.mokkery.matcher.any
+import dev.mokkery.spy
+import dev.mokkery.verify.VerifyMode.Companion.exactly
+import dev.mokkery.verifySuspend
+import kotlin.test.Test
+import kotlinx.coroutines.test.runTest
+import net.thunderbird.core.featureflag.FeatureFlagKey
+import net.thunderbird.core.featureflag.FeatureFlagProvider
+import net.thunderbird.core.featureflag.FeatureFlagResult
+import net.thunderbird.core.logging.testing.TestLogger
+import net.thunderbird.core.outcome.Outcome
+import net.thunderbird.feature.notification.api.NotificationRegistry
+import net.thunderbird.feature.notification.api.NotificationSeverity
+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.InAppNotification
+import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
+import net.thunderbird.feature.notification.testing.fake.FakeNotification
+import net.thunderbird.feature.notification.testing.fake.FakeNotificationRegistry
+import net.thunderbird.feature.notification.testing.fake.receiver.FakeInAppNotificationNotifier
+
+@Suppress("MaxLineLength")
+class InAppNotificationCommandTest {
+    @Test
+    fun `execute should return Failure when display_in_app_notifications feature flag is Disabled`() =
+        runTest {
+            // Arrange
+            val testSubject = createTestSubject(
+                featureFlagProvider = { key ->
+                    when (key) {
+                        FeatureFlagKey.DisplayInAppNotifications -> FeatureFlagResult.Disabled
+                        else -> FeatureFlagResult.Enabled
+                    }
+                },
+            )
+
+            // Act
+            val outcome = testSubject.execute()
+
+            // Assert
+
+            assertThat(outcome)
+                .isInstanceOf<Outcome.Failure<Failure<InAppNotification>>>()
+                .prop("error") { it.error }
+                .all {
+                    prop(Failure<InAppNotification>::command)
+                        .isEqualTo(testSubject)
+                    prop(Failure<InAppNotification>::throwable)
+                        .isInstanceOf<NotificationCommandException>()
+                        .hasMessage(
+                            "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled",
+                        )
+                }
+        }
+
+    @Test
+    fun `execute should return Failure when display_in_app_notifications feature flag is Unavailable`() =
+        runTest {
+            // Arrange
+            val testSubject = createTestSubject(
+                featureFlagProvider = { key ->
+                    when (key) {
+                        FeatureFlagKey.DisplayInAppNotifications -> FeatureFlagResult.Unavailable
+                        else -> FeatureFlagResult.Enabled
+                    }
+                },
+            )
+
+            // Act
+            val outcome = testSubject.execute()
+
+            // Assert
+            assertThat(outcome)
+                .isInstanceOf<Outcome.Failure<Failure<InAppNotification>>>()
+                .prop("error") { it.error }
+                .all {
+                    prop(Failure<InAppNotification>::command)
+                        .isEqualTo(testSubject)
+                    prop(Failure<InAppNotification>::throwable)
+                        .isInstanceOf<NotificationCommandException>()
+                        .hasMessage(
+                            "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled",
+                        )
+                }
+        }
+
+    @Test
+    fun `execute should return Success when display_in_app_notifications feature flag is Enabled`() =
+        runTest {
+            // Arrange
+            val notification = FakeNotification(
+                severity = NotificationSeverity.Information,
+            )
+            val notifier = spy(FakeInAppNotificationNotifier())
+            val testSubject = createTestSubject(
+                notification = notification,
+                notifier = notifier,
+            )
+
+            // Act
+            val outcome = testSubject.execute()
+
+            // Assert
+            assertThat(outcome)
+                .isInstanceOf<Outcome.Success<Success<InAppNotification>>>()
+                .prop("data") { it.data }
+                .all {
+                    prop(Success<InAppNotification>::command)
+                        .isEqualTo(testSubject)
+                }
+
+            verifySuspend(exactly(1)) {
+                notifier.show(id = any(), notification)
+            }
+        }
+
+    private fun createTestSubject(
+        notification: InAppNotification = FakeNotification(),
+        featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled },
+        notifier: NotificationNotifier<InAppNotification> = FakeInAppNotificationNotifier(),
+        notificationRegistry: NotificationRegistry = FakeNotificationRegistry(),
+    ): InAppNotificationCommand {
+        val logger = TestLogger()
+        return InAppNotificationCommand(
+            logger = logger,
+            featureFlagProvider = featureFlagProvider,
+            notificationRegistry = notificationRegistry,
+            notification = notification,
+            notifier = notifier,
+        )
+    }
+}
diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifierTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifierTest.kt
new file mode 100644
index 00000000000..62448fec9ae
--- /dev/null
+++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifierTest.kt
@@ -0,0 +1,73 @@
+package net.thunderbird.feature.notification.impl.receiver
+
+import dev.mokkery.matcher.any
+import dev.mokkery.spy
+import dev.mokkery.verify.VerifyMode.Companion.exactly
+import dev.mokkery.verifySuspend
+import kotlin.test.Test
+import kotlinx.coroutines.test.runTest
+import net.thunderbird.core.logging.testing.TestLogger
+import net.thunderbird.feature.notification.api.NotificationId
+import net.thunderbird.feature.notification.api.NotificationRegistry
+import net.thunderbird.feature.notification.api.content.Notification
+import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
+
+class InAppNotificationNotifierTest {
+    @Test
+    fun `show should not publish event when notification is already present in NotificationRegistry`() = runTest {
+        // Arrange
+        val notificationId = NotificationId(value = 1)
+        val notification = FakeInAppOnlyNotification()
+        val registrar = mapOf(notificationId to notification)
+        val eventBus = spy(InAppNotificationEventBus())
+        val testSubject = createTestSubject(registrar, eventBus)
+
+        // Act
+        testSubject.show(notificationId, notification)
+
+        // Assert
+        verifySuspend(exactly(1)) {
+            eventBus.publish(any())
+        }
+    }
+
+    @Test
+    fun `show should publish event when notification is not present in NotificationRegistry`() = runTest {
+        // Arrange
+        val notificationId = NotificationId(value = Int.MAX_VALUE)
+        val notification = FakeInAppOnlyNotification()
+        val registrar = buildMap<NotificationId, Notification> {
+            repeat(times = 100) { index ->
+                put(NotificationId(index), FakeInAppOnlyNotification(title = "fake title $index"))
+            }
+        }
+        val eventBus = spy(InAppNotificationEventBus())
+        val testSubject = createTestSubject(registrar, eventBus)
+
+        // Act
+        testSubject.show(notificationId, notification)
+
+        // Assert
+        verifySuspend(exactly(0)) {
+            eventBus.publish(any())
+        }
+    }
+
+    private fun createTestSubject(
+        registrar: Map<NotificationId, Notification>,
+        eventBus: InAppNotificationEventBus,
+    ): InAppNotificationNotifier {
+        return InAppNotificationNotifier(
+            logger = TestLogger(),
+            notificationRegistry = object : NotificationRegistry {
+                override val registrar: Map<NotificationId, Notification> = registrar
+                override fun get(notificationId: NotificationId): Notification? = error("Not yet implemented")
+                override fun get(notification: Notification): NotificationId? = error("Not yet implemented")
+                override suspend fun register(notification: Notification): NotificationId = error("Not yet implemented")
+                override fun unregister(notificationId: NotificationId) = error("Not yet implemented")
+                override fun unregister(notification: Notification) = error("Not yet implemented")
+            },
+            inAppNotificationEventBus = eventBus,
+        )
+    }
+}
diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationRegistry.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationRegistry.kt
new file mode 100644
index 00000000000..e2ca648dd9f
--- /dev/null
+++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationRegistry.kt
@@ -0,0 +1,31 @@
+package net.thunderbird.feature.notification.testing.fake
+
+import kotlin.random.Random
+import net.thunderbird.feature.notification.api.NotificationId
+import net.thunderbird.feature.notification.api.NotificationRegistry
+import net.thunderbird.feature.notification.api.content.Notification
+
+open class FakeNotificationRegistry : NotificationRegistry {
+    override val registrar: Map<NotificationId, Notification>
+        get() = TODO("Not yet implemented")
+
+    override fun get(notificationId: NotificationId): Notification? {
+        TODO("Not yet implemented")
+    }
+
+    override fun get(notification: Notification): NotificationId? {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun register(notification: Notification): NotificationId {
+        return NotificationId(value = Random.Default.nextInt())
+    }
+
+    override fun unregister(notificationId: NotificationId) {
+        TODO("Not yet implemented")
+    }
+
+    override fun unregister(notification: Notification) {
+        TODO("Not yet implemented")
+    }
+}
diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeInAppNotificationNotifier.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeInAppNotificationNotifier.kt
new file mode 100644
index 00000000000..adcf427d7db
--- /dev/null
+++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeInAppNotificationNotifier.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.InAppNotification
+import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
+
+open class FakeInAppNotificationNotifier : NotificationNotifier<InAppNotification> {
+    override suspend fun show(
+        id: NotificationId,
+        notification: InAppNotification,
+    ) = Unit
+
+    override fun dispose() = Unit
+}