Files
simplex-chat/apps/ios/spec/services/notifications.md
2026-02-19 10:58:16 +00:00

18 KiB

SimpleX Chat iOS -- Push Notification Service

Technical specification for the notification system: NtfManager, Notification Service Extension (NSE), notification modes, and token lifecycle.

Related specs: Architecture | API Reference | Navigation | README Related product: Product Overview

Source: NtfManager.swift | BGManager.swift | Notifications.swift | [NotificationService.swift](../../SimpleX NSE/NotificationService.swift)


Table of Contents

  1. Overview
  2. Notification Modes
  3. NtfManager
  4. Notification Service Extension (NSE)
  5. Token Lifecycle
  6. Notification Categories & Actions
  7. Badge Management
  8. Background Tasks (BGManager)

1. Overview

SimpleX Chat uses a privacy-preserving notification architecture. Because messages are end-to-end encrypted and the notification server never sees message content, the app uses a Notification Service Extension (NSE) to decrypt push payloads on-device before displaying notifications.

APNs Push → NSE receives encrypted payload
         → NSE starts Haskell core (own chat_ctrl)
         → NSE decrypts message using stored keys
         → NSE creates UNNotificationContent with decrypted preview
         → iOS displays notification to user

The notification system has three modes of operation, allowing users to choose their privacy/convenience tradeoff.


2. Notification Modes

Mode Description Mechanism
Instant Real-time notifications via Apple Push APNs push triggers NSE, which decrypts and displays
Periodic Background fetch every ~20 minutes BGAppRefreshTask wakes app, checks for new messages
Off No notifications User must open app to see messages

Configuration

Notification mode is set via:

ChatCommand.apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)

NotificationsMode enum: .instant, .periodic, .off

The mode is stored in ChatModel.notificationMode and persisted in GroupDefaults.


3. NtfManager

File: Shared/Model/NtfManager.swift

Central notification coordinator. Singleton: NtfManager.shared.

Class Definition

class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
    static let shared = NtfManager()
    public var navigatingToChat = false
    private var granted = false
    private var prevNtfTime: Dictionary<ChatId, Date> = [:]
}

Key Responsibilities

Method Purpose Line
registerCategories() Registers notification action categories with iOS 156
requestAuthorization() Requests notification permission from user 215
setNtfBadgeCount(_:) Updates app icon badge 264
processNotificationResponse(_:) Handles user interaction with notification 54
notifyContactRequest(_:) Shows contact request notification 239
notifyCallInvitation(_:) Shows incoming call notification 258
notifyMessageReceived(_:) Shows message received notification 250

Notification Response Processing

When user taps a notification:

  1. userNotificationCenter(didReceive:) delegate method fires
  2. If app is active: calls processNotificationResponse() immediately
  3. If app is inactive: stores in ChatModel.notificationResponse for later processing
  4. processNotificationResponse():
    • Extracts userId from userInfo -- switches user if needed
    • Extracts chatId -- navigates to the conversation
    • Handles action identifiers (accept contact, accept/reject call)

Rate Limiting

prevNtfTime dictionary prevents notification flooding:

  • Each chat has a timestamp of its last notification
  • New notifications are suppressed if within ntfTimeInterval (1 second) of the previous one for the same chat

4. Notification Service Extension (NSE)

File: [SimpleX NSE/NotificationService.swift](../../SimpleX NSE/NotificationService.swift)

Architecture

The NSE is a separate process that iOS launches when a push notification arrives. It has:

  • Its own Haskell runtime instance (chat_ctrl)
  • Shared database access (via app group container)
  • ~30 second execution window per notification
  • No access to main app's in-memory state

