diff --git a/docs/assets/notification-system/big-text-collapsed-system-notification.png b/docs/assets/notification-system/big-text-collapsed-system-notification.png new file mode 100644 index 00000000000..8bb8bf87b71 Binary files /dev/null and b/docs/assets/notification-system/big-text-collapsed-system-notification.png differ diff --git a/docs/assets/notification-system/big-text-expanded-system-notification-style.png b/docs/assets/notification-system/big-text-expanded-system-notification-style.png new file mode 100644 index 00000000000..4bf64905b45 Binary files /dev/null and b/docs/assets/notification-system/big-text-expanded-system-notification-style.png differ diff --git a/docs/assets/notification-system/in-app-banner-global-error.png b/docs/assets/notification-system/in-app-banner-global-error.png new file mode 100644 index 00000000000..2c970338a8f Binary files /dev/null and b/docs/assets/notification-system/in-app-banner-global-error.png differ diff --git a/docs/assets/notification-system/in-app-banner-global-info.png b/docs/assets/notification-system/in-app-banner-global-info.png new file mode 100644 index 00000000000..9232945a232 Binary files /dev/null and b/docs/assets/notification-system/in-app-banner-global-info.png differ diff --git a/docs/assets/notification-system/in-app-banner-global-success.png b/docs/assets/notification-system/in-app-banner-global-success.png new file mode 100644 index 00000000000..2b4d2eb6a3a Binary files /dev/null and b/docs/assets/notification-system/in-app-banner-global-success.png differ diff --git a/docs/assets/notification-system/in-app-banner-global-warning.png b/docs/assets/notification-system/in-app-banner-global-warning.png new file mode 100644 index 00000000000..b6d22cb599e Binary files /dev/null and b/docs/assets/notification-system/in-app-banner-global-warning.png differ diff --git a/docs/assets/notification-system/in-app-banner-inline-item.png b/docs/assets/notification-system/in-app-banner-inline-item.png new file mode 100644 index 00000000000..00c9ff3c478 Binary files /dev/null and b/docs/assets/notification-system/in-app-banner-inline-item.png differ diff --git a/docs/assets/notification-system/in-app-dialog.png b/docs/assets/notification-system/in-app-dialog.png new file mode 100644 index 00000000000..0d9e569c8b5 Binary files /dev/null and b/docs/assets/notification-system/in-app-dialog.png differ diff --git a/docs/assets/notification-system/in-app-snackbar.png b/docs/assets/notification-system/in-app-snackbar.png new file mode 100644 index 00000000000..cd8b3539a8d Binary files /dev/null and b/docs/assets/notification-system/in-app-snackbar.png differ diff --git a/docs/assets/notification-system/system-notification-basic-style.png b/docs/assets/notification-system/system-notification-basic-style.png new file mode 100644 index 00000000000..a1886f758b7 Binary files /dev/null and b/docs/assets/notification-system/system-notification-basic-style.png differ diff --git a/feature/notification/README.md b/feature/notification/README.md new file mode 100644 index 00000000000..bf1ad42b0ce --- /dev/null +++ b/feature/notification/README.md @@ -0,0 +1,570 @@ +# Thunderbird for Android Notification System + +A flexible, extensible way to deliver notifications in **Thunderbird for Android**, across multiple providers (system +tray and in‑app) with a consistent API and a decoupled architecture. + +--- + +## Quickstart — Create & Send a Notification + +This section gets you from zero to a delivered notification. + +### Step 1: Inject the `NotificationSender` + +```kotlin +// MyViewModel.kt +class MyViewModel( + private val notificationSender: NotificationSender, +) : ViewModel() { + // ... +} + +// KoinModule.kt +val myFeatureModule = module { + // ... + viewModel { + MyViewModel( + notificationSender = get(), + ) + } +} +``` + +### Step 2: Create a Notification instance + +Create a concrete type that implements `SystemNotification`, `InAppNotification`, or both (see next section). You can +build it directly or via a factory (recommended for localized text). + +```kotlin +// Example: Authentication error shown as both system and in‑app +val notification = AuthenticationErrorNotification( + accountNumber = accountNumber, + accountUuid = accountUuid, + accountDisplayName = accountDisplayName, +) +``` + +### Step 3: Send and handle the outcome + +```kotlin +viewModelScope.launch { + notificationSender + .send(notification) + .collect { outcome -> + outcome.handle( + onSuccess = { commandOutcome -> + // Optional: update UI/log + }, + onFailure = { error -> + // Optional: show an error message/log + }, + ) + } +} +``` + +--- + +## Notification Types & When to Use Them + +Thunderbird uses a common `Notification` model with two specialized types: + +* **`SystemNotification`** — Standard Android OS notifications (require permission; UI is determined by the OS). +* **`InAppNotification`** — Messages displayed only within the app's UI (no permission; fully app‑controlled). + +### Choosing the correct type + +Answer **when** the notification must trigger: + +1. **Background?** Use `SystemNotification`. +2. **Foreground?** Use `InAppNotification`. +3. **Both?** Implement both interfaces in your type (single payload, two render targets). + +### Type Comparison Matrix + +| Capability | SystemNotification | InAppNotification | +|------------------------------------|-----------------------------|---------------------------------------------| +| Appears when app in **background** | ✅ | ❌ | +| Appears when app in **foreground** | ✅ | ✅ | +| Requires runtime permission | ✅ (`POST_NOTIFICATIONS`) | ❌ | +| UI control lives in | Android OS | App (Compose) | +| Typical uses | New mail, background errors | Guidance, inline errors, transient feedback | + +> [!TIP] +> For critical issues that also need persistent, global visibility while the app is open, implement **both** and select +> appropriate in‑app style(s) below. + +For a more deep information about the notification types, see +the [Notification Data Model](docs/notification-architecture.md#the-notification-data-model) documentation. + +--- + +## Severity Levels & Behavioral Differences + +Every notification **must** define a `NotificationSeverity` to drive user intrusiveness and styling. + +| Severity | When to use | Expected user action | SystemNotification behavior | InApp (BannerGlobal) color cue | +|-----------------|----------------------------|--------------------------|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Fatal** | Blocks essential tasks | Immediate resolution | Not dismissable | [Error colours](https://github.com/thunderbird/thunderbird-android/blob/main/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt#L22) | +| **Critical** | Disrupts core flows | Usually requires action | Not dismissable | [Error colours](https://github.com/thunderbird/thunderbird-android/blob/main/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt#L22) | +| **Warning** | Potential issue/limitation | Often recommended | Dismissable | [Warning colours](https://github.com/thunderbird/thunderbird-android/blob/main/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt#L58) | +| **Temporary** | Temporary disruption/delay | May self‑resolve; inform | Dismissable | [Information colours](https://github.com/thunderbird/thunderbird-android/blob/main/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt#L48) | +| **Information** | Status/context only | None required | Dismissable | [Information colours](https://github.com/thunderbird/thunderbird-android/blob/main/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt#L48) | + +Examples: + +* **Fatal** — Authentication error; actions: *Retry*, *Provide other credentials*. +* **Critical** — "Sending message failed"; action: *Retry*. +* **Warning** — "Mailbox is 90% full"; action: *Manage Storage*. +* **Temporary** — "Offline; message will be sent later". +* **Information** — "Last synchronization succeeded". + +--- + +## Styling Options + +### 1. System Notification Styles + +By default, a System notification shows **icon + title + content text** (style `Undefined`). Supported styles: + +* `Undefined` (basic) +* `BigTextStyle` — Expanded large text block. +* `InboxStyle` — Multiple short lines (e.g., message previews). + +**`BigTextStyle` example** + +```kotlin +data class NewMailSingleMail( + override val accountUuid: String, + val accountName: String, + val summary: String, + val sender: String, + val subject: String, + val preview: String, + override val icon: NotificationIcon = NotificationIcons.NewMailSingleMail, +) : MailNotification() { + override val title: String = sender + override val contentText: String = subject + override val systemNotificationStyle: SystemNotificationStyle = systemNotificationStyle { + bigText(preview) + } +} +``` + +**System Notification with BigTextStyle collapsed:** +![big-text-collapsed-system-notification.png](../../docs/assets/notification-system/big-text-collapsed-system-notification.png) + +**System Notification with BigTextStyle expanded:** +![big-text-expanded-system-notification-style.png](../../docs/assets/notification-system/big-text-expanded-system-notification-style.png) + +**`InboxStyle` example** (digest of multiple items) + +```kotlin +@ConsistentCopyVisibility +data class NewMailSummaries private constructor( + override val accountUuid: String, + override val title: String, // collapsed title + override val contentText: String, // collapsed content + val expandedTitle: String, + val summary: String, + val lines: List, + override val icon: NotificationIcon = NotificationIcons.NewMailSummaries, +) : MailNotification() { + override val systemNotificationStyle: SystemNotificationStyle = systemNotificationStyle { + inbox { + title(expandedTitle) + summary(summary) + lines(lines = lines.toTypedArray()) + } + } + + companion object { + suspend operator fun invoke( + accountUuid: String, + accountDisplayName: String, + previews: List, + ): NewMailSummaries = NewMailSummaries( + accountUuid = accountUuid, + title = getPluralString( + resource = Res.strings.new_mail_summaries_collapsed_title, + quantity = previews.size, + previews.size, + accountDisplayName, + ), + contentText = getString(Res.strings.new_mail_summaries_content_text), + expandedTitle = getPluralString( + resource = Res.strings.new_mail_summaries_expanded_title, + quantity = previews.size, + previews.size, + ), + summary = getString( + resource = Res.strings.new_mail_summaries_additional_messages, + previews.size, + accountDisplayName, + ), + lines = previews, + ) + } +} +``` + +> [!IMPORTANT] +> The System Notification UI may vary between Android OS versions and OEMs, but in general, they will always have the +> same look and feel, with some differences. +> +> The above screenshots were taken using Android 16 and a Pixel 7 Pro. + +--- + +### 2. In‑App Notification Styles + +Choose one based on UX intent. Additional styles may be added in the future. + +#### Banner Global + +Persistent, non‑blocking global state cues. + +**Use for:** + +* Global warnings (e.g., offline, encryption key unavailable) +* Account configuration flows: errors/success/info needing constant indicator + +**Do not use for:** + +* Non‑configuration messages (prefer Banner Inline or Snackbar) +* Flow‑blocking cases (prefer Dialog) + +#### Banner Inline + +Inline blocking/near‑blocking feedback scoped to the screen. + +**Use for:** + +* Critical issues affecting the screen's primary function +* Errors requiring attention before the user can effectively proceed + +**Do not use for:** + +* Truly blocking flows (use Dialog) +* Global states (use Banner Global) +* Secondary/symptom messages when a deeper root cause exists + +#### Snackbar + +Transient, non‑interrupting feedback; may include an action. + +**Use for:** + +* Action feedback and quick corrective options + +**Do not use for:** + +* Errors that must interrupt or block (use Dialog) +* Unified Inbox sync errors (prefer Banner types) + +#### Dialog + +Short, interrupting prompt for permissions or must‑act items. + +**Use for:** + +* Requesting notification permission with clear rationale + +**Do not use for:** + +* General errors or non‑critical permissions (e.g., Contacts) +* Background activity permission tied to battery saver + +--- + +## Localization & String Handling + +Because `:feature:notification:api` is a **KMP** module, prefer Compose Resources in **suspending factory** functions +when building text. + +### KMP friendly (suspending factory) — recommended + +You can create the suspending factory by using a `companion object` + `suspend operator fun invoke()`: + +```kotlin +@ConsistentCopyVisibility +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 { + + companion object { + suspend operator fun invoke( + accountUuid: String, + accountDisplayName: String, + ): AuthenticationErrorNotification = AuthenticationErrorNotification( + title = getString( + resource = Res.string.notification_authentication_error_title, + accountDisplayName, + ), + contentText = getString(resource = Res.string.notification_authentication_error_text), + channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid), + ) + } +} +``` + +Or by using a suspending factory function named with the same name as the type: + +```kotlin +@ConsistentCopyVisibility +data class AuthenticationErrorNotification internal constructor( + override val title: String, + override val contentText: String, + override val channel: NotificationChannel, + override val icon: NotificationIcon = NotificationIcons.AuthenticationError, +) : AppNotification(), SystemNotification, InAppNotification + +suspend fun AuthenticationErrorNotification(): AuthenticationErrorNotification = AuthenticationErrorNotification( + title = getString( + resource = Res.string.notification_authentication_error_title, + accountDisplayName, + ), + contentText = getString(resource = Res.string.notification_authentication_error_text), + channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid), +) +``` + +### Android‑only modules — use a factory class with Android Resources + +As Android modules require a context, we need to retrieve the resources in a factory class. You can inject the +`*ResourceManager` you need to use in the constructor: + +```kotlin +class AuthenticationErrorNotificationFactory( + private val strings: StringsResourceManager, +) { + fun create( + accountUuid: String, + accountDisplayName: String, + ): AuthenticationErrorNotification = AuthenticationErrorNotification( + title = strings.stringResource( + resourceId = R.string.notification_authentication_error_title, + accountDisplayName, + ), + contentText = strings.stringResource( + resourceId = R.string.notification_authentication_error_text + ), + channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid), + ) +} +``` + +--- + +## Displaying Notifications to the User + +### System Notifications — permission handling + +System notifications require `android.permission.POST_NOTIFICATIONS`. The module doesn't auto‑check; call a +`PermissionChecker` first and react accordingly. + +Example: + +```kotlin +class CheckPermission( + private val permissionChecker: PermissionChecker, +) : UseCase.CheckPermission { + override fun invoke(permission: Permission): PermissionState { + return permissionChecker.checkPermission(permission) + } +} + +class ViewModel(checkPermission: UseCase.CheckPermission) : BaseViewModel(initialState = State()) { + init { + updateState { + it.copy( + permissionState = when (checkPermission(Permission.Notifications)) { + PermissionState.GrantedImplicitly -> UiPermissionState.Unknown + PermissionState.Granted -> UiPermissionState.Granted + PermissionState.Denied -> UiPermissionState.Unknown + }, + ) + } + } +} +``` + +Once permission is granted, the OS renders the notification using the style you defined. + +### In‑App Notifications — `InAppNotificationScaffold` + +Wrap screens that need in‑app notifications with `InAppNotificationScaffold`, a superset of `Scaffold` with +notification‑aware layout. + +```kotlin +@Composable +fun InAppNotificationScaffold( + modifier: Modifier = Modifier, + // 1. + enabled: ImmutableSet = DisplayInAppNotificationFlag.AllNotifications, + // 2 + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + // 3 + snackbarHostState: SnackbarHostState = rememberSnackbarHostState(), + // 4 + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: ScaffoldFabPosition = ScaffoldFabPosition.End, + // 5 + onNotificationActionClick: (NotificationAction) -> Unit = {}, + // 6 + content: @Composable (PaddingValues) -> Unit, +) { /* ... */ } +``` + +**Parameter notes** + +1. Limit which styles this screen will show via `enabled` flags. +2. The `topBar`/`bottomBar` slot parameters work as in `Scaffold`. +3. Provide a `SnackbarHostState`; it is used for `SnackbarNotification` styles and any custom snackbars you display. +4. FAB params work as in `Scaffold`. +5. `onNotificationActionClick` is called when the user taps a in-app notification action. +6. **Consume the `PaddingValues`** in `content`; otherwise layout won't adapt correctly while the notifications are + shown. + +--- + +## Architecture — How it Works (Command Pattern) + +If you want to deep-dive into the Notification System Architecture, please refer to the +[Notification Architecture](docs/notification-architecture.md) documentation. This section summarizes the architecture. + +### Architecture - Summary +The system is split into logical groups: + +* **Client** — builds a `Notification` payload and calls the Invoker. Typically a `ViewModel`. +* **Invoker** — `NotificationSender`/`DefaultNotificationSender`; uses a factory to create commands and executes them. +* **Command** — encapsulates a `Notification` + `NotificationNotifier`, exposes `execute()`. +* **Receiver** — platform code that shows the notification (`SystemNotificationNotifier` uses `NotificationManager`; + `InAppNotificationNotifier` uses `BroadcastReceiver`). + +### Architecture - Why this design? + +* Decouples request from platform rendering. +* Enables unit testing and fake implementations. +* Makes adding new providers/styles straightforward. + +--- + +## Summary & Best Practices + +* Always pick the **right type** (System/In‑App) based on **foreground/background** timing; implement **both** when + needed. +* Always set **severity** to control intrusiveness and visual cues. +* Prefer **factories** for localized text; use **suspending** factory functions in KMP code. +* In app screens, wrap content in **`InAppNotificationScaffold`** and **consume padding**. +* For system notifications, check **`POST_NOTIFICATIONS`** permission first. +* Use styles deliberately: + * `BigText` for rich single updates; + * `Inbox` for digests; + * *Banner Global* for persistent global warnings; + * *Banner Inline* for contextual blockers; + * *Snackbar* for transient feedback; + * *Dialog* for must‑act prompts. + +--- + +## Appendix — Data Model (Reference) + +```mermaid +classDiagram + direction LR + class Notification { + <> + + accountUuid: String + + title: String + + message: String + + accessibilityText: String + + severity: NotificationSeverity + + createdAt: LocalDateTime + + actions: Set~NotificationAction~ + + icon: NotificationIcon + } + class SystemNotification { + <> + + subText: String? + + lockscreenNotification: LockscreenNotification + + channel: NotificationChannel + + systemNotificationStyle: SystemNotificationStyle + } + class LockscreenNotification { + <> + + notification: SystemNotification + + lockscreenNotificationAppearance: LockscreenNotificationAppearance + } + class InAppNotification { + <> + + inAppNotificationStyle: InAppNotificationStyle + } + class NotificationSeverity { + <> + Fatal Critical Temporary Warning Information + } + class NotificationAction { + + icon: NotificationIcon? + title: String + } + class NotificationIcon { + + systemNotificationIcon: SystemNotificationIcon? + + inAppNotificationIcon: ImageVector? + } + class NotificationChannel { + + id: String + + name: StringResource + + description: StringResource + + importance: NotificationChannelImportance + } + class NotificationChannelImportance { + <> + None + Min + Low + Default + High + } + class SystemNotificationStyle { + <> + } + class BigTextStyle { + + text: String + } + class InboxStyle { + + bigContentTitle: String + + summary: String + + lines: List~CharSequence~ + } + class SystemNotificationStyle.Undefined { } + class InAppNotificationStyle { <> } + class InAppNotificationStyle.Undefined { } + class SnackbarNotification { + duration: SnackbarDuration } + class BannerGlobalNotification { } + class BannerInlineNotification { } + + Notification --> NotificationSeverity + Notification --> NotificationAction + Notification --> NotificationIcon + NotificationAction --> NotificationIcon + Notification <|-- SystemNotification + Notification <|-- InAppNotification + SystemNotification --> SystemNotificationStyle + SystemNotification --> NotificationChannel + SystemNotification --> NotificationIcon + SystemNotification <--> LockscreenNotification + NotificationChannel --> NotificationChannelImportance + InAppNotification --> InAppNotificationStyle + InAppNotification --> NotificationIcon + SystemNotificationStyle <|-- SystemNotificationStyle.Undefined + SystemNotificationStyle <|-- BigTextStyle + SystemNotificationStyle <|-- InboxStyle + InAppNotificationStyle <|-- InAppNotificationStyle.Undefined + InAppNotificationStyle <|-- SnackbarNotification + InAppNotificationStyle <|-- BannerGlobalNotification + InAppNotificationStyle <|-- BannerInlineNotification +``` diff --git a/feature/notification/docs/notification-architecture.md b/feature/notification/docs/notification-architecture.md new file mode 100644 index 00000000000..6d1f1e30b5c --- /dev/null +++ b/feature/notification/docs/notification-architecture.md @@ -0,0 +1,510 @@ +# Thunderbird for Android Notification System - Architecture deep-dive + +This system is responsible for creating and dispatching all user-facing notifications, including system tray +notifications and in-app messages. + +At its core, this system uses the **Command Design Pattern**. The primary goal of this architecture is to **decouple** +the request for a notification from the underlying platform-specific code that displays it. This makes the system more +flexible, testable, and easier to extend. + +## Modules + +The notification system is organized in the following modules: + +- **api**: Core interfaces and classes +- **impl**: The implementation module +- **testing**: The testing helper module that provides common fake implementation. + +## Core Components + +The architecture is divided into four main logical groups: **Client**, **Invoker**, **Command**, and **Receiver** as +shown in the diagram: + +```mermaid +--- +config: + look: neo + layout: elk +--- +classDiagram + class Notification + +%% The Client that initiates the request + namespace Client { + class SomeAppViewModel { + - sender: NotificationSender + + onSendNotificationClicked() + } + } + +%% The Invoker and its implementation + namespace Invoker { + class NotificationSender { + <> + + send(notification: Notification) Flow~Outcome~ + } + class DefaultNotificationSender { + - commandFactory: NotificationCommandFactory + } + + %% The Factory for creating commands + class NotificationCommandFactory { + + create(notification: Notification) List~NotificationCommand~ + } + } + +%% The Command objects + namespace Command { + class NotificationCommand~Notification~ { + <> + # notification: Notification + # notifier: NotificationNotifier~Notification~ + + execute() Outcome + } + class SystemNotificationCommand + class InAppNotificationCommand + + class Outcome + class CommandOutcome { + <> + } + class CommandOutcomeSuccess { + <> + } + class CommandOutcomeFailure { + <> + + throwable: Throwable + } + } + +%% The Receivers that perform the action + namespace Receiver { + class NotificationNotifier~Notification~ { + <> + + show(id: NotificationId) + + dispose() + } + class SystemNotificationNotifier + class InAppNotificationNotifier + } + +%% External dependencies used by Receivers + namespace Platform Dependencies { + class NotificationManager + } + + class InAppNotificationEventBus + class NotificationRegistry + +%% Implementation and Inheritance + DefaultNotificationSender --|> NotificationSender + SystemNotificationCommand --|> NotificationCommand + InAppNotificationCommand --|> NotificationCommand + SystemNotificationNotifier --|> NotificationNotifier + InAppNotificationNotifier --|> NotificationNotifier + CommandOutcomeSuccess --|> CommandOutcome + CommandOutcomeFailure --|> CommandOutcome +%% Core Pattern Relationships + SomeAppViewModel "1" --* "1" NotificationSender: uses + SomeAppViewModel ..> Notification: creates + DefaultNotificationSender --> NotificationCommandFactory: uses + NotificationCommandFactory ..> SystemNotificationCommand: creates + NotificationCommandFactory ..> InAppNotificationCommand: creates + NotificationCommand "1" --* "1" NotificationNotifier: has-a + NotificationCommand ..> Outcome: returns +%% Receiver Dependencies + SystemNotificationNotifier ..> NotificationManager: uses + InAppNotificationNotifier ..> InAppNotificationEventBus: uses + InAppNotificationNotifier ..> NotificationRegistry: uses +%% Outcome Composition + Outcome --* "1" CommandOutcome +``` + +### The Client + +In the classic Command Pattern, the Client is often responsible for creating the command and setting its receiver. +However, in our implementation, the Client's role is simplified. + +- **Implementation:** Any `ViewModel` (e.g., `ProfileViewModel`, `SettingsViewModel`). +- **Responsibilities:** + - Constructs a concrete `Notification` data object based on user action or business logic. + - Holds a reference to the `NotificationSender` (the [Invoker](#the-invoker)). + - Calls `notificationSender.send()` to initiate the request. + - Consumes the `Flow` to react to the result. + +### The Invoker + +The **Invoker** holds a command and asks it to be executed. It is completely decoupled from the action itself. + +- **Implementation:** `NotificationSender` (Interface) and `DefaultNotificationSender` (Concrete Class). +- **Responsibilities:** + - The `DefaultNotificationSender` implements the `NotificationSender` interface. + - It uses the `NotificationCommandFactory` to get the correct command instances. + - It calls the `execute()` method on the command list it receives from the factory. + +### The Command + +The **Command** object encapsulates all the information required to act. + +- **Implementation:** `NotificationCommand` (abstract base), with concrete classes like `SystemNotificationCommand` and + `InAppNotificationCommand`. +- **Responsibilities:** + - Binds together a `Notification` (the payload) and a `NotificationNotifier` (the Receiver). + - Provides a common `execute()` interface that the Invoker can call without knowing the specific details of the + command. + +### The Receiver + +The **Receiver** knows how to perform the work required to carry out the request. It's where the business logic lives. + +- **Implementation:** `NotificationNotifier` (interface), with concrete classes like `SystemNotificationNotifier` and + `InAppNotificationNotifier`. +- **Responsibilities:** + - Contains the platform-specific implementation for displaying a notification. + - `SystemNotificationNotifier` uses the Android `NotificationManager`. + - `InAppNotificationNotifier` uses the `InAppNotificationEventBus` to tell the app that a notification is available + to be displayed. The `InAppNotificationScaffold` listens for this event and shows the notification. + +## The Notification Data Model + +The notification data model is represented by the `Notification` data model, which acts as the central payload for all +operations. Following, we will breakdown the notification model for better clarity. + +### Breakdown - The Notification model + +The notification model is composed by the following components: + +* The `Notification` interface, which defines the common properties that all notifications must have. This is a sealed + interface and can't be implemented outside it's package/module. +* The `SystemNotification` is a subtype of `Notification` that represents a notification displayed by the system, adding + it's own set of properties which is described in the SystemNotification model section. +* The `InAppNotification` is a subtype of `Notification` that represents a notification displayed within the + application, adding it's own set of properties which is described in the SystemNotification model section. +* The `AppNotification` is an abstract class that provides default properties implementation to easy the app + notification. **This is the class you should extend** whenever creating a new Notification type. + +The below diagram describes these components more detailed: + +```mermaid +classDiagram + class Notification { + <> + + accountUuid: String + + title: String + + accessibilityText: String + + contentText: String? + + severity: NotificationSeverity + + createdAt: LocalDateTime + + actions: Set~NotificationAction~ + + icon: NotificationIcon + } + class AppNotification { + <> + + accessibilityText: String + + createdAt: LocalDateTime + + actions: Set~NotificationAction~ + } + class SystemNotification { + <> + } + class InAppNotification { + <> + } + class NotificationSeverity { + <> + Fatal + Critical + Temporary + Warning + Information + } + class NotificationAction { + <> + + icon: NotificationIcon? + + title: String + } + class NotificationIcon { + <> + + systemNotificationIcon: SystemNotificationIcon? + + inAppNotificationIcon: ImageVector? + } + + Notification --> NotificationSeverity + Notification --> NotificationAction + Notification --> NotificationIcon + NotificationAction --> NotificationIcon + Notification <|-- AppNotification + Notification <|-- SystemNotification + Notification <|-- InAppNotification +``` + +The properties of the `Notification` interface are: + +* `accountUuid`: The UUID of the account that owns the notification. This can be used to filter notifications when + deciding to display them. +* `title`: The title of the notification. +* `accessibilityText`: The text to be used for accessibility purposes. +* `contentText`: The main content text of the notification, can be null. +* `severity`: The severity level of the notification. +* `createdAt`: The date and time when the notification was created. +* `actions`: A set of actions that can be performed on the notification. +* `icon`: The notification icon. + +### Breakdown - The System Notification model + +System notifications have their own particularities, which are described in this section. Aside from all the properties +included in the `Notification` interface, the `SystemNotification` also includes: + +* `subText`: An optional secondary text that appears below the title, can be null. +* `channel`: The notification channel which the notification belongs to. +* `systemNotificationStyle`: The style of the notification which will explain to Android OS how to display the + notification. Defaults to `SystemNotificationStyle.Undefined`. For more information about the styles, see + the [notification styles' documentation](notification-styles.md) +* `asLockscreenNotification()`: A method to convert the `SystemNotification` to a `LockscreenNotification`. You should + only override this if you need to display a different notification when displaying in the lockscreen, e.g. hiding the + sender's mail, notification content, etc. + +```mermaid +classDiagram + class Notification { + <> + } + class SystemNotification { + <> + + subText: String? + + channel: NotificationChannel + + systemNotificationStyle: SystemNotificationStyle + + asLockscreenNotification(): LockscreenNotification? + } + class LockscreenNotification { + <> + + notification: SystemNotification + + lockscreenNotificationAppearance: LockscreenNotificationAppearance + } + class LockscreenNotificationAppearance { + <> + None + AppName + Public + MessageCount + } + class NotificationChannel { + <> + + id: String + + name: StringResource + + description: StringResource + + importance: NotificationChannelImportance + } + class NotificationChannelImportance { + <> + None + Min + Low + Default + High + } + class SystemNotificationStyle { + <> + BigTextStyle + InboxStyle + Undefined + } + class `SystemNotificationStyle.BigTextStyle` { + <> + + text: String + } + class `SystemNotificationStyle.InboxStyle` { + <> + + bigContentTitle: String + + summary: String + + lines: List~CharSequence~ + } + + Notification <|-- SystemNotification + SystemNotification --> SystemNotificationStyle + SystemNotification --> NotificationChannel + SystemNotification <--> LockscreenNotification + LockscreenNotification --> LockscreenNotificationAppearance + SystemNotificationStyle <|-- `SystemNotificationStyle.BigTextStyle` + SystemNotificationStyle <|-- `SystemNotificationStyle.InboxStyle` + NotificationChannel --> NotificationChannelImportance +``` + +### Breakdown - The In-App Notification model + +In-app notifications have their own particularities, which are described in this section. Aside from all the properties +included in the `Notification` interface, the `InAppNotification` also includes: + +* `inAppNotificationStyle`: The style of the in-app notification. This will explain to the UI how to display the + notification. For more information about the styles, see the [notification styles' documentation](notification-styles.md). + +```mermaid +classDiagram + class Notification { + <> + } + class InAppNotification { + <> + + inAppNotificationStyle: InAppNotificationStyle + } + class InAppNotificationStyle { + <> + BannerGlobalNotification + BannerInlineNotification + DialogNotification + SnackbarNotification + Undefined + } + class `InAppNotificationStyle.SnackbarNotification` { + <> + + duration: SnackbarDuration + } + + Notification <|-- InAppNotification + InAppNotification --> InAppNotificationStyle + InAppNotificationStyle <|-- `InAppNotificationStyle.SnackbarNotification` +``` + +## Summary and Diagram + +* **Core `Notification` Interface**: At the top level is the `Notification` interface, which contains properties common + to all notification types, such as `title`, `text`, `severity`, and a list of `NotificationAction`s. The + `Notification` interface should never be directly implemented. +* **Abstract `AppNotification` Class**: This is an abstract class that provides default properties implementation to + easy the app notification creation. +* **Specialized Notification Types**: To handle platform differences, the base interface is extended by two specialized + interfaces: + * **`SystemNotification`**: Represents a standard Android OS notification. It includes properties for + Android-specific features like the `NotificationChannel` and `NotificationChannelImportance`. Additionally it also + let you change how to display the notification via `SystemNotificationStyle`. + * **`InAppNotification`**: Represents a message shown inside the app's UI. It includes its own + `InAppNotificationStyle`. +* **Flexible Styling and Actions**: A key feature of the model is its use of polymorphism for styling. This allows the + UI to be defined by data, not hard-coded logic. + * `SystemNotificationStyle` can be a `BigTextStyle` or `InboxStyle`, mapping to native Android features. + * `InAppNotificationStyle` can be `BannerGlobalNotification`, `BannerInlineNotification`, `DialogNotification`, and + `SnackbarNotification`. + +The whole notification model is represented by the following diagram: +```mermaid +classDiagram + class Notification { + <> + + accountUuid: String + + title: String + + accessibilityText: String + + contentText: String? + + severity: NotificationSeverity + + createdAt: LocalDateTime + + actions: Set~NotificationAction~ + + icon: NotificationIcon + } + class AppNotification { + <> + + accessibilityText: String + + createdAt: LocalDateTime + + actions: Set~NotificationAction~ + } + class NotificationSeverity { + <> + Fatal + Critical + Temporary + Warning + Information + } + class NotificationAction { + <> + + icon: NotificationIcon? + + title: String + } + class NotificationIcon { + <> + + systemNotificationIcon: SystemNotificationIcon? + + inAppNotificationIcon: ImageVector? + } + class SystemNotification { + <> + + subText: String? + + channel: NotificationChannel + + systemNotificationStyle: SystemNotificationStyle + + asLockscreenNotification(): LockscreenNotification? + } + class LockscreenNotification { + <> + + notification: SystemNotification + + lockscreenNotificationAppearance: LockscreenNotificationAppearance + } + class LockscreenNotificationAppearance { + <> + None + AppName + Public + MessageCount + } + class InAppNotification { + <> + + inAppNotificationStyle: InAppNotificationStyle + } + class NotificationChannel { + <> + + id: String + + name: StringResource + + description: StringResource + + importance: NotificationChannelImportance + } + class NotificationChannelImportance { + <> + None + Min + Low + Default + High + } + class SystemNotificationStyle { + <> + BigTextStyle + InboxStyle + Undefined + } + class `SystemNotificationStyle.BigTextStyle` { + <> + + text: String + } + class `SystemNotificationStyle.InboxStyle` { + <> + + bigContentTitle: String + + summary: String + + lines: List~CharSequence~ + } + class InAppNotificationStyle { + <> + BannerGlobalNotification + BannerInlineNotification + DialogNotification + SnackbarNotification + Undefined + } + class `InAppNotificationStyle.SnackbarNotification` { + <> + + duration: SnackbarDuration + } + + Notification --> NotificationSeverity + Notification --> NotificationAction + Notification --> NotificationIcon + NotificationAction --> NotificationIcon + Notification <|-- AppNotification + Notification <|-- SystemNotification + Notification <|-- InAppNotification + SystemNotification --> SystemNotificationStyle + SystemNotification --> NotificationChannel + SystemNotification <--> LockscreenNotification + LockscreenNotification --> LockscreenNotificationAppearance + SystemNotificationStyle <|-- `SystemNotificationStyle.BigTextStyle` + SystemNotificationStyle <|-- `SystemNotificationStyle.InboxStyle` + NotificationChannel --> NotificationChannelImportance + InAppNotification --> InAppNotificationStyle + InAppNotificationStyle <|-- `InAppNotificationStyle.SnackbarNotification` +``` + diff --git a/feature/notification/docs/notification-styles.md b/feature/notification/docs/notification-styles.md new file mode 100644 index 00000000000..53210d05b57 --- /dev/null +++ b/feature/notification/docs/notification-styles.md @@ -0,0 +1,335 @@ +# Thunderbird for Android Notification System - Notification Styles deep-dive + +## System Notifications Styles + +During the creation of a `SystemNotification` implementation, you can decide if you want to have a custom look by +overriding the `systemNotificationStyle` property. + +By default, all System notifications are displayed by showing: + +- An Icon +- A title +- A content text. + +![system-notification-basic-style.png](../../../docs/assets/notification-system/system-notification-basic-style.png) + +Meaning that its style is always `Undefined` (Basic notification), unless specified. + +We currently support the following styles: + +- `Undefined` (default) +- `BigTextStyle` +- `InboxStyle` + +Next, we will show how to define each of the custom styles with examples. + +**BigTextStyle:** +The `BigTextStyle` allows the app to display a larger block of text in the expanded content area of the notification. + +The following code is how to define the System Notification with the `BigTextStyle` as its style: + +```kotlin +data class NewMailSingleMail( + // 1. + override val accountUuid: String, + val accountName: String, + val summary: String, + val sender: String, + val subject: String, + val preview: String, + // 2. + override val icon: NotificationIcon = NotificationIcons.NewMailSingleMail, +) : MailNotification() { + // 3. + override val title: String = sender + + // 4. + override val contentText: String = subject + + // 5. + override val systemNotificationStyle: SystemNotificationStyle = + systemNotificationStyle { + bigText(preview) + } +} +``` + +1. We first define all the data we need to create our system notification +2. We set our icon, which will be shown in the system tray bar (Android 16+ will display only in the system tray if not + expanded) +3. The `title` property is used to display the first text line in the notification, which we choose to be the `sender` + this time +4. The `contentText` property is used to display the notification's content text when the notification is in the + collapsed mode +5. Finally, we define that this System Notification will have the BigTextStyle, passing the `preview` String as a + parameter, which is used to display the notification's content text when the notification is in the expanded mode + +**System Notification with BigTextStyle collapsed:** +![big-text-collapsed-system-notification.png](../../../docs/assets/notification-system/big-text-collapsed-system-notification.png) + +**System Notification with BigTextStyle expanded:** +![big-text-expanded-system-notification-style.png](../../../docs/assets/notification-system/big-text-expanded-system-notification-style.png) + +> [!IMPORTANT] +> The System Notification UI may vary between Android OS versions and OEMs, but in general, they will always have the +> same look and feel, with some differences. +> +> The above screenshots were taken using Android 16 and a Pixel 7 Pro. + +**InboxStyle:** +The `InboxStyle` is designed to be used when we need to display multiple short summary lines, such as snippets from +incoming emails, grouping them all into one notification. + +The following code is how to define the System Notification with the `InboxStyle` as its style: + +```kotlin +@ConsistentCopyVisibility +data class NewMailSummaries private constructor( + override val accountUuid: String, + // 1.1. + override val title: String, + // 1.2. + override val contentText: String, + // 2.1 + val expandedTitle: String, + // 2.2 + val summary: String, + // 2.3 + val lines: List, + // 3. + override val icon: NotificationIcon = NotificationIcons.NewMailSummaries, +) : MailNotification() { + // 4. + override val systemNotificationStyle: SystemNotificationStyle = systemNotificationStyle { + inbox { + title(expandedTitle) + summary(summary) + lines(lines = lines.toTypedArray()) + } + } + + // 5. + companion object { + suspend operator fun invoke( + accountUuid: String, + accountDisplayName: String, + previews: List, + ): NewMailSummaries = NewMailSummaries( + accountUuid = accountUuid, + title = getPluralString( + resource = Res.strings.new_mail_summaries_collapsed_title, + quantity = messageSummaries.size, + messageSummaries.size, + accountDisplayName, + ), + contentText = getString(Res.strings.new_mail_summaries_content_text), + expandedTitle = getPluralString( + resource = Res.strings.new_mail_summaries_expanded_title, + quantity = messageSummaries.size, + messageSummaries.size, + ), + summary = getString( + resource = Res.strings.new_mail_summaries_additional_messages, + messageSummaries.size, + accountDisplayName, + ), + lines = previews, + ) + } +} +``` + +```xml + + + + You've received %1$d new message on %2$s + You've received %1$d new messages on %2$s + + Expand to preview + + %1$d new message + %1$d new messages + + + %1$d more on %2$s + +``` + +1. We first define all the default data we need to create our system notification: + 1. The `title` property is used to display the first text line in the notification when the notification is + collapsed + 2. The `contentText` property is used to display the notification's content text when the notification is in the + collapsed mode +2. Now we define the data used to fill our custom style, `InboxStyle`: + 1. The `expandedTitle` property is used to display the first text line in the notification when the notification is + expanded + 2. The `summary` property is used to display the notification's first line of text after the detail section in the + big form of the template. + 3. The `lines` property is used to display the previews in the digest section of the Inbox notification. +3. We set our icon, which will be shown in the system tray bar (Android 16+ will display only in the system tray if not + expanded) +4. We now define that this System Notification will have the `InboxStyle`, via the `systemNotificationStyle` DSL + function, consuming all the data we received via the constructor. +5. As some of the content from this notification is composed by a string/plural resource, we need to use a factory + function. See more information + in [Using String/Plural Resources to compose the notification](#using-string-plural-resources-to-compose-the-notification). + +## In-app Notifications Styles + +During the creation of an `InAppNotification` implementation, you need to choose how the notification will appear to the +user. Currently, we support the following in-app notification styles: + +- Banner Global +- Banner Inline +- Snackbar +- Dialog + +More In-App notification styles might be introduced in the future. If that happens, we will update this section. + +### Banner Global Notification + +| Error | Warning | Success | Info | +|:----------------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------------:| +| ![in-app-banner-global-error.png](../../../docs/assets/notification-system/in-app-banner-global-error.png) | ![in-app-banner-global-info.png](../../../docs/assets/notification-system/in-app-banner-global-info.png) | ![in-app-banner-global-success.png](../../../docs/assets/notification-system/in-app-banner-global-success.png) | ![in-app-banner-global-warning.png](../../../docs/assets/notification-system/in-app-banner-global-warning.png) | + +Used to maintain user awareness of a persistent, irregular state of the application without interrupting the primary +flow. This component is appropriate for warnings that apply globally across the app. +If the warning is caused by a critical error, a [Banner Inline Notification](#banner-inline-notification) should also be +shown in the relevant context (e.g., the message list) to guide direct resolution. + +#### Usage Guidelines + +**Use for:** + +- Persistent application states that affect the current screen +- In account configuration flows, to display: +- Errors, success, or informational messages that require a constant on-screen indicator +- Outside of account configuration, for global warnings such as: +- Being offline +- Encryption being unavailable + +**Do not use for:** + +- Errors, success, or informational messages outside the account configuration flow + (use [Banner Inline Notification](#banner-inline-notification) or other transient messaging components instead) +- Warnings that must interrupt the user’s flow or require immediate action (consider using + a [Dialog Notification](#dialog-notification) in these cases) + +### Banner Inline Notification + +![in-app-banner-inline-item.png](../../../docs/assets/notification-system/in-app-banner-inline-item.png) + +Use inline error banners to surface issues that must be resolved before the user can continue with the main task or +content on the screen. + +#### Usage Guidelines + +**Use for:** + +- Critical errors that disrupt a function of the screen’s functionality +- Errors that require user attention but do not completely block their ability to continue interacting with the app + +**Do not use for:** + +- Blocking errors that must halt the user’s flow until resolved (consider using + a [Dialog Notification](#dialog-notification) instead) +- Global or persistent application states that should be shown across all screens (consider using + a [Banner Global In-app Notification](#banner-global-notification)) +- Secondary or surface-level errors caused by a deeper issue (e.g., inability to encrypt is a warning, while the missing + encryption key is the actual error) +- Non-error messages, such as warnings, success confirmations, or informational notices these will use a different + component and are not part of the in-app error banner pattern. + +### Snackbar Notification + +![in-app-snackbar.png](../../../docs/assets/notification-system/in-app-snackbar.png) + +Snackbars are used to inform the user of an error or process outcome, and may optionally offer +a related action. They appear temporarily without interrupting the user's current task. + +#### Usage Guidelines + +**Use for:** + +- Providing feedback when an action fails, with the option for the user to take corrective action + **Do not use for:** +- Errors that must interrupt the user’s flow or block further interaction (use + a [Dialog Notification](#dialog-notification) in these cases) +- Account sync error feedback in the Unified Inbox (use a [Banner Inline Notification](#banner-inline-notification) + or [Banner Global In-app Notification](#banner-global-notification) for that context) + +### Dialog Notification + +![in-app-dialog.png](../../../docs/assets/notification-system/in-app-dialog.png) + +Used to inform the user about a required permission needed to enable or complete a key feature of the app. +The dialog provides a concise explanation of the need for the permission and prompts the user to grant it. + +#### Usage Guidelines + +**Use for:** + +- Requesting notification permission from the user +- Clearly and succinctly explaining why the permission is needed and how it impacts the app experience + +**Do not use for:** + +- Displaying errors +- Requesting contacts permission, as missing access does not critically affect app functionality +- Requesting background activity permission related to battery saver, since the app cannot reliably detect the current + permission state + +## Severity Levels & In-app Notification Style Differences + +Depending on the Notification Severity, the behaviour of the notification will be different. That could also affect how +the notification is displayed to the user. In this section we explain each style change based in the severity of the +notification. + +> [!IMPORTANT] +> +> Currently, the Notification severity only impacts the style of in-app notifications. + +### Notification Severity: Fatal + +| Notification Style | Can use style | Changes | Screenshot | +|--------------------|:-------------:|----------------------------------------------|------------------------------------------------------------------------------------------------------------| +| Banner Global | ✅ | The banner will use the error color scheme | ![in-app-banner-global-error.png](../../../docs/assets/notification-system/in-app-banner-global-error.png) | +| Banner Inline | ✅ | No style changes are expected for this style | | +| Snackbar | ❌ | | | +| Dialog | ✅ | No style changes are expected for this style | | + +### Notification Severity: Critical + +| Notification Style | Can use style | Changes | Screenshot | +|--------------------|:-------------:|----------------------------------------------|------------------------------------------------------------------------------------------------------------| +| Banner Global | ✅ | The banner will use the error color scheme | ![in-app-banner-global-error.png](../../../docs/assets/notification-system/in-app-banner-global-error.png) | +| Banner Inline | ✅ | No style changes are expected for this style | | +| Snackbar | ❌ | | | +| Dialog | ✅ | No style changes are expected for this style | | + +### Notification Severity: Warning + +| Notification Style | Can use style | Changes | Screenshot | +|--------------------|:-------------:|----------------------------------------------|----------------------------------------------------------------------------------------------------------------| +| Banner Global | ✅ | The banner will use the warning color scheme | ![in-app-banner-global-warning.png](../../../docs/assets/notification-system/in-app-banner-global-warning.png) | +| Banner Inline | ❌ | | | +| Snackbar | ✅ | No style changes are expected for this style | | +| Dialog | ❌ | | | + +### Notification Severity: Temporary + +| Notification Style | Can use style | Changes | Screenshot | +|--------------------|:-------------:|--------------------------------------------------|----------------------------------------------------------------------------------------------------------| +| Banner Global | ✅ | The banner will use the information color scheme | ![in-app-banner-global-info.png](../../../docs/assets/notification-system/in-app-banner-global-info.png) | +| Banner Inline | ❌ | | | +| Snackbar | ✅ | No style changes are expected for this style | | +| Dialog | ❌ | | | + +### Notification Severity: Information + +| Notification Style | Can use style | Changes | Screenshot | +|--------------------|:-------------:|--------------------------------------------------|----------------------------------------------------------------------------------------------------------| +| Banner Global | ✅ | The banner will use the information color scheme | ![in-app-banner-global-info.png](../../../docs/assets/notification-system/in-app-banner-global-info.png) | +| Banner Inline | ❌ | | | +| Snackbar | ✅ | No style changes are expected for this style | | +| Dialog | ❌ | | |