generated from element-hq/.github
-
Notifications
You must be signed in to change notification settings - Fork 108
/
NotificationServiceExtension.swift
302 lines (249 loc) · 13.1 KB
/
NotificationServiceExtension.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import CallKit
import Intents
import MatrixRustSDK
import UserNotifications
// The lifecycle of the NSE looks something like the following:
// 1) App receives notification
// 2) System creates an instance of the extension class
// and calls `didReceive` in the background
// 3) Extension processes messages / displays whatever
// notifications it needs to
// 4) Extension notifies its work is complete by calling
// the contentHandler
// 5) If the extension takes too long to perform its work
// (more than 30s), it will be notified and immediately
// terminated
//
// Note that the NSE does *not* always spawn a new process to
// handle a new notification and will also try and process notifications
// in parallel. `didReceive` could be called twice for the same process,
// but it will always be called on different threads. It may or may not be
// called on the same instance of `NotificationService` as a previous
// notification.
//
// We keep a global `environment` singleton to ensure that our app context,
// database, logging, etc. are only ever setup once per *process*
private let settings: CommonSettingsProtocol = AppSettings()
private let notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()), destination: .notification),
settings: settings)
private let keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
class NotificationServiceExtension: UNNotificationServiceExtension {
private var handler: ((UNNotificationContent) -> Void)?
private var modifiedContent: UNMutableNotificationContent?
private let appHooks = AppHooks()
// Used to create one single UserSession across process/instances/runs
private static let serialQueue = DispatchQueue(label: "io.element.elementx.nse")
// Temporary. We need to make sure the NSE and the main app pass in the same value.
// The NSE has a tendency of staying alive for longer so use this to manually kill it
// when the feature flag doesn't match.
private static var eventCacheEnabled = false
private static var userSession: NSEUserSession? {
didSet {
eventCacheEnabled = settings.eventCacheEnabled
}
}
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory),
let roomID = request.roomID,
let eventID = request.eventID,
let clientID = request.pusherNotificationClientIdentifier,
let credentials = keychainController.restorationTokens().first(where: { $0.restorationToken.pusherNotificationClientIdentifier == clientID }) else {
// We cannot process this notification, it might be due to one of these:
// - Device rebooted and locked
// - Not a Matrix notification
// - User is not signed in
// - NotificationID could not be resolved
return contentHandler(request.content)
}
handler = contentHandler
modifiedContent = request.content.mutableCopy() as? UNMutableNotificationContent
ExtensionLogger.configure(currentTarget: "nse", logLevel: settings.logLevel)
MXLog.info("\(tag) #########################################")
ExtensionLogger.logMemory(with: tag)
MXLog.info("\(tag) Payload came: \(request.content.userInfo)")
Self.serialQueue.sync {
// If the session directories have changed, the user has logged out and back in (even if they entered the same user ID).
// We can't do this comparison with the access token of the existing session here due to token refresh when using OIDC.
if Self.userSession == nil || Self.userSession?.sessionDirectories != credentials.restorationToken.sessionDirectories {
// This function might be run concurrently and from different processes
// It's imperative that we create **at most** one UserSession/Client per process
Task.synchronous { [appHooks] in
do {
Self.userSession = try await NSEUserSession(credentials: credentials,
clientSessionDelegate: keychainController,
appHooks: appHooks,
appSettings: settings)
} catch {
MXLog.error("Failed creating user session with error: \(error)")
}
}
}
if Self.userSession == nil {
return discard(unreadCount: request.unreadCount)
}
}
guard Self.eventCacheEnabled == settings.eventCacheEnabled else {
MXLog.error("Found missmatch `eventCacheEnabled` feature flag missmatch, restarting the NSE.")
exit(0)
}
Task {
await run(with: credentials,
roomID: roomID,
eventID: eventID,
unreadCount: request.unreadCount)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
MXLog.warning("\(tag) serviceExtensionTimeWillExpire")
notify(unreadCount: nil)
}
private func run(with credentials: KeychainCredentials,
roomID: String,
eventID: String,
unreadCount: Int?) async {
MXLog.info("\(tag) run with roomId: \(roomID), eventId: \(eventID)")
guard let userSession = Self.userSession else {
MXLog.error("Invalid NSE User Session, discarding.")
return discard(unreadCount: unreadCount)
}
do {
guard let itemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else {
MXLog.info("\(tag) no notification for the event, discard")
return discard(unreadCount: unreadCount)
}
guard await shouldHandleCallNotification(itemProxy) else {
return discard(unreadCount: unreadCount)
}
if await handleRedactionNotification(itemProxy) {
return discard(unreadCount: unreadCount)
}
// After the first processing, update the modified content
modifiedContent = try await notificationContentBuilder.content(for: itemProxy, mediaProvider: nil)
guard itemProxy.hasMedia else {
MXLog.info("\(tag) no media needed")
// We've processed the item and no media operations needed, so no need to go further
return notify(unreadCount: unreadCount)
}
MXLog.info("\(tag) process with media")
// There is some media to load, process it again
if let latestContent = try? await notificationContentBuilder.content(for: itemProxy, mediaProvider: userSession.mediaProvider) {
// Processing finished, hopefully with some media
modifiedContent = latestContent
}
// We still notify, but without the media attachment if it fails to load
return notify(unreadCount: unreadCount)
} catch {
MXLog.error("NSE run error: \(error)")
return discard(unreadCount: unreadCount)
}
}
private func notify(unreadCount: Int?) {
MXLog.info("\(tag) notify")
guard let modifiedContent else {
MXLog.info("\(tag) notify: no modified content")
return discard(unreadCount: unreadCount)
}
if let unreadCount {
modifiedContent.badge = NSNumber(value: unreadCount)
}
handler?(modifiedContent)
cleanUp()
}
private func discard(unreadCount: Int?) {
MXLog.info("\(tag) discard")
let content = UNMutableNotificationContent()
if let unreadCount {
content.badge = NSNumber(value: unreadCount)
}
handler?(content)
cleanUp()
}
private var tag: String {
"[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]"
}
private func cleanUp() {
handler = nil
modifiedContent = nil
}
deinit {
cleanUp()
ExtensionLogger.logMemory(with: tag)
MXLog.info("\(tag) deinit")
}
private func shouldHandleCallNotification(_ itemProxy: NotificationItemProxyProtocol) async -> Bool {
// Handle incoming VoIP calls, show the native OS call screen
// https://developer.apple.com/documentation/callkit/sending-end-to-end-encrypted-voip-calls
//
// The way this works is the following:
// - the NSE receives the notification and decrypts it
// - checks if it's still time relevant (max 10 seconds old) and whether it should ring
// - otherwise it goes on to show it as a normal notification
// - if it should ring then it discards the notification but invokes `reportNewIncomingVoIPPushPayload`
// so that the main app can handle it
// - the main app picks this up in `PKPushRegistry.didReceiveIncomingPushWith` and
// `CXProvider.reportNewIncomingCall` to show the system UI and handle actions on it.
// N.B. this flow works properly only when background processing capabilities are enabled
guard case let .timeline(event) = itemProxy.event,
case let .messageLike(content) = try? event.eventType(),
case let .callNotify(notificationType) = content,
notificationType == .ring else {
return true
}
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.timestamp() / 1000))
guard abs(timestamp.timeIntervalSinceNow) < ElementCallServiceNotificationDiscardDelta else {
MXLog.info("Call notification is too old, handling as push notification")
return true
}
let payload = [ElementCallServiceNotificationKey.roomID.rawValue: itemProxy.roomID,
ElementCallServiceNotificationKey.roomDisplayName.rawValue: itemProxy.roomDisplayName]
do {
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)
} catch {
MXLog.error("Failed reporting voip call with error: \(error). Handling as push notification")
return true
}
return false
}
/// Handles a notification for an `m.room.redaction` event.
/// - Returns: A boolean indicating whether the notification was handled.
private func handleRedactionNotification(_ itemProxy: NotificationItemProxyProtocol) async -> Bool {
guard case let .timeline(event) = itemProxy.event,
case let .messageLike(content) = try? event.eventType(),
case let .roomRedaction(redactedEventID, _) = content else {
return false
}
guard let redactedEventID else {
MXLog.error("Unable to redact notification due to missing event ID.")
return true // Return true as there's no point showing this notification.
}
let deliveredNotifications = await UNUserNotificationCenter.current().deliveredNotifications()
if let targetNotification = deliveredNotifications.first(where: { $0.request.content.eventID == redactedEventID }) {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [targetNotification.request.identifier])
}
return true
}
}
// https://stackoverflow.com/a/77300959/730924
private extension Task where Failure == Error {
/// Performs an async task in a sync context.
///
/// - Note: This function blocks the thread until the given operation is finished. The caller is responsible for managing multithreading.
static func synchronous(priority: TaskPriority? = nil, operation: @escaping @Sendable () async throws -> Success) {
let semaphore = DispatchSemaphore(value: 0)
Task(priority: priority) {
defer { semaphore.signal() }
return try await operation()
}
semaphore.wait()
}
}