[Processing Flow](../../SimpleX NSE/NotificationService.swift#L300)

1. didReceive(request:, withContentHandler:)                          L300
   ├── 2. Initialize Haskell core (if not already running)
   │       └── chat_migrate_init_key() with shared DB path            L861
   ├── 3. Decode encrypted notification payload
   │       └── apiGetNtfConns(nonce:, encNtfInfo:)                    L1123
   ├── 4. Fetch and decrypt messages
   │       └── apiGetConnNtfMessages(connMsgReqs:)                    L1140
   ├── 5. Create notification content
   │       ├── Contact name as title
   │       ├── Decrypted message preview as body
   │       └── Thread identifier for grouping
   └── 6. Deliver to content handler

NSE Commands

The NSE uses a subset of the chat API:

Command Purpose Line
[apiGetNtfConns(nonce:, encNtfInfo:)](../../SimpleX NSE/NotificationService.swift#L1123) Decrypt notification connection info [1123](../../SimpleX NSE/NotificationService.swift#L1123)
[apiGetConnNtfMessages(connMsgReqs:)](../../SimpleX NSE/NotificationService.swift#L1140) Fetch messages for notification connections [1140](../../SimpleX NSE/NotificationService.swift#L1140)

Database Coordination

  • NSE checks appStateGroupDefault before processing
  • If main app is .active, NSE may skip processing (main app handles notifications directly)
  • NSE uses chat_close_store / chat_reopen_store for safe concurrent access

Preview Modes

NotificationPreviewMode controls what the NSE shows:

Mode Title Body
.message Contact name Message text
.contact Contact name "New message"
.hidden "SimpleX" "New message"

Key Internal Types

Type Purpose Line
[NSENotificationData](../../SimpleX NSE/NotificationService.swift#L27) Enum of possible notification payloads [27](../../SimpleX NSE/NotificationService.swift#L27)
[NSEThreads](../../SimpleX NSE/NotificationService.swift#L82) Concurrency coordinator for multiple NSE instances [82](../../SimpleX NSE/NotificationService.swift#L82)
[NotificationEntity](../../SimpleX NSE/NotificationService.swift#L245) Per-connection processing state [245](../../SimpleX NSE/NotificationService.swift#L245)
[NotificationService](../../SimpleX NSE/NotificationService.swift#L287) Main NSE class (UNNotificationServiceExtension) [287](../../SimpleX NSE/NotificationService.swift#L287)
[NSEChatState](../../SimpleX NSE/NotificationService.swift#L781) Singleton managing NSE lifecycle state [781](../../SimpleX NSE/NotificationService.swift#L781)

Key Internal Functions

Function Purpose Line
[startChat()](../../SimpleX NSE/NotificationService.swift#L836) Initializes Haskell core for NSE [836](../../SimpleX NSE/NotificationService.swift#L836)
[doStartChat()](../../SimpleX NSE/NotificationService.swift#L861) Performs actual chat initialization (migration, config) [861](../../SimpleX NSE/NotificationService.swift#L861)
[activateChat()](../../SimpleX NSE/NotificationService.swift#L907) Reactivates suspended chat controller [907](../../SimpleX NSE/NotificationService.swift#L907)
[suspendChat(_:)](../../SimpleX NSE/NotificationService.swift#L921) Suspends chat controller with timeout [921](../../SimpleX NSE/NotificationService.swift#L921)
[receiveMessages()](../../SimpleX NSE/NotificationService.swift#L954) Main message-receive loop [954](../../SimpleX NSE/NotificationService.swift#L954)
[receivedMsgNtf(_:)](../../SimpleX NSE/NotificationService.swift#L1003) Maps chat events to notification data [1003](../../SimpleX NSE/NotificationService.swift#L1003)
[receiveNtfMessages(_:)](../../SimpleX NSE/NotificationService.swift#L403) Orchestrates notification message fetch and delivery [403](../../SimpleX NSE/NotificationService.swift#L403)
[deliverBestAttemptNtf()](../../SimpleX NSE/NotificationService.swift#L604) Delivers the best available notification content [604](../../SimpleX NSE/NotificationService.swift#L604)
didReceive(_:withContentHandler:) Main NSE entry point -- processes incoming notification 300

5. Token Lifecycle

Registration Flow

1. App starts → AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken
   └── ChatModel.deviceToken = token

2. Token registration (when chat running and token available):
   └── apiRegisterToken(token, notificationMode)
       └── Response: ntfToken(token, status, ntfMode, ntfServer)
           └── ChatModel.tokenStatus = status

3. Token verification (if server requires):
   └── apiVerifyToken(token, nonce, code)
       └── ChatModel.tokenRegistered = true

4. Token check (periodic):
   └── apiCheckToken(token)
       └── Updates ChatModel.tokenStatus

Token States (NtfTknStatus)

Status Description
.new Token just registered, not yet verified
.registered Token registered with notification server
.confirmed Token confirmed and ready
.active Token actively receiving notifications
.expired Token expired, needs re-registration
.invalid Token invalid, needs new registration
.invalidBad Token invalid due to bad data
.invalidTopic Token invalid due to wrong topic
.invalidExpired Token invalid because it expired
.invalidUnregistered Token invalid, was unregistered

Token Deletion

ChatCommand.apiDeleteToken(token: DeviceToken)

Called when:

  • User switches to .off notification mode
  • User deletes their profile
  • Token becomes invalid and needs replacement

6. Notification Categories & Actions

Registered in NtfManager.registerCategories():

Contact Request Category

// Category: "NTF_CAT_CONTACT_REQUEST"
// Actions:
//   - "NTF_ACT_ACCEPT_CONTACT": Accept contact request

When user taps "Accept" on a contact request notification:

  1. processNotificationResponse() detects ntfActionAcceptContact
  2. Calls apiAcceptContact(incognito: false, contactReqId:)
  3. Navigates to the new contact's chat

Call Invitation Category

// Category: "NTF_CAT_CALL_INVITATION"
// Actions:
//   - "NTF_ACT_ACCEPT_CALL": Accept incoming call
//   - "NTF_ACT_REJECT_CALL": Reject incoming call

When user taps "Accept" / "Reject" on a call notification:

  1. processNotificationResponse() detects the action
  2. Sets ChatModel.ntfCallInvitationAction = (chatId, .accept/.reject)
  3. Call controller picks up the pending action

Message Category

Standard tap-to-open behavior navigates to the chat.

Many Events Category

Batch notification for multiple events -- navigates to the app without specific chat context.


7. Badge Management

The app icon badge shows the total unread message count:

// Updated when:
// 1. App enters background:
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())

// 2. Messages are read:
// Badge is recalculated and updated

// 3. NSE receives notification:
// NSE updates badge based on its count

totalUnreadCountForAllUsers() sums unread counts across all user profiles (not just the active user).

NSE Badge Handling

Method Purpose Line
[setBadgeCount()](../../SimpleX NSE/NotificationService.swift#L592) Increments badge via ntfBadgeCountGroupDefault [592](../../SimpleX NSE/NotificationService.swift#L592)
setNtfBadgeCount(_:) Sets badge on UIApplication 264
changeNtfBadgeCount(by:) Adjusts badge by delta 270

8. Background Tasks

File: Shared/Model/BGManager.swift

BGManager

class BGManager {
    static let shared = BGManager()
    func register()    // Register BGAppRefreshTask handlers
    func schedule()    // Schedule next background refresh
}
Method Purpose Line
register() Registers BGAppRefreshTask handler with iOS 38
schedule() Schedules next background refresh request 46
handleRefresh(_:) Processes background refresh task 74
completionHandler(_:) Creates completion callback with cleanup 95
receiveMessages(_:) Activates chat and receives pending messages 112

Background Refresh (Periodic Mode)

When notification mode is .periodic:

  1. BGManager.schedule() is called when app enters background
  2. iOS wakes the app in the background approximately every 20 minutes
  3. BGAppRefreshTask handler:
    • Activates the chat engine: apiActivateChat(restoreChat: true)
    • Checks for new messages
    • Creates local notifications for any new messages
    • Suspends chat: apiSuspendChat(timeoutMicroseconds:)
    • Schedules next refresh
  4. Must complete within ~30 seconds or iOS terminates the task

Background Task Protection

All API calls use beginBGTask() / endBackgroundTask() to request extra execution time:

func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
    var id: UIBackgroundTaskIdentifier!
    // ...
    id = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask)
    return endTask
}

Maximum task duration: maxTaskDuration = 15 seconds.


Notification Content Builders

File: SimpleXChat/Notifications.swift

Function Purpose Line
createContactRequestNtf() Builds notification for incoming contact request L27
createContactConnectedNtf() Builds notification for contact connected event L46
createMessageReceivedNtf() Builds notification for received message L66
createCallInvitationNtf() Builds notification for incoming call L86
createConnectionEventNtf() Builds notification for connection events L102
createErrorNtf() Builds notification for database/encryption errors L134
createAppStoppedNtf() Builds notification when app is stopped L160
createNotification() Generic notification builder (used by all above) L175
hideSecrets() Redacts secret-formatted text in previews L200

Source Files

File Path
Notification manager Shared/Model/NtfManager.swift
Background manager Shared/Model/BGManager.swift
Notification types SimpleXChat/Notifications.swift
NSE service [SimpleX NSE/NotificationService.swift](../../SimpleX NSE/NotificationService.swift)
App delegate (token) Shared/AppDelegate.swift
Notification settings UI Shared/Views/UserSettings/NotificationsView.swift