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
- Overview
- Notification Modes
- NtfManager
- Notification Service Extension (NSE)
- Token Lifecycle
- Notification Categories & Actions
- Badge Management
- 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:
userNotificationCenter(didReceive:)delegate method fires- If app is active: calls
processNotificationResponse()immediately - If app is inactive: stores in
ChatModel.notificationResponsefor later processing processNotificationResponse():- Extracts
userIdfromuserInfo-- switches user if needed - Extracts
chatId-- navigates to the conversation - Handles action identifiers (accept contact, accept/reject call)
- Extracts
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
appStateGroupDefaultbefore processing - If main app is
.active, NSE may skip processing (main app handles notifications directly) - NSE uses
chat_close_store/chat_reopen_storefor 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
.offnotification 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:
processNotificationResponse()detectsntfActionAcceptContact- Calls
apiAcceptContact(incognito: false, contactReqId:) - 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:
processNotificationResponse()detects the action- Sets
ChatModel.ntfCallInvitationAction = (chatId, .accept/.reject) - 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:
BGManager.schedule()is called when app enters background- iOS wakes the app in the background approximately every 20 minutes
BGAppRefreshTaskhandler:- 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
- Activates the chat engine:
- 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 |