Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,20 +97,22 @@ fun InAppNotificationHost(
eventFilter: (InAppNotificationEvent) -> Boolean = { true },
content: @Composable (PaddingValues) -> Unit,
) {
val inAppNotificationEvents by koinInject<InAppNotificationReceiver>()
.events
.collectAsStateWithLifecycle(initialValue = null)

val receiver = koinInject<InAppNotificationReceiver>()
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
<string name="notification_notify_error_text">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.</string>

<string name="notification_authentication_error_title">Authentication failed</string>
<string name="notification_authentication_error_text">Authentication failed for %1$s. Update your server settings.</string>
<string name="notification_authentication_incoming_server_error_text">Authentication failed for %1$s. Update your incoming server settings.</string>
<string name="notification_authentication_outgoing_server_error_text">Authentication failed for %1$s. Update your outgoing server settings.</string>

<string name="notification_certificate_error_public">Certificate error</string>
<string name="notification_certificate_error_title">Certificate error for %1$s</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -33,11 +35,24 @@ abstract class NotificationCommand<TNotification : Notification>(
* 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<out TNotification : Notification>(
val notificationId: NotificationId,
val command: NotificationCommand<out TNotification>,
) : 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<Notification> = Success(NotificationId(notificationId), command)
}
}

/**
* Represents a failed command execution.
Expand All @@ -47,7 +62,7 @@ abstract class NotificationCommand<TNotification : Notification>(
* @property throwable The exception that caused the failure.
*/
data class Failure<out TNotification : Notification>(
val command: NotificationCommand<out TNotification>,
val command: NotificationCommand<out TNotification>?,
val throwable: Throwable,
) : CommandOutcome
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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<NotificationAction> = buildSet {
val action = if (isIncomingServerError) {
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Outcome<Success<Notification>, Failure<Notification>>>

/**
* 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<Outcome<Success<Notification>, Failure<Notification>>>
}
Original file line number Diff line number Diff line change
@@ -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<Success<Notification>, Failure<Notification>>)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<in TNotification : Notification> {
/**
* 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<Display
}
}

/**
* Dismisses all visual representations of the given in-app notification.
*
* This function will attempt to dismiss the global banner, inline banners,
* and snackbar associated with the provided notification.
*
* @param notification The [InAppNotification] to dismiss.
*/
fun dismiss(notification: InAppNotification) {
val data = notification.toInAppNotificationData()
data.bannerInlineVisuals.singleOrNull()?.let(::dismiss)
data.bannerGlobalVisual?.let(::dismiss)
data.snackbarVisual?.let(::dismiss)
}

/**
* Dismisses the given in-app notification visual.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.notification.api.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.command.NotificationCommandException
Expand All @@ -25,8 +26,8 @@ class NotificationSenderCompatTest {
fun `send should call listener callback whenever a result is received`() {
// Arrange
val expectedResults = listOf<Outcome<Success<Notification>, Failure<Notification>>>(
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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ public void send_shouldCallListenerCallback_wheneverAResultIsReceived() {
? extends @NotNull Failure<? extends @NotNull Notification>
>
> 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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading