element-ios/RiotNSE/NotificationService.swift

609 lines
29 KiB
Swift
Raw Normal View History

/*
Copyright 2020 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import UserNotifications
import MatrixKit
2020-08-10 11:03:47 +00:00
import MatrixSDK
class NotificationService: UNNotificationServiceExtension {
/// Content handlers. Keys are eventId's
var contentHandlers: [String: ((UNNotificationContent) -> Void)] = [:]
/// Best attempt contents. Will be updated incrementally, if something fails during the process, this best attempt content will be showed as notification. Keys are eventId's
var bestAttemptContents: [String: UNMutableNotificationContent] = [:]
/// Cached events. Keys are eventId's
var cachedEvents: [String: MXEvent] = [:]
2020-06-17 12:50:51 +00:00
static var mxSession: MXSession?
var showDecryptedContentInNotifications: Bool {
return RiotSettings.shared.showDecryptedContentInNotifications
}
2020-07-29 08:03:37 +00:00
lazy var configuration: Configurable = {
return CommonConfiguration()
2020-07-29 08:03:37 +00:00
}()
2020-06-16 13:19:09 +00:00
static var isLoggerInitialized: Bool = false
2020-08-10 11:03:47 +00:00
private lazy var pushGatewayRestClient: MXPushGatewayRestClient = {
let url = URL(string: BuildSettings.serverConfigSygnalAPIUrlString)!
2020-08-10 15:17:13 +00:00
return MXPushGatewayRestClient(pushGateway: url.scheme! + "://" + url.host!, andOnUnrecognizedCertificateBlock: nil)
2020-08-10 11:03:47 +00:00
}()
private var pushNotificationManager: PushNotificationManager = .shared
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
2020-07-28 14:53:55 +00:00
// Set static application settings
2020-07-29 08:03:37 +00:00
configuration.setupSettings()
2020-06-02 15:25:19 +00:00
if DataProtectionHelper.isDeviceInRebootedAndLockedState(appGroupIdentifier: MXSDKOptions.sharedInstance().applicationGroupIdentifier) {
// kill the process in this state, this leads for the notification to be displayed as came from APNS
exit(0)
}
2020-06-16 13:17:58 +00:00
// setup logs
setupLogger()
2020-06-17 12:18:20 +00:00
NSLog("[NotificationService] Instance: \(self), thread: \(Thread.current)")
2020-06-16 13:17:58 +00:00
UNUserNotificationCenter.current().removeUnwantedNotifications()
let userInfo = request.content.userInfo
NSLog("[NotificationService] Payload came: \(userInfo)")
2020-06-02 15:25:19 +00:00
// check if this is a Matrix notification
guard let roomId = userInfo["room_id"] as? String, let eventId = userInfo["event_id"] as? String else {
// it's not a Matrix notification, do not change the content
NSLog("[NotificationService] didReceiveRequest: This is not a Matrix notification.")
contentHandler(request.content)
return
}
// save this content as fallback content
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
return
}
2020-06-25 08:48:02 +00:00
// read badge from "unread_count"
// no need to check before, if it's nil, the badge will remain unchanged
content.badge = userInfo["unread_count"] as? NSNumber
bestAttemptContents[eventId] = content
contentHandlers[eventId] = contentHandler
// setup user account
setup(withRoomId: roomId, eventId: eventId) {
// preprocess the payload, will attempt to fetch room display name
self.preprocessPayload(forEventId: eventId, roomId: roomId)
// fetch the event first
self.fetchEvent(withEventId: eventId, roomId: roomId)
}
}
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.
2020-06-11 13:46:02 +00:00
NSLog("[NotificationService] serviceExtensionTimeWillExpire")
2020-06-11 13:46:02 +00:00
// No-op here. If the process is killed by the OS due to time limit, it will also show the notification with the original content.
}
2020-06-02 15:25:19 +00:00
func setupLogger() {
2020-06-16 13:19:09 +00:00
if !NotificationService.isLoggerInitialized {
if isatty(STDERR_FILENO) == 0 {
MXLogger.setSubLogName("nse")
MXLogger.redirectNSLog(toFiles: true)
2020-06-16 13:19:09 +00:00
}
NotificationService.isLoggerInitialized = true
2020-06-02 15:25:19 +00:00
}
}
func setup(withRoomId roomId: String, eventId: String, completion: @escaping () -> Void) {
if let userAccount = MXKAccountManager.shared()?.activeAccounts.first {
2020-06-17 12:50:51 +00:00
if NotificationService.mxSession == nil {
2020-06-17 16:05:11 +00:00
let store = NSEMemoryStore(withCredentials: userAccount.mxCredentials)
2020-06-17 12:50:51 +00:00
NotificationService.mxSession = MXSession(matrixRestClient: MXRestClient(credentials: userAccount.mxCredentials, unrecognizedCertificateHandler: nil))
2020-06-17 16:05:11 +00:00
NotificationService.mxSession?.setStore(store, completion: { (response) in
2020-06-11 13:37:21 +00:00
switch response {
case .success:
completion()
break
case .failure(let error):
NSLog("[NotificationService] setup: MXSession.setStore method returned error: \(String(describing: error))")
2020-06-25 11:23:21 +00:00
self.fallbackToBestAttemptContent(forEventId: eventId)
2020-06-11 13:37:21 +00:00
break
}
})
2020-06-17 12:18:20 +00:00
} else {
NSLog("[NotificationService] Instance: Reusing session")
2020-06-17 12:50:51 +00:00
completion()
2020-06-11 13:37:21 +00:00
}
2020-06-02 15:25:19 +00:00
} else {
NSLog("[NotificationService] setup: No active accounts")
2020-06-25 11:23:21 +00:00
fallbackToBestAttemptContent(forEventId: eventId)
}
}
/// Attempts to preprocess payload and attach room display name to the best attempt content
/// - Parameters:
/// - eventId: Event identifier to mutate best attempt content
/// - roomId: Room identifier to fetch display name
func preprocessPayload(forEventId eventId: String, roomId: String) {
guard let session = NotificationService.mxSession else { return }
guard let roomDisplayName = session.store.summary?(ofRoom: roomId)?.displayname else { return }
let isDirect = session.directUserId(inRoom: roomId) != nil
if isDirect {
bestAttemptContents[eventId]?.body = NSString.localizedUserNotificationString(forKey: "MESSAGE_FROM_X", arguments: [roomDisplayName as Any])
} else {
bestAttemptContents[eventId]?.body = NSString.localizedUserNotificationString(forKey: "MESSAGE_IN_X", arguments: [roomDisplayName as Any])
}
}
func fetchEvent(withEventId eventId: String, roomId: String) {
guard let mxSession = NotificationService.mxSession else {
// there is something wrong, do not change the content
NSLog("[NotificationService] fetchEvent: Either originalContent or mxSession is missing.")
2020-06-25 11:23:21 +00:00
fallbackToBestAttemptContent(forEventId: eventId)
return
}
/// Inline function to handle encryption for event, either from cache or from the backend
/// - Parameter event: The event to be handled
func handleEncryption(forEvent event: MXEvent) {
if !event.isEncrypted {
// not encrypted, go on processing
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] fetchEvent: Event not encrypted.")
self.processEvent(event)
return
}
// encrypted
if !self.showDecryptedContentInNotifications {
// do not show decrypted content in notification
NSLog("[NotificationService] fetchEvent: Do not show decrypted content in notifications, no need to attempt to decrypt it.")
self.processEvent(event)
return
}
// should show decrypted content in notification
if event.clear != nil {
// already decrypted
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] fetchEvent: Event already decrypted.")
self.processEvent(event)
return
}
// should decrypt it first
if mxSession.decryptEvent(event, inTimeline: nil) {
// decryption succeeded
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] fetchEvent: Event decrypted successfully.")
self.processEvent(event)
} else {
// decryption failed
NSLog("[NotificationService] fetchEvent: Event needs to be decrpyted, but we don't have the keys to decrypt it. Launching a background sync.")
self.launchBackgroundSync(forEventId: eventId, roomId: roomId)
}
}
// check if we've fetched the event before
if let cachedEvent = self.cachedEvents[eventId] {
// use cached event
handleEncryption(forEvent: cachedEvent)
} else {
// attempt to fetch the event
mxSession.event(withEventId: eventId, inRoom: roomId, success: { [weak self] (event) in
guard let self = self else {
NSLog("[NotificationService] fetchEvent: MXSession.event method returned too late successfully.")
return
}
guard let event = event else {
NSLog("[NotificationService] fetchEvent: MXSession.event method returned successfully with no event.")
2020-06-25 11:23:21 +00:00
self.fallbackToBestAttemptContent(forEventId: eventId)
return
}
// cache this event
self.cachedEvents[eventId] = event
// handle encryption for this event
handleEncryption(forEvent: event)
}) { [weak self] (error) in
guard let self = self else {
NSLog("[NotificationService] fetchEvent: MXSession.event method returned too late with error: \(String(describing: error))")
return
}
NSLog("[NotificationService] fetchEvent: MXSession.event method returned error: \(String(describing: error))")
2020-06-25 11:23:21 +00:00
self.fallbackToBestAttemptContent(forEventId: eventId)
}
}
}
func launchBackgroundSync(forEventId eventId: String, roomId: String) {
2020-06-17 12:50:51 +00:00
guard let mxSession = NotificationService.mxSession else {
NSLog("[NotificationService] launchBackgroundSync: mxSession is missing.")
2020-06-25 11:23:21 +00:00
self.fallbackToBestAttemptContent(forEventId: eventId)
return
}
// launch an initial background sync
mxSession.backgroundSync(withTimeout: 20, ignoreSessionState: true) { [weak self] (response) in
switch response {
case .success:
guard let self = self else {
NSLog("[NotificationService] launchBackgroundSync: MXSession.initialBackgroundSync returned too late successfully")
return
}
self.fetchEvent(withEventId: eventId, roomId: roomId)
break
case .failure(let error):
guard let self = self else {
NSLog("[NotificationService] launchBackgroundSync: MXSession.initialBackgroundSync returned too late with error: \(String(describing: error))")
return
}
NSLog("[NotificationService] launchBackgroundSync: MXSession.initialBackgroundSync returned with error: \(String(describing: error))")
2020-06-25 11:23:21 +00:00
self.fallbackToBestAttemptContent(forEventId: eventId)
break
}
}
}
func processEvent(_ event: MXEvent) {
guard let content = bestAttemptContents[event.eventId], let mxSession = NotificationService.mxSession else {
2020-06-25 11:23:21 +00:00
self.fallbackToBestAttemptContent(forEventId: event.eventId)
return
}
self.notificationContent(forEvent: event, inSession: mxSession) { (notificationContent) in
2020-06-02 15:25:19 +00:00
var isUnwantedNotification = false
// Modify the notification content here...
if let newContent = notificationContent {
content.title = newContent.title
content.subtitle = newContent.subtitle
content.body = newContent.body
content.threadIdentifier = newContent.threadIdentifier
content.categoryIdentifier = newContent.categoryIdentifier
content.userInfo = newContent.userInfo
content.sound = newContent.sound
} else {
// this is an unwanted notification, mark as to be deleted when app is foregrounded again OR a new push came
content.categoryIdentifier = Constants.toBeRemovedNotificationCategoryIdentifier
2020-06-02 15:25:19 +00:00
isUnwantedNotification = true
}
NSLog("[NotificationService] processEvent: Calling content handler for: \(String(describing: event.eventId)), isUnwanted: \(isUnwantedNotification)")
self.contentHandlers[event.eventId]?(content)
}
}
2020-06-25 11:23:21 +00:00
func fallbackToBestAttemptContent(forEventId eventId: String) {
NSLog("[NotificationService] fallbackToBestAttemptContent: method called.")
guard let content = bestAttemptContents[eventId] else {
NSLog("[NotificationService] fallbackToBestAttemptContent: Best attempt content is missing.")
return
}
// call contentHandler
contentHandlers[eventId]?(content)
}
func notificationContent(forEvent event: MXEvent, inSession session: MXSession, onComplete: @escaping (UNNotificationContent?) -> Void) {
guard let content = event.content, content.count > 0 else {
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] notificationContentForEvent: empty event content")
onComplete(nil)
return
}
2020-06-17 12:35:10 +00:00
guard let room = MXRoom.load(from: session.store, withRoomId: event.roomId, matrixSession: session) as? MXRoom else {
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] notificationContentForEvent: Unknown room")
onComplete(nil)
return
}
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] notificationContentForEvent: Attempt to fetch the room state")
room.state { (roomState) in
guard let roomState = roomState else {
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] notificationContentForEvent: Could not fetch the room state")
onComplete(nil)
return
}
var notificationTitle: String?
var notificationBody: String?
var threadIdentifier = room.roomId
let eventSenderName = roomState.members.memberName(event.sender)
let currentUserId = session.credentials.userId
2020-06-16 16:25:53 +00:00
let pushRule = session.notificationCenter.rule(matching: event, roomState: roomState)
switch event.eventType {
case .roomMessage, .roomEncrypted:
if room.isMentionsOnly {
// A local notification will be displayed only for highlighted notification.
var isHighlighted = false
// Check whether is there an highlight tweak on it
for ruleAction in pushRule?.actions ?? [] {
guard let action = ruleAction as? MXPushRuleAction else { continue }
guard action.actionType == MXPushRuleActionTypeSetTweak else { continue }
guard action.parameters["set_tweak"] as? String == "highlight" else { continue }
// Check the highlight tweak "value"
// If not present, highlight. Else check its value before highlighting
if nil == action.parameters["value"] || true == (action.parameters["value"] as? Bool) {
isHighlighted = true
break
}
}
if !isHighlighted {
// Ignore this notif.
NSLog("[NotificationService] notificationContentForEvent: Ignore non highlighted notif in mentions only room")
onComplete(nil)
return
}
}
var msgType = event.content["msgtype"] as? String
let messageContent = event.content["body"] as? String
if event.isEncrypted && !self.showDecryptedContentInNotifications {
// Hide the content
msgType = nil
}
2020-06-17 16:05:11 +00:00
let roomDisplayName = session.store.summary?(ofRoom: room.roomId)?.displayname
let myUserId = session.myUser.userId
let isIncomingEvent = event.sender != myUserId
// Display the room name only if it is different than the sender name
if roomDisplayName != nil && roomDisplayName != eventSenderName {
notificationTitle = NSString.localizedUserNotificationString(forKey: "MSG_FROM_USER_IN_ROOM_TITLE", arguments: [eventSenderName as Any, roomDisplayName as Any])
if msgType == kMXMessageTypeText {
notificationBody = messageContent
} else if msgType == kMXMessageTypeEmote {
notificationBody = NSString.localizedUserNotificationString(forKey: "ACTION_FROM_USER", arguments: [eventSenderName as Any, messageContent as Any])
} else if msgType == kMXMessageTypeImage {
notificationBody = NSString.localizedUserNotificationString(forKey: "IMAGE_FROM_USER", arguments: [eventSenderName as Any, messageContent as Any])
} else if room.isDirect && isIncomingEvent && msgType == kMXMessageTypeKeyVerificationRequest {
session.crypto.keyVerificationManager.keyVerification(fromKeyVerificationEvent: event,
success:{ (keyVerification) in
guard let request = keyVerification.request, request.state == MXKeyVerificationRequestStatePending else {
onComplete(nil)
return
}
// TODO: Add accept and decline actions to notification
let body = NSString.localizedUserNotificationString(forKey: "KEY_VERIFICATION_REQUEST_FROM_USER", arguments: [eventSenderName as Any])
let notificationContent = self.notificationContent(withTitle: notificationTitle,
body: body,
threadIdentifier: threadIdentifier,
userId: currentUserId,
event: event,
pushRule: pushRule)
onComplete(notificationContent)
}, failure:{ (error) in
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] notificationContentForEvent: failed to fetch key verification with error: \(error)")
onComplete(nil)
})
} else {
// Encrypted messages falls here
notificationBody = NSString.localizedUserNotificationString(forKey: "MESSAGE", arguments: [])
}
} else {
notificationTitle = eventSenderName
switch msgType {
case kMXMessageTypeText:
notificationBody = messageContent
break
case kMXMessageTypeEmote:
notificationBody = NSString.localizedUserNotificationString(forKey: "ACTION_FROM_USER", arguments: [eventSenderName as Any, messageContent as Any])
break
case kMXMessageTypeImage:
notificationBody = NSString.localizedUserNotificationString(forKey: "IMAGE_FROM_USER", arguments: [eventSenderName as Any, messageContent as Any])
break
default:
// Encrypted messages falls here
notificationBody = NSString.localizedUserNotificationString(forKey: "MESSAGE", arguments: [])
break
}
}
break
case .callInvite:
let offer = event.content["offer"] as? [AnyHashable: Any]
let sdp = offer?["sdp"] as? String
let isVideoCall = sdp?.contains("m=video") ?? false
if isVideoCall {
notificationBody = NSString.localizedUserNotificationString(forKey: "VIDEO_CALL_FROM_USER", arguments: [eventSenderName as Any])
} else {
notificationBody = NSString.localizedUserNotificationString(forKey: "VOICE_CALL_FROM_USER", arguments: [eventSenderName as Any])
}
// call notifications should stand out from normal messages, so we don't stack them
threadIdentifier = nil
2020-08-10 11:03:47 +00:00
self.sendVoipPush(forEvent: event)
case .roomMember:
let roomDisplayName = room.summary.displayname
if roomDisplayName != nil && roomDisplayName != eventSenderName {
notificationBody = NSString.localizedUserNotificationString(forKey: "USER_INVITE_TO_NAMED_ROOM", arguments: [eventSenderName as Any, roomDisplayName as Any])
} else {
notificationBody = NSString.localizedUserNotificationString(forKey: "USER_INVITE_TO_CHAT", arguments: [eventSenderName as Any])
}
case .sticker:
let roomDisplayName = room.summary.displayname
if roomDisplayName != nil && roomDisplayName != eventSenderName {
notificationTitle = NSString.localizedUserNotificationString(forKey: "MSG_FROM_USER_IN_ROOM_TITLE", arguments: [eventSenderName as Any, roomDisplayName as Any])
} else {
notificationTitle = eventSenderName
}
notificationBody = NSString.localizedUserNotificationString(forKey: "STICKER_FROM_USER", arguments: [eventSenderName as Any])
default:
break
}
2020-06-02 15:25:19 +00:00
guard notificationBody != nil else {
NSLog("[NotificationService] notificationContentForEvent: notificationBody is nil")
onComplete(nil)
return
}
let notificationContent = self.notificationContent(withTitle: notificationTitle,
body: notificationBody,
threadIdentifier: threadIdentifier,
userId: currentUserId,
event: event,
pushRule: pushRule)
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] notificationContentForEvent: Calling onComplete.")
onComplete(notificationContent)
}
}
func notificationContent(withTitle title: String?,
body: String?,
threadIdentifier: String?,
userId: String?,
event: MXEvent,
pushRule: MXPushRule?) -> UNNotificationContent {
let notificationContent = UNMutableNotificationContent()
if let title = title {
notificationContent.title = title
}
if let body = body {
notificationContent.body = body
}
if let threadIdentifier = threadIdentifier {
notificationContent.threadIdentifier = threadIdentifier
}
if let categoryIdentifier = self.notificationCategoryIdentifier(forEvent: event) {
notificationContent.categoryIdentifier = categoryIdentifier
}
if let soundName = notificationSoundName(fromPushRule: pushRule) {
notificationContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
}
notificationContent.userInfo = notificationUserInfo(forEvent: event, andUserId: userId)
return notificationContent
}
func notificationUserInfo(forEvent event: MXEvent, andUserId userId: String?) -> [AnyHashable: Any] {
var notificationUserInfo: [AnyHashable: Any] = [
"type": "full",
"room_id": event.roomId as Any,
"event_id": event.eventId as Any
]
if let userId = userId {
notificationUserInfo["user_id"] = userId
}
return notificationUserInfo
}
func notificationSoundName(fromPushRule pushRule: MXPushRule?) -> String? {
var soundName: String?
// Set sound name based on the value provided in action of MXPushRule
for ruleAction in pushRule?.actions ?? [] {
guard let action = ruleAction as? MXPushRuleAction else { continue }
guard action.actionType == MXPushRuleActionTypeSetTweak else { continue }
guard action.parameters["set_tweak"] as? String == "sound" else { continue }
soundName = action.parameters["value"] as? String
if soundName == "default" {
soundName = "message.caf"
}
}
2020-06-19 14:07:13 +00:00
NSLog("Sound name: \(String(describing: soundName))")
return soundName
}
func notificationCategoryIdentifier(forEvent event: MXEvent) -> String? {
let isNotificationContentShown = !event.isEncrypted || self.showDecryptedContentInNotifications
guard isNotificationContentShown else {
return nil
}
2020-08-10 15:17:46 +00:00
if event.eventType == .callInvite {
return Constants.callInviteNotificationCategoryIdentifier
2020-08-10 15:17:46 +00:00
}
guard event.eventType == .roomMessage || event.eventType == .roomEncrypted else {
return nil
}
return "QUICK_REPLY"
}
2020-08-10 11:03:47 +00:00
private func sendVoipPush(forEvent event: MXEvent) {
guard let token = pushNotificationManager.pushToken else {
return
}
2020-08-10 15:17:13 +00:00
let appId = BuildSettings.pushKitAppId
pushGatewayRestClient.notifyApp(withId: appId, pushToken: token, eventId: event.eventId, roomId: event.roomId, eventType: event.wireType, sender: event.sender, success: { (rejected) in
NSLog("[NotificationService] sendVoipPush succeeded, rejected token: \(rejected)")
2020-08-10 11:03:47 +00:00
}) { (error) in
2020-08-10 15:17:13 +00:00
NSLog("[NotificationService] sendVoipPush failed with error: \(error)")
2020-08-10 11:03:47 +00:00
}
}
}
extension MXRoom {
func getRoomPushRule() -> MXPushRule? {
guard let rules = self.mxSession.notificationCenter.rules.global.room else {
return nil
}
for rule in rules {
guard let pushRule = rule as? MXPushRule else { continue }
// the rule id is the room Id
// it is the server trick to avoid duplicated rule on the same room.
if pushRule.ruleId == self.roomId {
return pushRule
}
}
return nil
}
var isMentionsOnly: Bool {
// Check push rules at room level
guard let rule = self.getRoomPushRule() else {
return false
}
for ruleAction in rule.actions {
guard let action = ruleAction as? MXPushRuleAction else { continue }
if action.actionType == MXPushRuleActionTypeDontNotify {
return rule.enabled
}
}
return false
}
}