Back to Thunderbird Android

Thunderbird for Android Notification System

feature/notification/README.md

6.90420.7 KB
Original Source

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

CapabilitySystemNotificationInAppNotification
Appears when app in background
Appears when app in foreground
Requires runtime permission✅ (POST_NOTIFICATIONS)
UI control lives inAndroid OSApp (Compose)
Typical usesNew mail, background errorsGuidance, 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 documentation.


Severity Levels & Behavioral Differences

Every notification must define a NotificationSeverity to drive user intrusiveness and styling.

SeverityWhen to useExpected user actionSystemNotification behaviorInApp (BannerGlobal) color cue
FatalBlocks essential tasksImmediate resolutionNot dismissableError colours
CriticalDisrupts core flowsUsually requires actionNot dismissableError colours
WarningPotential issue/limitationOften recommendedDismissableWarning colours
TemporaryTemporary disruption/delayMay self‑resolve; informDismissableInformation colours
InformationStatus/context onlyNone requiredDismissableInformation colours

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:

System Notification with BigTextStyle expanded:

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<CharSequence>,
    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<CharSequence>,
        ): 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.

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> = 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 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.
  • InvokerNotificationSender/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 {
        <<interface>>
        + accountUuid: String
        + title: String
        + message: String
        + accessibilityText: String
        + severity: NotificationSeverity
        + createdAt: LocalDateTime
        + actions: Set~NotificationAction~
        + icon: NotificationIcon
    }
    class SystemNotification {
        <<interface>>
        + subText: String?
        + lockscreenNotification: LockscreenNotification
        + channel: NotificationChannel
        + systemNotificationStyle: SystemNotificationStyle
    }
    class LockscreenNotification {
        <<data>>
        + notification: SystemNotification
        + lockscreenNotificationAppearance: LockscreenNotificationAppearance
    }
    class InAppNotification {
        <<interface>>
        + inAppNotificationStyle: InAppNotificationStyle
    }
    class NotificationSeverity {
        <<enum>>
        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 {
        <<enum>>
        None
        Min
        Low
        Default
        High
    }
    class SystemNotificationStyle {
        <<sealed>>
    }
    class BigTextStyle {
        + text: String
    }
    class InboxStyle {
        + bigContentTitle: String
        + summary: String
        + lines: List~CharSequence~
    }
    class SystemNotificationStyle.Undefined { }
    class InAppNotificationStyle { <<sealed>> }
    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