diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt index e55d54ac12b..71fd88ba2f8 100644 --- a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt +++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt @@ -7,9 +7,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.coroutineScope import app.k9mail.core.ui.compose.designsystem.template.Scaffold import kotlinx.collections.immutable.ImmutableSet +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver import net.thunderbird.feature.notification.api.ui.action.NotificationAction @@ -94,20 +97,22 @@ fun InAppNotificationHost( eventFilter: (InAppNotificationEvent) -> Boolean = { true }, content: @Composable (PaddingValues) -> Unit, ) { - val inAppNotificationEvents by koinInject() - .events - .collectAsStateWithLifecycle(initialValue = null) - + val receiver = koinInject() val state by hostStateHolder.currentInAppNotificationHostState.collectAsState() - LaunchedEffect(inAppNotificationEvents, eventFilter) { - val event = inAppNotificationEvents - if (event != null && eventFilter(event)) { - when (event) { - is InAppNotificationEvent.Dismiss -> Unit // TODO(#9626): Handle dismiss - is InAppNotificationEvent.Show -> hostStateHolder.showInAppNotification(event.notification) - } + LifecycleStartEffect(receiver, eventFilter) { + val job = lifecycle.coroutineScope.launch { + receiver + .events + .filter(eventFilter) + .collect { event -> + when (event) { + is InAppNotificationEvent.Dismiss -> hostStateHolder.dismiss(event.notification) + is InAppNotificationEvent.Show -> hostStateHolder.showInAppNotification(event.notification) + } + } } + onStopOrDispose { job.cancel() } } LaunchedEffect(state.snackbarVisual, onSnackbarNotificationEvent) { diff --git a/feature/notification/api/src/commonMain/composeResources/values/strings.xml b/feature/notification/api/src/commonMain/composeResources/values/strings.xml index 14fea1b75db..0be6954a6de 100644 --- a/feature/notification/api/src/commonMain/composeResources/values/strings.xml +++ b/feature/notification/api/src/commonMain/composeResources/values/strings.xml @@ -15,7 +15,8 @@ An error has occurred while trying to create a system notification for a new message. The reason is most likely a missing notification sound.\n\nTap to open notification settings. Authentication failed - Authentication failed for %1$s. Update your server settings. + Authentication failed for %1$s. Update your incoming server settings. + Authentication failed for %1$s. Update your outgoing server settings. Certificate error Certificate error for %1$s diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationManager.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationManager.kt new file mode 100644 index 00000000000..19503d19e94 --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationManager.kt @@ -0,0 +1,12 @@ +package net.thunderbird.feature.notification.api + +import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser +import net.thunderbird.feature.notification.api.sender.NotificationSender + +/** + * Manages sending and dismissing notifications. + * + * This interface combines the functionalities of [NotificationSender] and [NotificationDismisser] + * to provide a unified API for notification management. + */ +interface NotificationManager : NotificationSender, NotificationDismisser diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt index fd6777654fa..b38db391e0a 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt @@ -58,4 +58,20 @@ interface NotificationRegistry { * @param notification The [Notification] object to unregister. */ fun unregister(notification: Notification) + + /** + * Checks if a specific notification is currently registered. + * + * @param notification The [Notification] object to check. + * @return `true` if the notification is registered, `false` otherwise. + */ + operator fun contains(notification: Notification): Boolean + + /** + * Checks if a notification with the given [notificationId] is currently registered. + * + * @param notificationId The ID of the notification to check. + * @return `true` if the notification is registered, `false` otherwise. + */ + operator fun contains(notificationId: NotificationId): Boolean } diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommand.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommand.kt index b780c1e7fa4..6a748644437 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommand.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommand.kt @@ -1,6 +1,8 @@ package net.thunderbird.feature.notification.api.command +import androidx.annotation.Discouraged import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationId import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.receiver.NotificationNotifier @@ -33,11 +35,24 @@ abstract class NotificationCommand( * Represents a successful command execution. * * @param TNotification The type of notification associated with the command. + * @property notificationId The ID of the notification that was successfully acted upon. * @property command The command that was executed successfully. */ data class Success( + val notificationId: NotificationId, val command: NotificationCommand, - ) : CommandOutcome + ) : CommandOutcome { + companion object { + @Discouraged( + message = "This is a utility function to enable usage in Java code. " + + "Use Success(NotificationId, NotificationCommand) instead.", + ) + operator fun invoke( + notificationId: Int, + command: NotificationCommand<*>, + ): Success = Success(NotificationId(notificationId), command) + } + } /** * Represents a failed command execution. @@ -47,7 +62,7 @@ abstract class NotificationCommand( * @property throwable The exception that caused the failure. */ data class Failure( - val command: NotificationCommand, + val command: NotificationCommand?, val throwable: Throwable, ) : CommandOutcome } diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt index 606c161c88b..310fcaac7c3 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt @@ -8,8 +8,9 @@ import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyle import net.thunderbird.feature.notification.resources.api.Res -import net.thunderbird.feature.notification.resources.api.notification_authentication_error_text import net.thunderbird.feature.notification.resources.api.notification_authentication_error_title +import net.thunderbird.feature.notification.resources.api.notification_authentication_incoming_server_error_text +import net.thunderbird.feature.notification.resources.api.notification_authentication_outgoing_server_error_text import org.jetbrains.compose.resources.getString /** @@ -25,8 +26,8 @@ data class AuthenticationErrorNotification private constructor( override val title: String, override val contentText: String?, override val channel: NotificationChannel, - override val icon: NotificationIcon = NotificationIcons.AuthenticationError, ) : AppNotification(), SystemNotification, InAppNotification { + override val icon: NotificationIcon = NotificationIcons.AuthenticationError override val severity: NotificationSeverity = NotificationSeverity.Fatal override val actions: Set = buildSet { val action = if (isIncomingServerError) { @@ -63,7 +64,11 @@ data class AuthenticationErrorNotification private constructor( accountNumber = accountNumber, title = getString(resource = Res.string.notification_authentication_error_title), contentText = getString( - resource = Res.string.notification_authentication_error_text, + resource = if (isIncomingServerError) { + Res.string.notification_authentication_incoming_server_error_text + } else { + Res.string.notification_authentication_outgoing_server_error_text + }, accountDisplayName, ), channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid), diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/NotificationDismisser.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/NotificationDismisser.kt new file mode 100644 index 00000000000..b96a2b42e8d --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/NotificationDismisser.kt @@ -0,0 +1,32 @@ +package net.thunderbird.feature.notification.api.dismisser + +import kotlinx.coroutines.flow.Flow +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationId +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 + +/** + * Responsible for dismissing notifications by creating and executing the appropriate commands. + */ +interface NotificationDismisser { + /** + * Dismisses a notification with the given ID. + * + * @param id The ID of the notification to dismiss. + * @return A [Flow] of [Outcome] that emits either a [Success] with the dismissed [Notification] + * or a [Failure] with the [Notification] that failed to be dismissed. + */ + fun dismiss(id: NotificationId): Flow, Failure>> + + /** + * Dismisses a notification. + * + * @param notification The notification to dismiss. + * @return A [Flow] of [Outcome] that emits the result of the dismiss operation. + * The [Outcome] will be a [Success] containing the dismissed [Notification] if the operation was successful, + * or a [Failure] containing the [Notification] if the operation failed. + */ + fun dismiss(notification: Notification): Flow, Failure>> +} diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/compat/NotificationDismisserCompat.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/compat/NotificationDismisserCompat.kt new file mode 100644 index 00000000000..7d1ba13cb97 --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/dismisser/compat/NotificationDismisserCompat.kt @@ -0,0 +1,50 @@ +package net.thunderbird.feature.notification.api.dismisser.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.dismisser.NotificationDismisser + +/** + * A compatibility layer for dismissing notifications from Java code. + * + * This class wraps [NotificationDismisser] 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 [NotificationDismisser] directly. + * + * @property notificationDismisser The underlying [NotificationDismisser] instance. + * @property mainImmediateDispatcher The [CoroutineDispatcher] used for launching coroutines. + */ +@Discouraged("Only for usage within a Java class. Use NotificationDismisser instead.") +class NotificationDismisserCompat @JvmOverloads constructor( + private val notificationDismisser: NotificationDismisser, + mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, +) : DisposableHandle { + private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher) + + fun dismiss(notification: Notification, onResultListener: OnResultListener) { + notificationDismisser.dismiss(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/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/NotificationNotifier.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/NotificationNotifier.kt index 427a383bff3..5897296f990 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/NotificationNotifier.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/NotificationNotifier.kt @@ -4,21 +4,40 @@ import net.thunderbird.feature.notification.api.NotificationId import net.thunderbird.feature.notification.api.content.Notification /** - * Interface for displaying notifications. + * Abstraction for components that present and manage a specific kind of notification. * - * This is a sealed interface, meaning that all implementations must be declared in this file. + * Implementations are responsible for rendering notifications (e.g., system tray notifications, + * in-app notifications) and for dismissing them when requested. The generic type parameter + * allows an implementation to declare which [Notification] sub-type it can handle. * - * @param TNotification The type of notification to display. + * Type parameters: + * @param TNotification The specific subtype of [Notification] this notifier can display. The + * contravariant `in` variance allows a notifier for a base type to also accept its subtypes. */ interface NotificationNotifier { /** - * Shows a notification to the user. + * Displays or updates a notification associated with the given [id]. * - * @param id The notification id. Mostly used by System Notifications. - * @param notification The notification to show. + * Implementations should render [notification] according to their medium. If a notification + * with the same [id] is already visible, this call should update/replace it when supported + * by the underlying mechanism. + * + * @param id A stable identifier that correlates to this notification instance across updates + * and dismissal. Often maps to a system notification ID when using platform notifications. + * @param notification The domain model describing what to present to the user. */ suspend fun show(id: NotificationId, notification: TNotification) + /** + * Dismisses the notification previously shown with [id]. + * + * If no notification is currently displayed for [id], implementations should treat this as a + * no-op. + * + * @param id The identifier of the notification to dismiss. + */ + suspend fun dismiss(id: NotificationId) + /** * Disposes of any resources used by the notifier. * diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt index 0505063ae40..7799c98f356 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt @@ -82,6 +82,21 @@ class InAppNotificationHostStateHolder(private val enabled: ImmutableSet, Failure>>( - Outcome.success(Success(FakeInAppNotificationCommand())), - Outcome.success(Success(FakeSystemNotificationCommand())), + Outcome.success(Success(NotificationId(1), FakeInAppNotificationCommand())), + Outcome.success(Success(NotificationId(2), FakeSystemNotificationCommand())), Outcome.failure( error = Failure( command = FakeSystemNotificationCommand(), 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 index 94c16247703..20677829366 100644 --- 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 @@ -53,8 +53,8 @@ public void send_shouldCallListenerCallback_wheneverAResultIsReceived() { ? extends @NotNull Failure > > expectedResults = List.of( - Outcome.Companion.success(new Success<>(new FakeInAppNotificationCommand())), - Outcome.Companion.success(new Success<>(new FakeSystemNotificationCommand())), + Outcome.Companion.success(Success.Companion.invoke(1, new FakeInAppNotificationCommand())), + Outcome.Companion.success(Success.Companion.invoke(2, new FakeSystemNotificationCommand())), Outcome.Companion.failure( new Failure<>( new FakeSystemNotificationCommand(), diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt index a36f33bc240..59dc59e9258 100644 --- a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt @@ -33,6 +33,11 @@ internal class AndroidSystemNotificationNotifier( notificationManager.notify(id.value, androidNotification) } + override suspend fun dismiss(id: NotificationId) { + logger.debug(TAG) { "dismiss() called with: id = $id" } + notificationManager.cancel(id.value) + } + override fun dispose() { logger.debug(TAG) { "dispose() called" } } diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationManager.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationManager.kt new file mode 100644 index 00000000000..25f77dd1bf1 --- /dev/null +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationManager.kt @@ -0,0 +1,19 @@ +package net.thunderbird.feature.notification.impl + +import net.thunderbird.feature.notification.api.NotificationManager +import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser +import net.thunderbird.feature.notification.api.sender.NotificationSender + +/** + * Default implementation of [NotificationManager]. + * + * This class acts as a central point for managing notifications, delegating sending and dismissing + * operations to the provided [NotificationSender] and [NotificationDismisser] respectively. + * + * @param notificationSender The [NotificationSender] responsible for displaying notifications. + * @param notificationDismisser The [NotificationDismisser] responsible for removing notifications. + */ +class DefaultNotificationManager( + private val notificationSender: NotificationSender, + private val notificationDismisser: NotificationDismisser, +) : NotificationManager, NotificationSender by notificationSender, NotificationDismisser by notificationDismisser diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistry.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistry.kt index cbdd3294fdb..3d457ea3a88 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistry.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/DefaultNotificationRegistry.kt @@ -57,4 +57,12 @@ class DefaultNotificationRegistry : NotificationRegistry { override fun unregister(notification: Notification) { _registrar.remove(notification) } + + override fun contains(notification: Notification): Boolean { + return _registrar.containsKey(notification) + } + + override fun contains(notificationId: NotificationId): Boolean { + return _registrar.containsValue(notificationId) + } } diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissInAppNotificationCommand.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissInAppNotificationCommand.kt new file mode 100644 index 00000000000..ab7129016e1 --- /dev/null +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissInAppNotificationCommand.kt @@ -0,0 +1,27 @@ +package net.thunderbird.feature.notification.impl.command + +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier + +private const val TAG = "DismissInAppNotificationCommand" + +class DismissInAppNotificationCommand( + logger: Logger, + featureFlagProvider: FeatureFlagProvider, + notificationRegistry: NotificationRegistry, + notification: InAppNotification, + notifier: NotificationNotifier, +) : DismissNotificationCommand( + logTag = TAG, + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = notifier, +) { + override val featureFlagKey: FeatureFlagKey = FeatureFlagKey.DisplayInAppNotifications +} diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissNotificationCommand.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissNotificationCommand.kt new file mode 100644 index 00000000000..b9f2331f4cc --- /dev/null +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissNotificationCommand.kt @@ -0,0 +1,76 @@ +package net.thunderbird.feature.notification.impl.command + +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.logging.LogTag +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.Notification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier + +/** + * A command that dismisses a notification. + * + * This command will only be executed if the [featureFlagKey] is enabled. + * + * @param TNotification The type of notification to dismiss. + * @property logTag The log tag to use for logging. + * @property logger The logger to use for logging. + * @property featureFlagProvider The provider for feature flags. + * @property notificationRegistry The registry of notifications. + * @param notification The notification to dismiss. + * @param notifier The notifier to use to dismiss the notification. + */ +sealed class DismissNotificationCommand( + private val logTag: LogTag, + private val logger: Logger, + private val featureFlagProvider: FeatureFlagProvider, + private val notificationRegistry: NotificationRegistry, + notification: TNotification, + notifier: NotificationNotifier, +) : NotificationCommand( + notification = notification, + notifier = notifier, +) { + abstract val featureFlagKey: FeatureFlagKey + + override suspend fun execute(): Outcome, Failure> { + logger.verbose(logTag) { "execute() called with: notification = $notification" } + return when { + featureFlagProvider.provide(featureFlagKey).isDisabledOrUnavailable() -> + Outcome.failure( + error = Failure( + command = this, + throwable = NotificationCommandException( + message = "${featureFlagKey.key} feature flag is not enabled", + ), + ), + ) + + notification in notificationRegistry -> { + val id = checkNotNull(notificationRegistry[notification]) { + "Unexcepted state when trying to dismiss a notification. " + + "The required notification was not found in registry." + + "This might have been caused by a concurrent modification of the registry." + + "Please report this issue." + + "Notification = $notification" + + "Registrar = ${notificationRegistry.registrar}" + } + notifier.dismiss(id) + Outcome.success(Success(notificationId = id, command = this)) + } + + else -> { + Outcome.failure( + Failure( + command = this, + throwable = NotificationCommandException("Can't execute command."), + ), + ) + } + } + } +} diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissSystemNotificationCommand.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissSystemNotificationCommand.kt new file mode 100644 index 00000000000..a9d14304c44 --- /dev/null +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DismissSystemNotificationCommand.kt @@ -0,0 +1,27 @@ +package net.thunderbird.feature.notification.impl.command + +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier + +private const val TAG = "DismissSystemNotificationCommand" + +class DismissSystemNotificationCommand( + private val logger: Logger, + private val featureFlagProvider: FeatureFlagProvider, + private val notificationRegistry: NotificationRegistry, + notification: SystemNotification, + notifier: NotificationNotifier, +) : DismissNotificationCommand( + logTag = TAG, + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = notifier, +) { + override val featureFlagKey: FeatureFlagKey = FeatureFlagKey.UseNotificationSenderForSystemNotifications +} 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/DisplayInAppNotificationCommand.kt similarity index 89% rename from feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt rename to feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DisplayInAppNotificationCommand.kt index 207c19d6f33..e9480580a06 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/DisplayInAppNotificationCommand.kt @@ -11,7 +11,7 @@ import net.thunderbird.feature.notification.api.command.NotificationCommandExcep import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.receiver.NotificationNotifier -private const val TAG = "InAppNotificationCommand" +private const val TAG = "DisplayInAppNotificationCommand" /** * A command that handles in-app notifications. @@ -21,7 +21,7 @@ private const val TAG = "InAppNotificationCommand" * @param notification The [InAppNotification] to be handled. * @param notifier The [NotificationNotifier] responsible for actually displaying the notification. */ -internal class InAppNotificationCommand( +internal class DisplayInAppNotificationCommand( private val logger: Logger, private val featureFlagProvider: FeatureFlagProvider, private val notificationRegistry: NotificationRegistry, @@ -46,8 +46,9 @@ internal class InAppNotificationCommand( ) canExecuteCommand() -> { - notifier.show(id = notificationRegistry.register(notification), notification = notification) - Outcome.success(Success(command = this)) + val id = notificationRegistry.register(notification) + notifier.show(id = id, notification = notification) + Outcome.success(Success(notificationId = id, command = this)) } else -> { diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommand.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DisplaySystemNotificationCommand.kt similarity index 90% rename from feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommand.kt rename to feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DisplaySystemNotificationCommand.kt index 630d473278a..9f9be069842 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommand.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/DisplaySystemNotificationCommand.kt @@ -13,7 +13,7 @@ import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.content.SystemNotification import net.thunderbird.feature.notification.api.receiver.NotificationNotifier -private const val TAG = "SystemNotificationCommand" +private const val TAG = "DisplaySystemNotificationCommand" /** * Command for displaying system notifications. @@ -21,7 +21,7 @@ private const val TAG = "SystemNotificationCommand" * @param notification The system notification to display. * @param notifier The notifier responsible for displaying the notification. */ -internal class SystemNotificationCommand( +internal class DisplaySystemNotificationCommand( private val logger: Logger, private val featureFlagProvider: FeatureFlagProvider, private val notificationRegistry: NotificationRegistry, @@ -52,11 +52,9 @@ internal class SystemNotificationCommand( ) canExecuteCommand() -> { - notifier.show( - id = notificationRegistry.register(notification), - notification = notification, - ) - Outcome.success(Success(command = this)) + val id = notificationRegistry.register(notification) + notifier.show(id = id, notification = notification) + Outcome.success(Success(notificationId = id, command = this)) } else -> { 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 deleted file mode 100644 index e9d43664ef0..00000000000 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt +++ /dev/null @@ -1,59 +0,0 @@ -package net.thunderbird.feature.notification.impl.command - -import net.thunderbird.core.featureflag.FeatureFlagProvider -import net.thunderbird.core.logging.Logger -import net.thunderbird.feature.notification.api.NotificationRegistry -import net.thunderbird.feature.notification.api.command.NotificationCommand -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 - -/** - * A factory for creating a set of notification commands based on a given notification. - */ -internal class NotificationCommandFactory( - private val logger: Logger, - private val featureFlagProvider: FeatureFlagProvider, - private val notificationRegistry: NotificationRegistry, - private val systemNotificationNotifier: NotificationNotifier, - private val inAppNotificationNotifier: NotificationNotifier, -) { - /** - * Creates a set of [NotificationCommand]s for the given [notification]. - * - * The commands are returned in a [LinkedHashSet] to preserve the order in which they should be executed. - * - * @param notification The notification for which to create commands. - * @return A set of notification commands. - */ - fun create(notification: Notification): LinkedHashSet> { - val commands = linkedSetOf>() - - if (notification is SystemNotification) { - commands.add( - SystemNotificationCommand( - logger = logger, - featureFlagProvider = featureFlagProvider, - notificationRegistry = notificationRegistry, - notification = notification, - notifier = systemNotificationNotifier, - ), - ) - } - - if (notification is InAppNotification) { - commands.add( - InAppNotificationCommand( - logger = logger, - featureFlagProvider = featureFlagProvider, - notificationRegistry = notificationRegistry, - notification = notification, - notifier = inAppNotificationNotifier, - ), - ) - } - - return commands - } -} diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/dismisser/DefaultNotificationDismisser.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/dismisser/DefaultNotificationDismisser.kt new file mode 100644 index 00000000000..0192b33e79f --- /dev/null +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/dismisser/DefaultNotificationDismisser.kt @@ -0,0 +1,124 @@ +package net.thunderbird.feature.notification.impl.dismisser + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.command.NotificationCommand +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.content.Notification +import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +import net.thunderbird.feature.notification.impl.command.DismissInAppNotificationCommand +import net.thunderbird.feature.notification.impl.command.DismissSystemNotificationCommand + +private const val TAG = "DefaultNotificationDismisser" + +/** + * Responsible for dismissing notifications by creating and executing the appropriate commands. + * + * This class determines the type of the incoming [Notification] and constructs + * the relevant [NotificationCommand]s (e.g., [DismissSystemNotificationCommand] for [SystemNotification], + * [DismissInAppNotificationCommand] for [InAppNotification]). It then executes each command + * and emits the result of the execution as a [Flow]. + * + * @param logger The logger instance for logging events. + * @param featureFlagProvider Provider for accessing feature flag states. + * @param notificationRegistry Registry for managing notifications. + * @param systemNotificationNotifier Notifier specifically for system notifications. + * @param inAppNotificationNotifier Notifier specifically for in-app notifications. + */ +class DefaultNotificationDismisser internal constructor( + private val logger: Logger, + private val featureFlagProvider: FeatureFlagProvider, + private val notificationRegistry: NotificationRegistry, + private val systemNotificationNotifier: NotificationNotifier, + private val inAppNotificationNotifier: NotificationNotifier, +) : NotificationDismisser { + override fun dismiss(id: NotificationId): Flow, Failure>> = flow { + logger.verbose(TAG) { "dismiss() called with: id = $id" } + val notification = notificationRegistry[id] + if (notification == null) { + emit( + value = Outcome.failure( + error = Failure( + command = null, + throwable = NotificationCommandException(message = "Notification with id '$id' not found"), + ), + ), + ) + } else { + emitAll(dismiss(notification)) + } + } + + override fun dismiss(notification: Notification): Flow, Failure>> = + flow { + logger.verbose(TAG) { "dismiss() called with: notification = $notification" } + + if (notification in notificationRegistry) { + val commands = buildCommands(notification) + commands + .ifEmpty { + val message = "The notification is present in the registrar; " + + "however no commands where found to execute for notification $notification" + logger.warn { message } + emit( + Outcome.Failure( + Failure( + command = null, + throwable = NotificationCommandException(message), + ), + ), + ) + emptyList() + } + .forEach { command -> emit(command.execute()) } + } else { + emit( + value = Outcome.failure( + error = Failure( + command = null, + throwable = NotificationCommandException( + message = "Can't dismiss notification that is already dismissed", + ), + ), + ), + ) + } + } + + private fun buildCommands(notification: Notification): List> = buildList { + if (notification is SystemNotification) { + add( + DismissSystemNotificationCommand( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = systemNotificationNotifier, + ), + ) + } + + if (notification is InAppNotification) { + add( + DismissInAppNotificationCommand( + 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 f40587fca4c..cdd9f53e917 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,12 +1,15 @@ package net.thunderbird.feature.notification.impl.inject +import net.thunderbird.feature.notification.api.NotificationManager import net.thunderbird.feature.notification.api.NotificationRegistry import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser 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.DefaultNotificationManager import net.thunderbird.feature.notification.impl.DefaultNotificationRegistry -import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory +import net.thunderbird.feature.notification.impl.dismisser.DefaultNotificationDismisser import net.thunderbird.feature.notification.impl.receiver.InAppNotificationEventBus import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier @@ -34,8 +37,8 @@ val featureNotificationModule = module { ) } - factory { - NotificationCommandFactory( + single { + DefaultNotificationSender( logger = get(), featureFlagProvider = get(), notificationRegistry = get(), @@ -44,9 +47,20 @@ val featureNotificationModule = module { ) } - single { - DefaultNotificationSender( - commandFactory = get(), + single { + DefaultNotificationDismisser( + logger = get(), + featureFlagProvider = get(), + notificationRegistry = get(), + systemNotificationNotifier = get(named()), + inAppNotificationNotifier = get(named()), + ) + } + + single { + DefaultNotificationManager( + notificationSender = get(), + notificationDismisser = get(), ) } } 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 index 6f11de08f76..65411c6f03b 100644 --- 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 @@ -22,7 +22,7 @@ internal interface InAppNotificationEventBus : InAppNotificationReceiver { } internal fun InAppNotificationEventBus(): InAppNotificationEventBus = object : InAppNotificationEventBus { - private val _events = MutableSharedFlow(replay = 1) + private val _events = MutableSharedFlow(replay = 32) override val events: SharedFlow = _events.asSharedFlow() override suspend fun publish(event: InAppNotificationEvent) { 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 41c7f2541f3..843ab92b805 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 @@ -20,15 +20,26 @@ internal class InAppNotificationNotifier( ) : NotificationNotifier { override suspend fun show(id: NotificationId, notification: InAppNotification) { - logger.debug(TAG) { "show() called with: id = $id, notification = $notification" } - if (notificationRegistry.registrar.containsKey(id)) { + logger.verbose(TAG) { "show() called with: id = $id, notification = $notification" } + if (id in notificationRegistry) { inAppNotificationEventBus.publish( event = InAppNotificationEvent.Show(notification), ) } } + override suspend fun dismiss(id: NotificationId) { + logger.verbose(TAG) { "dismiss() called with: id = $id" } + val notification = notificationRegistry[id] + if (notification != null && notification is InAppNotification) { + notificationRegistry.unregister(notification) + inAppNotificationEventBus.publish( + event = InAppNotificationEvent.Dismiss(notification), + ) + } + } + override fun dispose() { - logger.debug(TAG) { "dispose() called" } + logger.verbose(TAG) { "dispose() called" } } } diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/sender/DefaultNotificationSender.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/sender/DefaultNotificationSender.kt index a5806853f24..4bb1960655d 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/sender/DefaultNotificationSender.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/sender/DefaultNotificationSender.kt @@ -2,41 +2,86 @@ package net.thunderbird.feature.notification.impl.sender import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import net.thunderbird.core.featureflag.FeatureFlagProvider +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.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.content.Notification +import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier import net.thunderbird.feature.notification.api.sender.NotificationSender -import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory +import net.thunderbird.feature.notification.impl.command.DisplayInAppNotificationCommand +import net.thunderbird.feature.notification.impl.command.DisplaySystemNotificationCommand /** * Responsible for sending notifications by creating and executing the appropriate commands. * - * This class utilizes a [NotificationCommandFactory] to generate a list of - * [NotificationCommand]s based on the provided [Notification]. It then executes - * each command and emits the result of the execution as a [Flow]. + * This class determines the type of the incoming [Notification] and constructs + * the relevant [NotificationCommand]s (e.g., [DisplaySystemNotificationCommand] for [SystemNotification], + * [DisplayInAppNotificationCommand] for [InAppNotification]). It then executes each command + * and emits the result of the execution as a [Flow]. * - * @param commandFactory The factory used to create notification commands. + * @param logger The logger instance for logging events. + * @param featureFlagProvider Provider for accessing feature flag states. + * @param notificationRegistry Registry for managing notifications. + * @param systemNotificationNotifier Notifier specifically for system notifications. + * @param inAppNotificationNotifier Notifier specifically for in-app notifications. */ class DefaultNotificationSender internal constructor( - private val commandFactory: NotificationCommandFactory, + private val logger: Logger, + private val featureFlagProvider: FeatureFlagProvider, + private val notificationRegistry: NotificationRegistry, + private val systemNotificationNotifier: NotificationNotifier, + private val inAppNotificationNotifier: NotificationNotifier, ) : NotificationSender { - /** - * Sends a notification by creating and executing the appropriate commands. - * - * This function takes a [Notification] object, uses the [commandFactory] to generate - * a list of [NotificationCommand]s tailored to that notification, and then executes - * each command sequentially. The result of each command execution ([NotificationCommand.CommandOutcome]) - * is emitted as part of the returned [Flow]. - * - * @param notification The [Notification] to be sent. - * @return A [Flow] that emits the [NotificationCommand.CommandOutcome] for each executed command. - */ override fun send(notification: Notification): Flow, Failure>> = flow { - val commands = commandFactory.create(notification) - commands.forEach { command -> - emit(command.execute()) + val commands = buildCommands(notification) + + commands + .ifEmpty { + val message = "No commands to execute for notification $notification" + logger.warn { message } + emit( + Outcome.Failure( + Failure( + command = null, + throwable = NotificationCommandException(message), + ), + ), + ) + emptyList() + } + .forEach { command -> emit(command.execute()) } + } + + private fun buildCommands(notification: Notification): List> = buildList { + if (notification is SystemNotification) { + add( + DisplaySystemNotificationCommand( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = systemNotificationNotifier, + ), + ) + } + + if (notification is InAppNotification) { + add( + DisplayInAppNotificationCommand( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = inAppNotificationNotifier, + ), + ) } } } diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DismissInAppNotificationCommandTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DismissInAppNotificationCommandTest.kt new file mode 100644 index 00000000000..3bacfb2b50c --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DismissInAppNotificationCommandTest.kt @@ -0,0 +1,172 @@ +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.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 + +class DismissInAppNotificationCommandTest { + @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 + } + }, + notificationRegistry = FakeNotificationRegistry(), + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .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 + } + }, + notificationRegistry = FakeNotificationRegistry(), + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage( + "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled", + ) + } + } + + @Test + fun `execute should return Success when feature flag Enabled and notification registered`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = FakeNotificationRegistry().apply { + register(notification) + } + val notifier = spy(FakeInAppNotificationNotifier()) + val testSubject = createTestSubject( + notification = notification, + notifier = notifier, + notificationRegistry = registry, + ) + val expectedId = registry.getValue(notification) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("data") { it.data } + .all { + prop(Success::command) + .isEqualTo(testSubject) + prop(Success::notificationId) + .isEqualTo(expectedId) + } + + verifySuspend(exactly(1)) { notifier.dismiss(expectedId) } + } + + @Test + fun `execute should return Failure when notification not registered`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = FakeNotificationRegistry() // empty, not registered + val notifier = spy(FakeInAppNotificationNotifier()) + val testSubject = createTestSubject( + notification = notification, + notifier = notifier, + notificationRegistry = registry, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage("Can't execute command.") + } + + verifySuspend(exactly(0)) { notifier.dismiss(any()) } + } + + private fun createTestSubject( + notification: InAppNotification = FakeNotification(), + featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled }, + notifier: NotificationNotifier = FakeInAppNotificationNotifier(), + notificationRegistry: NotificationRegistry = FakeNotificationRegistry(), + ): DismissInAppNotificationCommand { + val logger = TestLogger() + return DismissInAppNotificationCommand( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = notifier, + ) + } +} diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DismissSystemNotificationCommandTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DismissSystemNotificationCommandTest.kt new file mode 100644 index 00000000000..5909db633f4 --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DismissSystemNotificationCommandTest.kt @@ -0,0 +1,173 @@ +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.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.SystemNotification +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.FakeSystemNotificationNotifier + +@Suppress("MaxLineLength") +class DismissSystemNotificationCommandTest { + @Test + fun `execute should return Failure when use_notification_sender_for_system_notifications feature flag is Disabled`() = + runTest { + // Arrange + val testSubject = createTestSubject( + featureFlagProvider = { key -> + when (key) { + FeatureFlagKey.UseNotificationSenderForSystemNotifications -> FeatureFlagResult.Disabled + else -> FeatureFlagResult.Enabled + } + }, + notificationRegistry = FakeNotificationRegistry(), + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage( + "${FeatureFlagKey.UseNotificationSenderForSystemNotifications.key} feature flag is not enabled", + ) + } + } + + @Test + fun `execute should return Failure when use_notification_sender_for_system_notifications feature flag is Unavailable`() = + runTest { + // Arrange + val testSubject = createTestSubject( + featureFlagProvider = { key -> + when (key) { + FeatureFlagKey.UseNotificationSenderForSystemNotifications -> FeatureFlagResult.Unavailable + else -> FeatureFlagResult.Enabled + } + }, + notificationRegistry = FakeNotificationRegistry(), + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage( + "${FeatureFlagKey.UseNotificationSenderForSystemNotifications.key} feature flag is not enabled", + ) + } + } + + @Test + fun `execute should return Success when feature flag Enabled and notification registered`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = FakeNotificationRegistry().apply { + register(notification) + } + val notifier = spy(FakeSystemNotificationNotifier()) + val testSubject = createTestSubject( + notification = notification, + notifier = notifier, + notificationRegistry = registry, + ) + val expectedId = registry.getValue(notification) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("data") { it.data } + .all { + prop(Success::command) + .isEqualTo(testSubject) + prop(Success::notificationId) + .isEqualTo(expectedId) + } + + verifySuspend(exactly(1)) { notifier.dismiss(expectedId) } + } + + @Test + fun `execute should return Failure when notification not registered`() = runTest { + // Arrange + val notification = FakeNotification() + val registry = FakeNotificationRegistry() + val notifier = spy(FakeSystemNotificationNotifier()) + val testSubject = createTestSubject( + notification = notification, + notifier = notifier, + notificationRegistry = registry, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage("Can't execute command.") + } + + verifySuspend(exactly(0)) { notifier.dismiss(any()) } + } + + private fun createTestSubject( + notification: SystemNotification = FakeNotification(), + featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled }, + notifier: NotificationNotifier = FakeSystemNotificationNotifier(), + notificationRegistry: NotificationRegistry = FakeNotificationRegistry(), + ): DismissSystemNotificationCommand { + val logger = TestLogger() + return DismissSystemNotificationCommand( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = notifier, + ) + } +} 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/DisplayInAppNotificationCommandTest.kt similarity index 93% rename from feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommandTest.kt rename to feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DisplayInAppNotificationCommandTest.kt index a98494d4740..f10e8deb23a 100644 --- 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/DisplayInAppNotificationCommandTest.kt @@ -29,7 +29,7 @@ import net.thunderbird.feature.notification.testing.fake.FakeNotificationRegistr import net.thunderbird.feature.notification.testing.fake.receiver.FakeInAppNotificationNotifier @Suppress("MaxLineLength") -class InAppNotificationCommandTest { +class DisplayInAppNotificationCommandTest { @Test fun `execute should return Failure when display_in_app_notifications feature flag is Disabled`() = runTest { @@ -101,9 +101,11 @@ class InAppNotificationCommandTest { severity = NotificationSeverity.Information, ) val notifier = spy(FakeInAppNotificationNotifier()) + val notificationRegistry = FakeNotificationRegistry() val testSubject = createTestSubject( notification = notification, notifier = notifier, + notificationRegistry = notificationRegistry, ) // Act @@ -116,6 +118,8 @@ class InAppNotificationCommandTest { .all { prop(Success::command) .isEqualTo(testSubject) + prop(Success::notificationId) + .isEqualTo(notificationRegistry.getValue(notification)) } verifySuspend(exactly(1)) { @@ -128,9 +132,9 @@ class InAppNotificationCommandTest { featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled }, notifier: NotificationNotifier = FakeInAppNotificationNotifier(), notificationRegistry: NotificationRegistry = FakeNotificationRegistry(), - ): InAppNotificationCommand { + ): DisplayInAppNotificationCommand { val logger = TestLogger() - return InAppNotificationCommand( + return DisplayInAppNotificationCommand( logger = logger, featureFlagProvider = featureFlagProvider, notificationRegistry = notificationRegistry, diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommandTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DisplaySystemNotificationCommandTest.kt similarity index 88% rename from feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommandTest.kt rename to feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DisplaySystemNotificationCommandTest.kt index 164b4f5d745..617bb281f2c 100644 --- a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/SystemNotificationCommandTest.kt +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/DisplaySystemNotificationCommandTest.kt @@ -10,7 +10,6 @@ import dev.mokkery.matcher.any import dev.mokkery.spy import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend -import kotlin.random.Random import kotlin.test.Test import kotlinx.coroutines.test.runTest import net.thunderbird.core.featureflag.FeatureFlagKey @@ -24,14 +23,14 @@ 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.Notification import net.thunderbird.feature.notification.api.content.SystemNotification 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.FakeSystemOnlyNotification @Suppress("MaxLineLength") -class SystemNotificationCommandTest { +class DisplaySystemNotificationCommandTest { @Test fun `execute should return Failure when use_notification_sender_for_system_notifications feature flag is Disabled`() = runTest { @@ -134,11 +133,13 @@ class SystemNotificationCommandTest { severity = NotificationSeverity.Information, ) val notifier = spy(FakeNotifier()) + val notificationRegistry = FakeNotificationRegistry() val testSubject = createTestSubject( notification = notification, // TODO(#9391): Verify if the app is backgrounded. isAppInBackground = { true }, notifier = notifier, + notificationRegistry = notificationRegistry, ) // Act @@ -151,6 +152,8 @@ class SystemNotificationCommandTest { .all { prop(Success::command) .isEqualTo(testSubject) + prop(Success::notificationId) + .isEqualTo(notificationRegistry.getValue(notification)) } verifySuspend(exactly(1)) { @@ -166,11 +169,13 @@ class SystemNotificationCommandTest { severity = NotificationSeverity.Fatal, ) val notifier = spy(FakeNotifier()) + val notificationRegistry = FakeNotificationRegistry() val testSubject = createTestSubject( notification = notification, // TODO(#9391): Verify if the app is backgrounded. isAppInBackground = { false }, notifier = notifier, + notificationRegistry = notificationRegistry, ) // Act @@ -183,6 +188,8 @@ class SystemNotificationCommandTest { .all { prop(Success::command) .isEqualTo(testSubject) + prop(Success::notificationId) + .isEqualTo(notificationRegistry.getValue(notification)) } verifySuspend(exactly(1)) { @@ -198,11 +205,13 @@ class SystemNotificationCommandTest { severity = NotificationSeverity.Critical, ) val notifier = spy(FakeNotifier()) + val notificationRegistry = FakeNotificationRegistry() val testSubject = createTestSubject( notification = notification, // TODO(#9391): Verify if the app is backgrounded. isAppInBackground = { false }, notifier = notifier, + notificationRegistry = notificationRegistry, ) // Act @@ -215,6 +224,8 @@ class SystemNotificationCommandTest { .all { prop(Success::command) .isEqualTo(testSubject) + prop(Success::notificationId) + .isEqualTo(notificationRegistry.getValue(notification)) } verifySuspend(exactly(1)) { @@ -230,11 +241,13 @@ class SystemNotificationCommandTest { severity = NotificationSeverity.Information, ) val notifier = spy(FakeNotifier()) + val notificationRegistry = FakeNotificationRegistry() val testSubject = createTestSubject( notification = notification, // TODO(#9391): Verify if the app is backgrounded. isAppInBackground = { false }, notifier = notifier, + notificationRegistry = notificationRegistry, ) // Act @@ -247,6 +260,8 @@ class SystemNotificationCommandTest { .all { prop(Success::command) .isEqualTo(testSubject) + prop(Success::notificationId) + .isEqualTo(notificationRegistry.getValue(notification)) } verifySuspend(exactly(1)) { @@ -263,9 +278,9 @@ class SystemNotificationCommandTest { // TODO(#9391): Verify if the app is backgrounded. false }, - ): SystemNotificationCommand { + ): DisplaySystemNotificationCommand { val logger = TestLogger() - return SystemNotificationCommand( + return DisplaySystemNotificationCommand( logger = logger, featureFlagProvider = featureFlagProvider, notificationRegistry = notificationRegistry, @@ -276,36 +291,13 @@ class SystemNotificationCommandTest { } } -private open class FakeNotificationRegistry : NotificationRegistry { - override val registrar: Map - 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.nextInt()) - } - - override fun unregister(notificationId: NotificationId) { - TODO("Not yet implemented") - } - - override fun unregister(notification: Notification) { - TODO("Not yet implemented") - } -} - private open class FakeNotifier : NotificationNotifier { override suspend fun show( id: NotificationId, notification: SystemNotification, ) = Unit + override suspend fun dismiss(id: NotificationId) = Unit + override fun dispose() = Unit } diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/dismisser/DefaultNotificationDismisserTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/dismisser/DefaultNotificationDismisserTest.kt new file mode 100644 index 00000000000..acf2baeeea1 --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/dismisser/DefaultNotificationDismisserTest.kt @@ -0,0 +1,212 @@ +package net.thunderbird.feature.notification.impl.dismisser + +import assertk.all +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.hasMessage +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +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.random.Random +import kotlin.test.Test +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationId +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.AppNotification +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.api.ui.icon.NotificationIcon +import net.thunderbird.feature.notification.impl.command.DismissInAppNotificationCommand +import net.thunderbird.feature.notification.impl.command.DismissSystemNotificationCommand +import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification +import net.thunderbird.feature.notification.testing.fake.FakeNotification +import net.thunderbird.feature.notification.testing.fake.FakeNotificationRegistry +import net.thunderbird.feature.notification.testing.fake.FakeSystemOnlyNotification +import net.thunderbird.feature.notification.testing.fake.icon.EMPTY_SYSTEM_NOTIFICATION_ICON +import net.thunderbird.feature.notification.testing.fake.receiver.FakeInAppNotificationNotifier +import net.thunderbird.feature.notification.testing.fake.receiver.FakeSystemNotificationNotifier + +@Suppress("MaxLineLength") +class DefaultNotificationDismisserTest { + + @Test + fun `dismiss(id) should emit Failure when notification is not found`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() + val dismisser = createTestSubject(notificationRegistry = registry) + val missingId = NotificationId(value = Random.nextInt()) + + // Act + val outcomes = dismisser.dismiss(missingId).toList(mutableListOf()) + + // Assert + assertThat(outcomes.single()) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command).isEqualTo(null) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage("Notification with id '$missingId' not found") + } + } + + @Test + fun `dismiss(notification) should emit Success and call system notifier when registered SystemNotification`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() + val systemNotifier = spy(FakeSystemNotificationNotifier()) + val inAppNotifier = spy(FakeInAppNotificationNotifier()) + val dismisser = createTestSubject( + notificationRegistry = registry, + systemNotificationNotifier = systemNotifier, + inAppNotificationNotifier = inAppNotifier, + ) + val notification: SystemNotification = FakeSystemOnlyNotification() + // register notification to be dismissible + registry.register(notification) + + // Act + val outcomes = dismisser.dismiss(notification).toList(mutableListOf()) + + // Assert + assertThat(outcomes.single()) + .isInstanceOf>>() + .prop("data") { it.data } + .prop(Success::command) + .isInstanceOf() + verifySuspend(exactly(1)) { systemNotifier.dismiss(id = any()) } + verifySuspend(exactly(0)) { inAppNotifier.dismiss(id = any()) } + } + + @Test + fun `dismiss(notification) should emit Success and call in-app notifier when registered InAppNotification`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() + val systemNotifier = spy(FakeSystemNotificationNotifier()) + val inAppNotifier = spy(FakeInAppNotificationNotifier()) + val dismisser = createTestSubject( + notificationRegistry = registry, + systemNotificationNotifier = systemNotifier, + inAppNotificationNotifier = inAppNotifier, + ) + val notification: InAppNotification = FakeInAppOnlyNotification() + registry.register(notification) + + // Act + val outcomes = dismisser.dismiss(notification).toList(mutableListOf()) + + // Assert + assertThat(outcomes.single()) + .isInstanceOf>>() + .prop("data") { it.data } + .prop(Success::command) + .isInstanceOf() + verifySuspend(exactly(1)) { inAppNotifier.dismiss(id = any()) } + verifySuspend(exactly(0)) { systemNotifier.dismiss(id = any()) } + } + + @Test + fun `dismiss(notification) should emit Failure when notification is not registered`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() // empty + val dismisser = createTestSubject(notificationRegistry = registry) + val unregistered: Notification = FakeNotification() + + // Act + val outcomes = dismisser.dismiss(unregistered).toList(mutableListOf()) + + // Assert + assertThat(outcomes.single()) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command).isEqualTo(null) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage("Can't dismiss notification that is already dismissed") + } + } + + @Test + fun `send should emit Failure when no commands can be executed`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() + val systemNotifier = spy(FakeSystemNotificationNotifier()) + val inAppNotifier = spy(FakeInAppNotificationNotifier()) + val logger = TestLogger() + val testSubject = createTestSubject( + logger = logger, + notificationRegistry = registry, + systemNotificationNotifier = systemNotifier, + inAppNotificationNotifier = inAppNotifier, + ) + val notification = object : AppNotification() { + override val accountUuid: String? get() = "" + override val title: String = "" + override val contentText: String? = null + override val severity: NotificationSeverity = NotificationSeverity.Critical + override val icon: NotificationIcon = NotificationIcon( + systemNotificationIcon = EMPTY_SYSTEM_NOTIFICATION_ICON, + ) + } + // register notification to be dismissible + registry.register(notification) + val expectedMessage = "The notification is present in the registrar; " + + "however no commands where found to execute for notification $notification" + + // Act + val outcomes = testSubject.dismiss(notification).toList(mutableListOf()) + + // Assert + assertThat(outcomes).all { + hasSize(size = 1) + transform { it.single() } + .isInstanceOf>>() + .prop(Outcome.Failure>::error) + .all { + prop(Failure::command).isNull() + prop(Failure::throwable) + .isInstanceOf() + .hasMessage(expectedMessage) + } + } + assertThat(logger.events) + .transform { it.map { event -> event.level to event.message } } + .contains(LogLevel.WARN to expectedMessage) + } + + private fun createTestSubject( + logger: TestLogger = TestLogger(), + featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled }, + notificationRegistry: NotificationRegistry = FakeNotificationRegistry(), + systemNotificationNotifier: NotificationNotifier = FakeSystemNotificationNotifier(), + inAppNotificationNotifier: NotificationNotifier = FakeInAppNotificationNotifier(), + ): DefaultNotificationDismisser { + return DefaultNotificationDismisser( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + systemNotificationNotifier = systemNotificationNotifier, + inAppNotificationNotifier = inAppNotificationNotifier, + ) + } +} 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 index 62448fec9ae..7a6d8384f99 100644 --- 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 @@ -66,6 +66,8 @@ class InAppNotificationNotifierTest { 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") + override fun contains(notification: Notification): Boolean = registrar.containsValue(notification) + override fun contains(notificationId: NotificationId): Boolean = registrar.contains(notificationId) }, inAppNotificationEventBus = eventBus, ) diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/sender/DefaultNotificationSenderTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/sender/DefaultNotificationSenderTest.kt new file mode 100644 index 00000000000..68392a9072f --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/sender/DefaultNotificationSenderTest.kt @@ -0,0 +1,196 @@ +package net.thunderbird.feature.notification.impl.sender + +import assertk.all +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.hasMessage +import assertk.assertions.hasSize +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +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.flow.toList +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.core.logging.LogLevel +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.AppNotification +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.api.ui.icon.NotificationIcon +import net.thunderbird.feature.notification.impl.command.DisplayInAppNotificationCommand +import net.thunderbird.feature.notification.impl.command.DisplaySystemNotificationCommand +import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification +import net.thunderbird.feature.notification.testing.fake.FakeNotification +import net.thunderbird.feature.notification.testing.fake.FakeNotificationRegistry +import net.thunderbird.feature.notification.testing.fake.FakeSystemOnlyNotification +import net.thunderbird.feature.notification.testing.fake.icon.EMPTY_SYSTEM_NOTIFICATION_ICON +import net.thunderbird.feature.notification.testing.fake.receiver.FakeInAppNotificationNotifier +import net.thunderbird.feature.notification.testing.fake.receiver.FakeSystemNotificationNotifier + +class DefaultNotificationSenderTest { + + @Test + fun `send should emit Success and call system notifier for SystemNotification`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() + val systemNotifier = spy(FakeSystemNotificationNotifier()) + val inAppNotifier = spy(FakeInAppNotificationNotifier()) + val testSubject = createTestSubject( + notificationRegistry = registry, + systemNotificationNotifier = systemNotifier, + inAppNotificationNotifier = inAppNotifier, + ) + val notification: SystemNotification = FakeSystemOnlyNotification() + + // Act + val outcomes = testSubject.send(notification).toList(mutableListOf()) + + // Assert + assertThat(outcomes.single()) + .isInstanceOf>>() + .prop("data") { it.data } + .prop(Success::command) + .isInstanceOf() + + verifySuspend(exactly(1)) { systemNotifier.show(id = any(), notification = any()) } + // Ensure in-app notifier wasn't called + verifySuspend(exactly(0)) { inAppNotifier.show(id = any(), notification = any()) } + } + + @Test + fun `send should emit Successes and call both notifiers when notification qualifies for both`() = runTest { + // Arrange: Make system command succeed by using a Critical severity (always show) + val registry = FakeNotificationRegistry() + val systemNotifier = spy(FakeSystemNotificationNotifier()) + val inAppNotifier = spy(FakeInAppNotificationNotifier()) + val testSubject = createTestSubject( + notificationRegistry = registry, + systemNotificationNotifier = systemNotifier, + inAppNotificationNotifier = inAppNotifier, + ) + val notification = FakeNotification( + // Critical ensures DisplaySystemNotificationCommand can execute even if app is foreground + severity = NotificationSeverity.Critical, + ) + + // Act + val outcomes = testSubject.send(notification).toList(mutableListOf()) + + // Assert: we expect two outcomes in order: system then in-app + assertThat(outcomes[0]) + .isInstanceOf>>() + .prop("data") { it.data } + .prop(Success::command) + .isInstanceOf() + assertThat(outcomes[1]) + .isInstanceOf>>() + .prop("data") { it.data } + .prop(Success::command) + .isInstanceOf() + + verifySuspend(exactly(1)) { systemNotifier.show(id = any(), notification) } + verifySuspend(exactly(1)) { inAppNotifier.show(id = any(), notification) } + } + + @Test + fun `send should emit Success and call in-app notifier for InAppNotification`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() + val systemNotifier = spy(FakeSystemNotificationNotifier()) + val inAppNotifier = spy(FakeInAppNotificationNotifier()) + val testSubject = createTestSubject( + notificationRegistry = registry, + systemNotificationNotifier = systemNotifier, + inAppNotificationNotifier = inAppNotifier, + ) + val notification: InAppNotification = FakeInAppOnlyNotification() + + // Act + val outcomes = testSubject.send(notification).toList(mutableListOf()) + + // Assert + assertThat(outcomes.single()) + .isInstanceOf>>() + .prop("data") { it.data } + .prop(Success::command) + .isInstanceOf() + + verifySuspend(exactly(1)) { inAppNotifier.show(id = any(), notification = any()) } + verifySuspend(exactly(0)) { systemNotifier.show(id = any(), notification = any()) } + } + + @Test + fun `send should emit Failure when no commands can be executed`() = runTest { + // Arrange + val registry = FakeNotificationRegistry() + val systemNotifier = spy(FakeSystemNotificationNotifier()) + val inAppNotifier = spy(FakeInAppNotificationNotifier()) + val logger = TestLogger() + val testSubject = createTestSubject( + logger = logger, + notificationRegistry = registry, + systemNotificationNotifier = systemNotifier, + inAppNotificationNotifier = inAppNotifier, + ) + val notification = object : AppNotification() { + override val accountUuid: String? get() = "" + override val title: String = "" + override val contentText: String? = null + override val severity: NotificationSeverity = NotificationSeverity.Critical + override val icon: NotificationIcon = NotificationIcon( + systemNotificationIcon = EMPTY_SYSTEM_NOTIFICATION_ICON, + ) + } + val expectedMessage = "No commands to execute for notification $notification" + + // Act + val outcomes = testSubject.send(notification).toList(mutableListOf()) + + // Assert + assertThat(outcomes).all { + hasSize(size = 1) + transform { it.single() } + .isInstanceOf>>() + .prop(Outcome.Failure>::error) + .all { + prop(Failure::command).isNull() + prop(Failure::throwable) + .isInstanceOf() + .hasMessage(expectedMessage) + } + } + assertThat(logger.events) + .transform { it.map { event -> event.level to event.message } } + .contains(LogLevel.WARN to expectedMessage) + } + + private fun createTestSubject( + logger: TestLogger = TestLogger(), + featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled }, + notificationRegistry: NotificationRegistry = FakeNotificationRegistry(), + systemNotificationNotifier: NotificationNotifier = FakeSystemNotificationNotifier(), + inAppNotificationNotifier: NotificationNotifier = FakeInAppNotificationNotifier(), + ): DefaultNotificationSender { + return DefaultNotificationSender( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + systemNotificationNotifier = systemNotificationNotifier, + inAppNotificationNotifier = inAppNotificationNotifier, + ) + } +} diff --git a/feature/notification/testing/src/androidMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.android.kt b/feature/notification/testing/src/androidMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.android.kt index b35b2707086..ec94b1bd96f 100644 --- a/feature/notification/testing/src/androidMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.android.kt +++ b/feature/notification/testing/src/androidMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.android.kt @@ -2,4 +2,4 @@ package net.thunderbird.feature.notification.testing.fake.icon import net.thunderbird.feature.notification.api.ui.icon.SystemNotificationIcon -internal actual val EMPTY_SYSTEM_NOTIFICATION_ICON: SystemNotificationIcon = 0 +actual val EMPTY_SYSTEM_NOTIFICATION_ICON: SystemNotificationIcon = 0 diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationManager.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationManager.kt new file mode 100644 index 00000000000..25c43fdfff7 --- /dev/null +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationManager.kt @@ -0,0 +1,32 @@ +package net.thunderbird.feature.notification.testing.fake + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.NotificationManager +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 + +open class FakeNotificationManager( + private val emitOnSend: (notification: Notification) -> Outcome, Failure>, + private val emitOnDismissNotification: (notification: Notification) -> Outcome< + Success, + Failure, + >, + private val emitOnDismissId: (id: NotificationId) -> Outcome, Failure>, +) : NotificationManager { + override fun send(notification: Notification): Flow, Failure>> = flow { + emit(emitOnSend(notification)) + } + + override fun dismiss(id: NotificationId): Flow, Failure>> = flow { + emit(emitOnDismissId(id)) + } + + override fun dismiss(notification: Notification): Flow, Failure>> = + flow { + emit(emitOnDismissNotification(notification)) + } +} 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 index e2ca648dd9f..389c3cb8494 100644 --- 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 @@ -6,26 +6,40 @@ import net.thunderbird.feature.notification.api.NotificationRegistry import net.thunderbird.feature.notification.api.content.Notification open class FakeNotificationRegistry : NotificationRegistry { + private val byId = mutableMapOf() + private val byNotification = mutableMapOf() + override val registrar: Map - get() = TODO("Not yet implemented") + get() = byId - override fun get(notificationId: NotificationId): Notification? { - TODO("Not yet implemented") - } + override fun get(notificationId: NotificationId): Notification? = byId[notificationId] - override fun get(notification: Notification): NotificationId? { - TODO("Not yet implemented") - } + fun getValue(notificationId: NotificationId): Notification = byId.getValue(notificationId) + + override fun get(notification: Notification): NotificationId? = byNotification[notification] + + fun getValue(notification: Notification): NotificationId = byNotification.getValue(notification) override suspend fun register(notification: Notification): NotificationId { - return NotificationId(value = Random.Default.nextInt()) + val id = NotificationId(value = Random.nextInt()) + byId[id] = notification + byNotification[notification] = id + return id } override fun unregister(notificationId: NotificationId) { - TODO("Not yet implemented") + byId.remove(notificationId)?.let { notif -> + byNotification.remove(notif) + } } override fun unregister(notification: Notification) { - TODO("Not yet implemented") + byNotification.remove(notification)?.let { id -> + byId.remove(id) + } } + + override fun contains(notification: Notification): Boolean = byNotification.containsKey(notification) + + override fun contains(notificationId: NotificationId): Boolean = byId.containsKey(notificationId) } diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/FakeSystemNotificationIcon.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/FakeSystemNotificationIcon.kt index 8a146b3954e..bd39d121e94 100644 --- a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/FakeSystemNotificationIcon.kt +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/FakeSystemNotificationIcon.kt @@ -2,4 +2,4 @@ package net.thunderbird.feature.notification.testing.fake.icon import net.thunderbird.feature.notification.api.ui.icon.SystemNotificationIcon -internal expect val EMPTY_SYSTEM_NOTIFICATION_ICON: SystemNotificationIcon +expect val EMPTY_SYSTEM_NOTIFICATION_ICON: SystemNotificationIcon 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 index adcf427d7db..4c470d3a575 100644 --- 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 @@ -10,5 +10,7 @@ open class FakeInAppNotificationNotifier : NotificationNotifier { +open class FakeSystemNotificationNotifier : NotificationNotifier { override suspend fun show( id: NotificationId, notification: SystemNotification, ) = Unit + override suspend fun dismiss(id: NotificationId) = Unit + override fun dispose() = Unit } diff --git a/feature/notification/testing/src/jvmMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.jvm.kt b/feature/notification/testing/src/jvmMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.jvm.kt index b35b2707086..ec94b1bd96f 100644 --- a/feature/notification/testing/src/jvmMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.jvm.kt +++ b/feature/notification/testing/src/jvmMain/kotlin/net/thunderbird/feature/notification/testing/fake/icon/SystemNotificationIcon.jvm.kt @@ -2,4 +2,4 @@ package net.thunderbird.feature.notification.testing.fake.icon import net.thunderbird.feature.notification.api.ui.icon.SystemNotificationIcon -internal actual val EMPTY_SYSTEM_NOTIFICATION_ICON: SystemNotificationIcon = 0 +actual val EMPTY_SYSTEM_NOTIFICATION_ICON: SystemNotificationIcon = 0 diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt b/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt index 3d79329276b..042772c9f57 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt @@ -14,7 +14,7 @@ import com.fsck.k9.notification.NotificationStrategy import net.thunderbird.core.featureflag.FeatureFlagProvider import net.thunderbird.core.logging.Logger import net.thunderbird.feature.mail.folder.api.OutboxFolderManager -import net.thunderbird.feature.notification.api.sender.NotificationSender +import net.thunderbird.feature.notification.api.NotificationManager import org.koin.core.qualifier.named import org.koin.dsl.binds import org.koin.dsl.module @@ -35,7 +35,7 @@ val controllerModule = module { get(named("controllerExtensions")), get(), get(named("syncDebug")), - get(), + get(), get(), ) } binds arrayOf(MessagingControllerRegistry::class) diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java b/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java index fb3fbec1b6c..47960954bee 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java +++ b/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java @@ -85,15 +85,15 @@ import net.thunderbird.core.android.account.LegacyAccountDto; import net.thunderbird.core.common.exception.MessagingException; import net.thunderbird.core.featureflag.FeatureFlagProvider; -import net.thunderbird.core.featureflag.FeatureFlagResult.Enabled; import net.thunderbird.core.featureflag.compat.FeatureFlagProviderCompat; import net.thunderbird.core.logging.Logger; import net.thunderbird.core.logging.legacy.Log; import net.thunderbird.feature.mail.folder.api.OutboxFolderManager; import net.thunderbird.feature.mail.folder.api.OutboxFolderManagerKt; +import net.thunderbird.feature.notification.api.NotificationManager; import net.thunderbird.feature.notification.api.content.AuthenticationErrorNotification; import net.thunderbird.feature.notification.api.content.NotificationFactoryCoroutineCompat; -import net.thunderbird.feature.notification.api.sender.NotificationSender; +import net.thunderbird.feature.notification.api.dismisser.compat.NotificationDismisserCompat; import net.thunderbird.feature.notification.api.sender.compat.NotificationSenderCompat; import net.thunderbird.feature.search.legacy.LocalMessageSearch; import org.jetbrains.annotations.NotNull; @@ -145,9 +145,9 @@ public class MessagingController implements MessagingControllerRegistry, Messagi private final ArchiveOperations archiveOperations; private final FeatureFlagProvider featureFlagProvider; private final Logger syncDebugLogger; - private final NotificationSenderCompat notificationSender; private final OutboxFolderManager outboxFolderManager; - + private final NotificationSenderCompat notificationSender; + private final NotificationDismisserCompat notificationDismisser; private volatile boolean stopped = false; @@ -171,7 +171,7 @@ public static MessagingController getInstance(Context context) { List controllerExtensions, FeatureFlagProvider featureFlagProvider, Logger syncDebugLogger, - NotificationSender notificationSender, + NotificationManager notificationManager, OutboxFolderManager outboxFolderManager ) { this.context = context; @@ -186,7 +186,8 @@ public static MessagingController getInstance(Context context) { this.localDeleteOperationDecider = localDeleteOperationDecider; this.featureFlagProvider = featureFlagProvider; this.syncDebugLogger = syncDebugLogger; - this.notificationSender = new NotificationSenderCompat(notificationSender); + this.notificationSender = new NotificationSenderCompat(notificationManager); + this.notificationDismisser = new NotificationDismisserCompat(notificationManager); this.outboxFolderManager = outboxFolderManager; controllerThread = new Thread(new Runnable() { @@ -707,19 +708,9 @@ public void handleAuthenticationFailure(LegacyAccountDto account, boolean incomi migrateAccountToOAuth(account); } - if (FeatureFlagProviderCompat.provide(featureFlagProvider, "display_in_app_notifications") == - Enabled.INSTANCE) { + if (FeatureFlagProviderCompat.provide(featureFlagProvider, "display_in_app_notifications").isEnabled()) { Log.d("handleAuthenticationFailure: sending in-app notification"); - final AuthenticationErrorNotification notification = NotificationFactoryCoroutineCompat.create( - continuation -> - AuthenticationErrorNotification.Companion.invoke( - account.getUuid(), - account.getDisplayName(), - account.getAccountNumber(), - incoming, - continuation - ) - ); + final AuthenticationErrorNotification notification = createAuthenticationErrorNotification(account, incoming); notificationSender.send(notification, outcome -> { Log.v("notificationSender outcome = " + outcome); @@ -727,12 +718,26 @@ public void handleAuthenticationFailure(LegacyAccountDto account, boolean incomi } if (FeatureFlagProviderCompat.provide(featureFlagProvider, - "use_notification_sender_for_system_notifications") != Enabled.INSTANCE) { + "use_notification_sender_for_system_notifications").isDisabled()) { Log.d("handleAuthenticationFailure: sending system notification via old notification controller"); notificationController.showAuthenticationErrorNotification(account, incoming); } } + private AuthenticationErrorNotification createAuthenticationErrorNotification( + LegacyAccountDto account, boolean incoming) { + return NotificationFactoryCoroutineCompat.create( + continuation -> + AuthenticationErrorNotification.Companion.invoke( + account.getUuid(), + account.getDisplayName(), + account.getAccountNumber(), + incoming, + continuation + ) + ); + } + private void migrateAccountToOAuth(LegacyAccountDto account) { account.setIncomingServerSettings(account.getIncomingServerSettings().newAuthenticationType(AuthType.XOAUTH2)); account.setOutgoingServerSettings(account.getOutgoingServerSettings().newAuthenticationType(AuthType.XOAUTH2)); @@ -2599,10 +2604,15 @@ public void checkAuthenticationProblem(LegacyAccountDto account) { if (isAuthenticationProblem(account, true)) { handleAuthenticationFailure(account, true); return; + } else { + clearAuthenticationErrorNotification(account, true); } + // checking outgoing server configuration if (isAuthenticationProblem(account, false)) { handleAuthenticationFailure(account, false); + } else { + clearAuthenticationErrorNotification(account, false); } } @@ -2614,6 +2624,16 @@ private boolean isAuthenticationProblem(LegacyAccountDto account, boolean incomi serverSettings.authenticationType == AuthType.XOAUTH2 && account.getOAuthState() == null; } + private void clearAuthenticationErrorNotification(LegacyAccountDto account, boolean incoming) { + if (FeatureFlagProviderCompat.provide(featureFlagProvider, "display_in_app_notifications").isEnabled()) { + final AuthenticationErrorNotification notification = createAuthenticationErrorNotification( + account, incoming); + notificationDismisser.dismiss(notification,outcome -> { + Log.v("notificationDismisser outcome = " + outcome); + }); + } + } + void actOnMessagesGroupedByAccountAndFolder(List messages, MessageActor actor) { Map>> accountMap = groupMessagesByAccountAndFolder(messages); @@ -2702,6 +2722,7 @@ public void syncStarted(@NotNull String folderServerId) { @Override public void syncAuthenticationSuccess() { + clearAuthenticationErrorNotification(account, true); notificationController.clearAuthenticationErrorNotification(account, true); } diff --git a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java index ff077145c1a..ad470746a39 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java +++ b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java @@ -40,8 +40,10 @@ import net.thunderbird.core.logging.Logger; import net.thunderbird.core.outcome.Outcome; import net.thunderbird.feature.mail.folder.api.OutboxFolderManager; +import net.thunderbird.feature.notification.api.NotificationManager; import net.thunderbird.feature.notification.api.sender.NotificationSender; import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification; +import net.thunderbird.feature.notification.testing.fake.FakeNotificationManager; import net.thunderbird.legacy.core.mailstore.folder.FakeOutboxFolderManager; import org.junit.After; import org.junit.Before; @@ -132,9 +134,11 @@ public void setUp() throws MessagingException { preferences = Preferences.getPreferences(); featureFlagProvider = key -> Disabled.INSTANCE; - final NotificationSender notificationSender = notification -> - (flowCollector, continuation) -> - Outcome.Companion.success(new FakeInAppOnlyNotification()); + final NotificationManager notificationManager = new FakeNotificationManager( + notification -> Outcome.Companion.success(new FakeInAppOnlyNotification()), + notification -> Outcome.Companion.success(new FakeInAppOnlyNotification()), + id -> Outcome.Companion.success(new FakeInAppOnlyNotification()) + ); final OutboxFolderManager fakeOutboxFolderManager = new FakeOutboxFolderManager(FOLDER_ID); @@ -152,7 +156,7 @@ public void setUp() throws MessagingException { Collections.emptyList(), featureFlagProvider, syncLogger, - notificationSender, + notificationManager, fakeOutboxFolderManager ); @@ -198,7 +202,7 @@ private void setupRemoteSearch() throws Exception { when(localNewMessage1.getUid()).thenReturn("newMessageUid1"); when(localNewMessage2.getUid()).thenReturn("newMessageUid2"); when(backend.search(eq(FOLDER_NAME), anyString(), nullable(Set.class), nullable(Set.class), eq(false))) - .thenReturn(remoteMessages); + .thenReturn(remoteMessages); when(localFolder.extractNewMessages(ArgumentMatchers.anyList())).thenReturn(newRemoteMessages); when(localFolder.getMessage("newMessageUid1")).thenReturn(localNewMessage1); when(localFolder.getMessage("newMessageUid2")).thenAnswer( @@ -290,7 +294,7 @@ public void searchRemoteMessagesSynchronous_shouldNotFetchExistingMessages() thr public void searchRemoteMessagesSynchronous_shouldNotifyOnFailure() throws Exception { setupRemoteSearch(); when(backend.search(anyString(), anyString(), nullable(Set.class), nullable(Set.class), eq(false))) - .thenThrow(new MessagingException("Test")); + .thenThrow(new MessagingException("Test")); controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); @@ -301,7 +305,7 @@ public void searchRemoteMessagesSynchronous_shouldNotifyOnFailure() throws Excep public void searchRemoteMessagesSynchronous_shouldNotifyOnFinish() throws Exception { setupRemoteSearch(); when(backend.search(anyString(), nullable(String.class), nullable(Set.class), nullable(Set.class), eq(false))) - .thenThrow(new MessagingException("Test")); + .thenThrow(new MessagingException("Test")); controller.searchRemoteMessagesSynchronous(accountUuid, FOLDER_ID, "query", reqFlags, forbiddenFlags, listener); @@ -423,9 +427,9 @@ private void configureAccount() { accountUuid = account.getUuid(); account.setIncomingServerSettings(new ServerSettings(Protocols.IMAP, "host", 993, - ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); + ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); account.setOutgoingServerSettings(new ServerSettings(Protocols.SMTP, "host", 465, - ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); + ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); account.setMaximumAutoDownloadMessageSize(MAXIMUM_SMALL_MESSAGE_SIZE); account.setEmail("user@host.com"); } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index fbbd7a566d0..73f27f73646 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -510,7 +510,7 @@ class MessageListFragment : onSnackbarNotificationEvent = ::onSnackbarInAppNotificationEvent, eventFilter = { event -> val accountUuid = event.notification.accountUuid - accountUuid != null && accounts.any { it.uuid == accountUuid } + accountUuid != null && accountUuid in accountUuids }, modifier = Modifier .animateContentSize()