element-ios/RiotNSE/NotificationService.swift

532 lines
27 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 {
2020-12-18 11:14:35 +00:00
// MARK: - Properties
/// Content handlers. Keys are eventId's
2020-10-22 14:27:35 +00:00
private var contentHandlers: [String: ((UNNotificationContent) -> Void)] = [:]
/// Flags to indicate there is an ongoing VoIP Push request for events. Keys are eventId's
private var ongoingVoIPPushRequests: [String: Bool] = [:]
2020-10-22 14:27:35 +00:00
private var userAccount: MXKAccount?
/// 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
2020-10-22 14:27:35 +00:00
private var bestAttemptContents: [String: UNMutableNotificationContent] = [:]
2020-10-22 14:27:35 +00:00
private static var backgroundSyncService: MXBackgroundSyncService!
private var showDecryptedContentInNotifications: Bool {
return RiotSettings.shared.showDecryptedContentInNotifications
}
2020-10-22 14:27:35 +00:00
private lazy var configuration: Configurable = {
return CommonConfiguration()
2020-07-29 08:03:37 +00:00
}()
2020-10-22 14:27:35 +00:00
private 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
}()
2020-08-20 14:43:46 +00:00
private var pushNotificationStore: PushNotificationStore = PushNotificationStore()
private let localAuthenticationService = LocalAuthenticationService(pinCodePreferences: .shared)
2020-12-18 11:14:35 +00:00
// MARK: - Method Overrides
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
2021-01-21 11:22:19 +00:00
let userInfo = request.content.userInfo
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
2021-01-21 11:22:19 +00:00
NSLog(" ")
NSLog(" ")
NSLog("################################################################################")
2020-06-17 12:18:20 +00:00
NSLog("[NotificationService] Instance: \(self), thread: \(Thread.current)")
2021-01-21 11:22:19 +00:00
NSLog("[NotificationService] Payload came: \(userInfo)")
2020-12-18 12:03:00 +00:00
// log memory at the beginning of the process
2020-12-18 11:14:35 +00:00
logMemory()
UNUserNotificationCenter.current().removeUnwantedNotifications()
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.
}
2021-01-21 11:22:19 +00:00
deinit {
NSLog("[NotificationService] deinit for \(self)");
self.logMemory()
2021-01-21 11:22:19 +00:00
NSLog(" ")
}
2020-12-18 11:14:35 +00:00
// MARK: - Private
private func logMemory() {
NSLog("[NotificationService] Memory: footprint: \(MXMemory.formattedMemoryFootprint()) - available: \(MXMemory.formattedMemoryAvailable())")
2020-12-18 11:14:35 +00:00
}
private func setupLogger() {
2020-10-12 18:52:43 +00:00
if !NotificationService.isLoggerInitialized {
if isatty(STDERR_FILENO) == 0 {
MXLogger.setSubLogName("nse")
let sizeLimit: UInt = 10 * 1024 * 1024; // 10MB
MXLogger.redirectNSLog(toFiles: true, numberOfFiles: 100, sizeLimit: sizeLimit)
2020-10-12 18:52:43 +00:00
}
NotificationService.isLoggerInitialized = true
}
2020-06-02 15:25:19 +00:00
}
2020-12-18 11:14:35 +00:00
private func setup(withRoomId roomId: String, eventId: String, completion: @escaping () -> Void) {
MXKAccountManager.shared()?.forceReloadAccounts()
2020-10-22 14:27:35 +00:00
self.userAccount = MXKAccountManager.shared()?.activeAccounts.first
if let userAccount = userAccount {
if NotificationService.backgroundSyncService == nil {
NSLog("[NotificationService] setup: MXBackgroundSyncService init: BEFORE")
self.logMemory()
2020-10-22 14:27:35 +00:00
NotificationService.backgroundSyncService = MXBackgroundSyncService(withCredentials: userAccount.mxCredentials)
NSLog("[NotificationService] setup: MXBackgroundSyncService init: AFTER")
self.logMemory()
2020-06-11 13:37:21 +00:00
}
2020-10-22 14:27:35 +00:00
completion()
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
2020-12-18 11:14:35 +00:00
private func preprocessPayload(forEventId eventId: String, roomId: String) {
if localAuthenticationService.isProtectionSet {
NSLog("[NotificationService] preprocessPayload: Do not preprocess because app protection is set")
return
}
2020-10-22 14:27:35 +00:00
guard let roomSummary = NotificationService.backgroundSyncService.roomSummary(forRoomId: roomId) else { return }
guard let roomDisplayName = roomSummary.displayname else { return }
if roomSummary.isDirect == true {
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])
}
}
2020-12-18 11:14:35 +00:00
private func fetchEvent(withEventId eventId: String, roomId: String, allowSync: Bool = true) {
2020-10-22 14:27:35 +00:00
NSLog("[NotificationService] fetchEvent")
2020-10-22 14:27:35 +00:00
NotificationService.backgroundSyncService.event(withEventId: eventId,
inRoom: roomId,
completion: { (response) in
switch response {
case .success(let event):
NSLog("[NotificationService] fetchEvent: Event fetched successfully")
self.processEvent(event)
case .failure(let error):
NSLog("[NotificationService] fetchEvent: error: \(error)")
self.fallbackToBestAttemptContent(forEventId: eventId)
}
2020-12-18 11:14:35 +00:00
})
}
2020-10-22 14:27:35 +00:00
private func processEvent(_ event: MXEvent) {
guard let content = bestAttemptContents[event.eventId], let userAccount = userAccount else {
2020-06-25 11:23:21 +00:00
self.fallbackToBestAttemptContent(forEventId: event.eventId)
return
}
2020-12-18 11:14:35 +00:00
2020-10-22 14:27:35 +00:00
self.notificationContent(forEvent: event, forAccount: userAccount) { (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
}
// modify the best attempt content, to be able to use in future
self.bestAttemptContents[event.eventId] = content
2020-12-18 12:03:00 +00:00
if self.ongoingVoIPPushRequests[event.eventId] == true {
// There is an ongoing VoIP Push request for this event, wait for it to be completed.
// When it completes, it'll continue with the bestAttemptContent.
return
} else {
NSLog("[NotificationService] processEvent: Calling content handler for: \(String(describing: event.eventId)), isUnwanted: \(isUnwantedNotification)")
self.contentHandlers[event.eventId]?(content)
// clear maps
self.contentHandlers.removeValue(forKey: event.eventId)
self.bestAttemptContents.removeValue(forKey: event.eventId)
// We are done for this push
NSLog("--------------------------------------------------------------------------------")
}
}
}
2020-10-22 14:27:35 +00:00
private func fallbackToBestAttemptContent(forEventId eventId: String) {
2020-06-25 11:23:21 +00:00
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)
2020-12-09 11:01:45 +00:00
// clear maps
contentHandlers.removeValue(forKey: eventId)
bestAttemptContents.removeValue(forKey: eventId)
// We are done for this push
NSLog("--------------------------------------------------------------------------------")
}
2020-10-22 14:27:35 +00:00
private func notificationContent(forEvent event: MXEvent, forAccount account: MXKAccount, 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-10-22 14:27:35 +00:00
let roomId = event.roomId!
let isRoomMentionsOnly = NotificationService.backgroundSyncService.isRoomMentionsOnly(roomId)
let roomSummary = NotificationService.backgroundSyncService.roomSummary(forRoomId: roomId)
2020-06-02 15:25:19 +00:00
NSLog("[NotificationService] notificationContentForEvent: Attempt to fetch the room state")
2020-10-22 14:27:35 +00:00
NotificationService.backgroundSyncService.roomState(forRoomId: roomId, completion: { (response) in
switch response {
case .success(let roomState):
var notificationTitle: String?
var notificationBody: String?
2020-08-11 18:17:48 +00:00
2020-10-22 14:27:35 +00:00
var threadIdentifier: String? = roomId
let eventSenderName = roomState.members.memberName(event.sender)
let currentUserId = account.mxCredentials.userId
let roomDisplayName = roomSummary?.displayname
let pushRule = NotificationService.backgroundSyncService.pushRule(matching: event, roomState: roomState)
2020-08-11 18:17:48 +00:00
2020-10-22 14:27:35 +00:00
switch event.eventType {
case .callInvite:
let offer = event.content["offer"] as? [AnyHashable: Any]
let sdp = offer?["sdp"] as? String
let isVideoCall = sdp?.contains("m=video") ?? false
2020-10-22 14:27:35 +00:00
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
self.sendVoipPush(forEvent: event)
case .roomMessage, .roomEncrypted:
if isRoomMentionsOnly {
// 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
}
}
2020-10-22 14:27:35 +00:00
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-10-22 14:27:35 +00:00
// 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 {
// Encrypted messages falls here
notificationBody = NSString.localizedUserNotificationString(forKey: "MESSAGE", arguments: [])
}
} else {
2020-10-22 14:27:35 +00:00
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
}
}
2020-10-22 14:27:35 +00:00
break
case .roomMember:
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:
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
}
2020-10-22 14:27:35 +00:00
notificationBody = NSString.localizedUserNotificationString(forKey: "STICKER_FROM_USER", arguments: [eventSenderName as Any])
case .custom:
if event.type == kWidgetMatrixEventTypeString || event.type == kWidgetModularEventTypeString {
if let content = event.content, let type = content["type"] as? String {
if type == kWidgetTypeJitsiV1 || type == kWidgetTypeJitsiV2 {
notificationBody = NSString.localizedUserNotificationString(forKey: "GROUP_CALL_STARTED_IN_ROOM", arguments: [eventSenderName as Any, roomDisplayName as Any])
// call notifications should stand out from normal messages, so we don't stack them
threadIdentifier = nil
// only send VoIP pushes if ringing is enabled for group calls
if RiotSettings.shared.enableRingingForGroupCalls {
self.sendVoipPush(forEvent: event)
}
}
}
}
2020-10-22 14:27:35 +00:00
default:
break
}
2020-10-22 14:27:35 +00:00
if self.localAuthenticationService.isProtectionSet {
NSLog("[NotificationService] notificationContentForEvent: Resetting title and body because app protection is set")
notificationBody = NSString.localizedUserNotificationString(forKey: "MESSAGE_PROTECTED", arguments: [])
notificationTitle = nil
}
2020-10-22 14:27:35 +00:00
guard notificationBody != nil else {
NSLog("[NotificationService] notificationContentForEvent: notificationBody is nil")
onComplete(nil)
return
}
2020-10-22 14:27:35 +00:00
let notificationContent = self.notificationContent(withTitle: notificationTitle,
body: notificationBody,
threadIdentifier: threadIdentifier,
userId: currentUserId,
event: event,
pushRule: pushRule)
NSLog("[NotificationService] notificationContentForEvent: Calling onComplete.")
onComplete(notificationContent)
case .failure(let error):
NSLog("[NotificationService] notificationContentForEvent: error: \(error)")
onComplete(nil)
}
2020-10-22 14:27:35 +00:00
})
}
2020-12-18 11:14:35 +00:00
private 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
}
2020-12-18 11:14:35 +00:00
private 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
}
2020-12-18 11:14:35 +00:00
private 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
}
2020-12-18 11:14:35 +00:00
private func notificationCategoryIdentifier(forEvent event: MXEvent) -> String? {
let isNotificationContentShown = (!event.isEncrypted || self.showDecryptedContentInNotifications)
&& !localAuthenticationService.isProtectionSet
guard isNotificationContentShown else {
return Constants.toBeRemovedNotificationCategoryIdentifier
}
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 Constants.toBeRemovedNotificationCategoryIdentifier
}
return "QUICK_REPLY"
}
2020-08-11 18:17:48 +00:00
/// Attempts to send trigger a VoIP push for the given event
/// - Parameter event: The call invite event.
2020-08-10 11:03:47 +00:00
private func sendVoipPush(forEvent event: MXEvent) {
2020-08-20 14:33:03 +00:00
guard let token = pushNotificationStore.pushKitToken else {
2020-08-10 11:03:47 +00:00
return
}
2020-08-20 14:33:03 +00:00
pushNotificationStore.lastCallInvite = event
2020-08-11 18:17:48 +00:00
ongoingVoIPPushRequests[event.eventId] = true
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: nil, sender: event.sender, success: { [weak self] (rejected) in
2020-08-11 18:17:48 +00:00
NSLog("[NotificationService] sendVoipPush succeeded, rejected tokens: \(rejected)")
guard let self = self else { return }
self.ongoingVoIPPushRequests.removeValue(forKey: event.eventId)
self.fallbackToBestAttemptContent(forEventId: event.eventId)
}) { [weak self] (error) in
2020-08-10 15:17:13 +00:00
NSLog("[NotificationService] sendVoipPush failed with error: \(error)")
guard let self = self else { return }
self.ongoingVoIPPushRequests.removeValue(forKey: event.eventId)
self.fallbackToBestAttemptContent(forEventId: event.eventId)
2020-08-10 11:03:47 +00:00
}
}
}