element-ios/RiotNSE/NotificationService.swift

888 lines
47 KiB
Swift

/*
Copyright 2020-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import UserNotifications
import MatrixSDK
/// The number of milliseconds in one second.
private let MSEC_PER_SEC: TimeInterval = 1000
class NotificationService: UNNotificationServiceExtension {
private struct NSE {
enum Constants {
static let voipPushRequestTimeout: TimeInterval = 15
static let timeNeededToSendVoIPPushes: TimeInterval = 20
}
}
// MARK: - Properties
/// Receiving dates for notifications. Keys are eventId's
private var receiveDates: [String: Date] = [:]
/// Content handlers. Keys are eventId's
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] = [:]
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
private var bestAttemptContents: [String: UNMutableNotificationContent] = [:]
private static var backgroundSyncService: MXBackgroundSyncService!
private var showDecryptedContentInNotifications: Bool {
return RiotSettings.shared.showDecryptedContentInNotifications
}
private lazy var configuration: Configurable = {
return CommonConfiguration()
}()
private lazy var mxRestClient: MXRestClient? = {
guard let userAccount = userAccount else {
return nil
}
let restClient = MXRestClient(credentials: userAccount.mxCredentials, unrecognizedCertificateHandler: nil, persistentTokenDataHandler: { persistTokenDataHandler in
MXKAccountManager.shared().readAndWriteCredentials(persistTokenDataHandler)
}, unauthenticatedHandler: { error, softLogout, refreshTokenAuth, completion in
userAccount.handleUnauthenticatedWithError(error, isSoftLogout: softLogout, isRefreshTokenAuth: refreshTokenAuth, andCompletion: completion)
})
return restClient
}()
private static var isLoggerInitialized: Bool = false
private lazy var pushGatewayRestClient: MXPushGatewayRestClient = {
let url = URL(string: BuildSettings.serverConfigSygnalAPIUrlString)!
return MXPushGatewayRestClient(pushGateway: url.scheme! + "://" + url.host!, andOnUnrecognizedCertificateBlock: nil)
}()
private var pushNotificationStore: PushNotificationStore = PushNotificationStore()
private let localAuthenticationService = LocalAuthenticationService(pinCodePreferences: .shared)
private static let backgroundServiceInitQueue = DispatchQueue(label: "io.element.NotificationService.backgroundServiceInitQueue")
// MARK: - Method Overrides
override init() {
super.init()
// Set up runtime language and fallback by considering the userDefaults object shared within the application group.
let sharedUserDefaults = MXKAppSettings.standard().sharedUserDefaults
if let language = sharedUserDefaults?.string(forKey: "appLanguage") {
Bundle.mxk_setLanguage(language)
}
Bundle.mxk_setFallbackLanguage("en")
}
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
let userInfo = request.content.userInfo
// Set static application settings
configuration.setupSettings()
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)
}
// setup logs
setupLogger()
MXLog.debug(" ")
MXLog.debug(" ")
MXLog.debug("################################################################################")
MXLog.debug("[NotificationService] Instance: \(self), thread: \(Thread.current)")
MXLog.debug("[NotificationService] Payload came: \(userInfo)")
// log memory at the beginning of the process
logMemory()
setupAnalytics()
UNUserNotificationCenter.current().removeUnwantedNotifications()
// 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
MXLog.debug("[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
}
// store receive date
receiveDates[eventId] = Date()
// 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.fetchAndProcessEvent(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.
MXLog.debug("[NotificationService] serviceExtensionTimeWillExpire")
// 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.
}
deinit {
MXLog.debug("[NotificationService] deinit for \(self)");
self.logMemory()
MXLog.debug(" ")
}
// MARK: - Private
private func logMemory() {
MXLog.debug("[NotificationService] Memory: footprint: \(MXMemory.formattedMemoryFootprint()) - available: \(MXMemory.formattedMemoryAvailable())")
}
private func setupLogger() {
if !NotificationService.isLoggerInitialized {
let configuration = MXLogConfiguration()
configuration.logLevel = .verbose
configuration.maxLogFilesCount = 100
configuration.logFilesSizeLimit = 10 * 1024 * 1024; // 10MB
configuration.subLogName = "nse"
if isatty(STDERR_FILENO) == 0 {
configuration.redirectLogsToFiles = true
}
MXLog.configure(configuration)
NotificationService.isLoggerInitialized = true
}
}
private func setupAnalytics(){
// Configure our analytics. It will start if the option is enabled
let analytics = Analytics.shared
MXSDKOptions.sharedInstance().analyticsDelegate = analytics
analytics.startIfEnabled()
}
private func setup(withRoomId roomId: String, eventId: String, completion: @escaping () -> Void) {
MXKAccountManager.sharedManager(withReload: true)
self.userAccount = MXKAccountManager.shared()?.activeAccounts.first
if let userAccount = userAccount {
Self.backgroundServiceInitQueue.sync {
if NotificationService.backgroundSyncService?.credentials != userAccount.mxCredentials {
MXLog.debug("[NotificationService] setup: MXBackgroundSyncService init: BEFORE")
self.logMemory()
NotificationService.backgroundSyncService = MXBackgroundSyncService(
withCredentials: userAccount.mxCredentials,
persistTokenDataHandler: { persistTokenDataHandler in
MXKAccountManager.shared().readAndWriteCredentials(persistTokenDataHandler)
}, unauthenticatedHandler: { error, softLogout, refreshTokenAuth, completion in
userAccount.handleUnauthenticatedWithError(error, isSoftLogout: softLogout, isRefreshTokenAuth: refreshTokenAuth, andCompletion: completion)
})
MXLog.debug("[NotificationService] setup: MXBackgroundSyncService init: AFTER")
self.logMemory()
}
completion()
}
} else {
MXLog.debug("[NotificationService] setup: No active accounts")
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
private func preprocessPayload(forEventId eventId: String, roomId: String) {
if localAuthenticationService.isProtectionSet {
MXLog.debug("[NotificationService] preprocessPayload: Do not preprocess because app protection is set")
return
}
// If a room summary is available, use the displayname for the best attempt title.
guard let roomSummary = NotificationService.backgroundSyncService.roomSummary(forRoomId: roomId) else { return }
guard let roomDisplayName = roomSummary.displayName else { return }
bestAttemptContents[eventId]?.title = roomDisplayName
// At this stage we don't know the message type, so leave the body as set in didReceive.
}
private func fetchAndProcessEvent(withEventId eventId: String, roomId: String) {
MXLog.debug("[NotificationService] fetchAndProcessEvent")
NotificationService.backgroundSyncService.event(withEventId: eventId, inRoom: roomId) { [weak self] (response) in
switch response {
case .success(let event):
MXLog.debug("[NotificationService] fetchAndProcessEvent: Event fetched successfully")
self?.checkPlaybackAndContinueProcessing(event, roomId: roomId)
case .failure(let error):
MXLog.error("[NotificationService] fetchAndProcessEvent: Failed fetching notification event", context: error)
self?.fallbackToBestAttemptContent(forEventId: eventId)
}
}
}
private func checkPlaybackAndContinueProcessing(_ notificationEvent: MXEvent, roomId: String) {
NotificationService.backgroundSyncService.readMarkerEvent(forRoomId: roomId) { [weak self] response in
switch response {
case .success(let readMarkerEvent):
MXLog.debug("[NotificationService] checkPlaybackAndContinueProcessing: Read marker event fetched successfully")
// As origin server timestamps are not always correct data in a federated environment, we add 10 minutes
// to the calculation to reduce the possibility that an event is marked as read which isn't.
let notificationTimestamp = notificationEvent.originServerTs + (10 * 60 * 1000)
if readMarkerEvent.originServerTs > notificationTimestamp {
MXLog.error("[NotificationService] checkPlaybackAndContinueProcessing: Event already read, discarding.")
self?.discardEvent(event: notificationEvent)
} else {
self?.processEvent(notificationEvent)
}
case .failure(let error):
MXLog.error("[NotificationService] checkPlaybackAndContinueProcessing: Failed fetching read marker event", context: error)
self?.processEvent(notificationEvent)
}
}
}
private func processEvent(_ event: MXEvent) {
if let receiveDate = receiveDates[event.eventId] {
MXLog.debug("[NotificationService] processEvent: notification receive delay: \(receiveDate.timeIntervalSince1970*MSEC_PER_SEC - TimeInterval(event.originServerTs)) ms")
}
guard let content = bestAttemptContents[event.eventId], let userAccount = userAccount else {
self.fallbackToBestAttemptContent(forEventId: event.eventId)
return
}
self.notificationContent(forEvent: event, forAccount: userAccount) { [weak self] (notificationContent, ignoreBadgeUpdate) in
guard let self = self else { return }
guard let newContent = notificationContent else {
// We still want them removed if the NSE filtering entitlement is not available
content.categoryIdentifier = Constants.toBeRemovedNotificationCategoryIdentifier
self.discardEvent(event: event)
return
}
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
if ignoreBadgeUpdate {
content.badge = nil
}
if self.ongoingVoIPPushRequests[event.eventId] == true {
// modify the best attempt content, to be able to use in the future
self.bestAttemptContents[event.eventId] = content
// 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 {
self.finishProcessing(forEventId: event.eventId, withContent: content)
}
}
}
private func discardEvent(event:MXEvent) {
MXLog.debug("[NotificationService] discardEvent: Discarding event: \(String(describing: event.eventId))")
finishProcessing(forEventId: event.eventId, withContent: UNNotificationContent())
}
private func fallbackToBestAttemptContent(forEventId eventId: String) {
MXLog.debug("[NotificationService] fallbackToBestAttemptContent: method called.")
guard let content = bestAttemptContents[eventId] else {
MXLog.debug("[NotificationService] fallbackToBestAttemptContent: Best attempt content is missing.")
return
}
finishProcessing(forEventId: eventId, withContent: content)
}
private func finishProcessing(forEventId eventId: String, withContent content: UNNotificationContent) {
MXLog.debug("[NotificationService] finishProcessingEvent: Calling content handler for: \(String(describing: eventId))")
contentHandlers[eventId]?(content)
// clear maps
contentHandlers.removeValue(forKey: eventId)
bestAttemptContents.removeValue(forKey: eventId)
receiveDates.removeValue(forKey: eventId)
// We are done for this push
MXLog.debug("--------------------------------------------------------------------------------")
}
private func notificationContent(forEvent event: MXEvent, forAccount account: MXKAccount, onComplete: @escaping (UNNotificationContent?, Bool) -> Void) {
guard let content = event.content, content.count > 0 else {
MXLog.debug("[NotificationService] notificationContentForEvent: empty event content")
onComplete(nil, false)
return
}
let roomId = event.roomId!
let isRoomMentionsOnly = NotificationService.backgroundSyncService.isRoomMentionsOnly(roomId)
let roomSummary = NotificationService.backgroundSyncService.roomSummary(forRoomId: roomId)
MXLog.debug("[NotificationService] notificationContentForEvent: Attempt to fetch the room state")
self.context(ofEvent: event, inRoom: roomId, completion: { (response) in
switch response {
case .success(let (roomState, eventSenderName)):
var notificationTitle: String?
var notificationBody: String?
var additionalUserInfo: [AnyHashable: Any]?
var ignoreBadgeUpdate = false
var threadIdentifier: String? = roomId
let currentUserId = account.mxCredentials.userId
let roomDisplayName = roomSummary?.displayName
let pushRule = NotificationService.backgroundSyncService.pushRule(matching: event, roomState: roomState)
// if the push rule must not be notified we complete and return
if pushRule?.dontNotify == true {
onComplete(nil, false)
return
}
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
if isVideoCall {
notificationBody = NotificationService.localizedString(forKey: "VIDEO_CALL_FROM_USER", eventSenderName)
} else {
notificationBody = NotificationService.localizedString(forKey: "VOICE_CALL_FROM_USER", eventSenderName)
}
// call notifications should stand out from normal messages, so we don't stack them
threadIdentifier = nil
if let callInviteContent = MXCallInviteEventContent(fromJSON: event.content),
callInviteContent.lifetime > event.age,
(callInviteContent.lifetime - event.age) > UInt(NSE.Constants.timeNeededToSendVoIPPushes * MSEC_PER_SEC) {
NotificationService.backgroundSyncService.roomAccountData(forRoomId: roomId) { response in
if let accountData = response.value, accountData.virtualRoomInfo.isVirtual {
self.sendReadReceipt(forEvent: event)
ignoreBadgeUpdate = true
}
self.validateNotificationContentAndComplete(
notificationTitle: notificationTitle,
notificationBody: notificationBody,
additionalUserInfo: additionalUserInfo,
ignoreBadgeUpdate: ignoreBadgeUpdate,
threadIdentifier: threadIdentifier,
currentUserId: currentUserId,
event: event,
pushRule: pushRule,
onComplete: onComplete
)
}
self.sendVoipPush(forEvent: event)
return
} else {
MXLog.debug("[NotificationService] notificationContent: Do not attempt to send a VoIP push, there is not enough time to process it.")
}
case .roomEncrypted:
// If unable to decrypt the event, use the fallback.
break
case .roomMessage:
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 {
// In practice, this only hides the notification's content. An empty notification may be less useful in this instance?
// Ignore this notif.
MXLog.debug("[NotificationService] notificationContentForEvent: Ignore non highlighted notif in mentions only room")
onComplete(nil, false)
return
}
}
let msgType = event.content[kMXMessageTypeKey] as? String
let messageContent = event.content[kMXMessageBodyKey] as? String ?? ""
let isReply = event.isReply()
if isReply {
notificationTitle = self.replyTitle(for: eventSenderName, in: roomDisplayName)
} else {
notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName)
}
if event.isEncrypted && !self.showDecryptedContentInNotifications {
// Hide the content
notificationBody = NotificationService.localizedString(forKey: "MESSAGE")
break
}
if event.location != nil {
notificationBody = NotificationService.localizedString(forKey: "LOCATION_FROM_USER", eventSenderName)
break
}
switch msgType {
case kMXMessageTypeEmote:
notificationBody = NotificationService.localizedString(forKey: "ACTION_FROM_USER", eventSenderName, messageContent)
case kMXMessageTypeImage:
notificationBody = NotificationService.localizedString(forKey: "PICTURE_FROM_USER", eventSenderName)
case kMXMessageTypeVideo:
notificationBody = NotificationService.localizedString(forKey: "VIDEO_FROM_USER", eventSenderName)
case kMXMessageTypeAudio:
if event.isVoiceMessage() {
// Ignore voice broadcast chunk event except the first one.
if let chunkInfo = event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] as? [String: UInt] {
if chunkInfo[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence] == 1 {
notificationBody = NotificationService.localizedString(forKey: "VOICE_BROADCAST_FROM_USER", eventSenderName)
}
} else {
notificationBody = NotificationService.localizedString(forKey: "VOICE_MESSAGE_FROM_USER", eventSenderName)
}
} else {
notificationBody = NotificationService.localizedString(forKey: "AUDIO_FROM_USER", eventSenderName, messageContent)
}
case kMXMessageTypeFile:
notificationBody = NotificationService.localizedString(forKey: "FILE_FROM_USER", eventSenderName, messageContent)
// All other message types such as text, notice, server notice etc
default:
if event.isReply() {
let parser = MXReplyEventParser()
let replyParts = parser.parse(event)
notificationBody = replyParts?.bodyParts.replyText
} else {
notificationBody = messageContent
}
}
case .roomMember:
// If the current user is already joined, display updated displayname/avatar events.
// This is an unexpected path, but has been seen in some circumstances.
if NotificationService.backgroundSyncService.roomSummary(forRoomId: roomId)?.membership == .join {
notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName)
// If the sender's membership is join and hasn't changed.
if let newContent = MXRoomMemberEventContent(fromJSON: event.content),
let prevContentDict = event.prevContent,
let oldContent = MXRoomMemberEventContent(fromJSON: prevContentDict),
newContent.membership == kMXMembershipStringJoin,
oldContent.membership == kMXMembershipStringJoin {
// Check for display name changes
if newContent.displayname != oldContent.displayname {
// If there was a change, use the sender's userID if one was blank and show the change.
if let oldDisplayname = oldContent.displayname ?? event.sender,
let displayname = newContent.displayname ?? event.sender {
notificationBody = NotificationService.localizedString(forKey: "USER_UPDATED_DISPLAYNAME", oldDisplayname, displayname)
} else {
// Should never be reached as the event should always have a sender.
notificationBody = NotificationService.localizedString(forKey: "GENERIC_USER_UPDATED_DISPLAYNAME", eventSenderName)
}
} else {
// If the display name hasn't changed, handle as an avatar change.
notificationBody = NotificationService.localizedString(forKey: "USER_UPDATED_AVATAR", eventSenderName)
}
} else {
// No known reports of having reached this situation for a membership notification
// So use a generic membership updated fallback.
notificationBody = NotificationService.localizedString(forKey: "USER_MEMBERSHIP_UPDATED", eventSenderName)
}
// Otherwise treat the notification as an invite.
// This is the expected notification content for a membership event.
} else {
if let roomDisplayName = roomDisplayName, roomDisplayName != eventSenderName {
notificationBody = NotificationService.localizedString(forKey: "USER_INVITE_TO_NAMED_ROOM", eventSenderName, roomDisplayName)
} else {
notificationBody = NotificationService.localizedString(forKey: "USER_INVITE_TO_CHAT", eventSenderName)
}
}
case .sticker:
notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName)
notificationBody = NotificationService.localizedString(forKey: "STICKER_FROM_USER", eventSenderName)
// Reactions are unexpected notification types, but have been seen in some circumstances.
case .reaction:
notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName)
if let reactionKey = event.relatesTo?.key {
// Try to show the reaction key in the notification.
notificationBody = NotificationService.localizedString(forKey: "REACTION_FROM_USER", eventSenderName, reactionKey)
} else {
// Otherwise show a generic reaction.
notificationBody = NotificationService.localizedString(forKey: "GENERIC_REACTION_FROM_USER", eventSenderName)
}
case .custom:
if (event.type == kWidgetMatrixEventTypeString || event.type == kWidgetModularEventTypeString),
let type = event.content?["type"] as? String,
(type == kWidgetTypeJitsiV1 || type == kWidgetTypeJitsiV2) {
notificationBody = NotificationService.localizedString(forKey: "GROUP_CALL_STARTED")
notificationTitle = roomDisplayName
// 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)
} else {
additionalUserInfo = [Constants.userInfoKeyPresentNotificationOnForeground: true]
}
}
case .pollStart:
notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName)
notificationBody = MXEventContentPollStart(fromJSON: event.content)?.question
case .pollEnd:
notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName)
notificationBody = VectorL10n.pollTimelineEndedText
default:
break
}
self.validateNotificationContentAndComplete(
notificationTitle: notificationTitle,
notificationBody: notificationBody,
additionalUserInfo: additionalUserInfo,
ignoreBadgeUpdate: ignoreBadgeUpdate,
threadIdentifier: threadIdentifier,
currentUserId: currentUserId,
event: event,
pushRule: pushRule,
onComplete: onComplete
)
case .failure(let error):
MXLog.debug("[NotificationService] notificationContentForEvent: error: \(error)")
onComplete(nil, false)
}
})
}
private func validateNotificationContentAndComplete(
notificationTitle: String?,
notificationBody: String?,
additionalUserInfo: [AnyHashable: Any]?,
ignoreBadgeUpdate: Bool,
threadIdentifier: String?,
currentUserId: String?,
event: MXEvent,
pushRule: MXPushRule?,
onComplete: @escaping (UNNotificationContent?, Bool) -> Void
) {
var validatedNotificationBody: String? = notificationBody
var validatedNotificationTitle: String? = notificationTitle
if self.localAuthenticationService.isProtectionSet {
MXLog.debug("[NotificationService] validateNotificationContentAndComplete: Resetting title and body because app protection is set")
validatedNotificationBody = NotificationService.localizedString(forKey: "MESSAGE_PROTECTED")
validatedNotificationTitle = nil
}
guard validatedNotificationBody != nil else {
MXLog.debug("[NotificationService] validateNotificationContentAndComplete: notificationBody is nil")
onComplete(nil, false)
return
}
let notificationContent = self.notificationContent(withTitle: validatedNotificationTitle,
body: validatedNotificationBody,
threadIdentifier: threadIdentifier,
userId: currentUserId,
event: event,
pushRule: pushRule,
additionalInfo: additionalUserInfo)
MXLog.debug("[NotificationService] validateNotificationContentAndComplete: Calling onComplete.")
onComplete(notificationContent, ignoreBadgeUpdate)
}
/// Returns the default title for message notifications.
/// - Parameters:
/// - eventSenderName: The displayname of the sender.
/// - roomDisplayName: The displayname of the room the message was sent in.
/// - Returns: A string to be used for the notification's title.
private func messageTitle(for eventSenderName: String, in roomDisplayName: String?) -> String {
// Display the room name only if it is different than the sender name
if let roomDisplayName = roomDisplayName, roomDisplayName != eventSenderName {
return NotificationService.localizedString(forKey: "MSG_FROM_USER_IN_ROOM_TITLE", eventSenderName, roomDisplayName)
} else {
return eventSenderName
}
}
private func replyTitle(for eventSenderName: String, in roomDisplayName: String?) -> String {
// Display the room name only if it is different than the sender name
if let roomDisplayName = roomDisplayName, roomDisplayName != eventSenderName {
return NotificationService.localizedString(forKey: "REPLY_FROM_USER_IN_ROOM_TITLE", eventSenderName, roomDisplayName)
} else {
return NotificationService.localizedString(forKey: "REPLY_FROM_USER_TITLE", eventSenderName)
}
}
/// Get the context of an event.
/// - Parameters:
/// - event: the event
/// - roomId: the id of the room of the event.
/// - completion: Completion block that will return the room state and the sender display name.
private func context(ofEvent event: MXEvent, inRoom roomId: String,
completion: @escaping (MXResponse<(MXRoomState, String)>) -> Void) {
// First get the room state
NotificationService.backgroundSyncService.roomState(forRoomId: roomId) { (response) in
switch response {
case .success(let roomState):
// Extract the member name from room state member
let eventSender = event.sender!
let eventSenderName = roomState.members.memberName(eventSender) ?? eventSender
// Check if we are happy with it
if eventSenderName != eventSender
|| roomState.members.member(withUserId: eventSender) != nil {
completion(.success((roomState, eventSenderName)))
return
}
// Else, if the room member is not known, use the user profile to avoid to display a Matrix id
NotificationService.backgroundSyncService.profile(ofMember: eventSender, inRoom: roomId) { (response) in
switch response {
case .success((let displayName, _)):
guard let displayName = displayName else {
completion(.success((roomState, eventSender)))
return
}
completion(.success((roomState, displayName)))
case .failure(_):
completion(.success((roomState, eventSender)))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
private func notificationContent(withTitle title: String?,
body: String?,
threadIdentifier: String?,
userId: String?,
event: MXEvent,
pushRule: MXPushRule?,
additionalInfo: [AnyHashable: Any]? = nil) -> 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,
additionalInfo: additionalInfo)
return notificationContent
}
private func notificationUserInfo(forEvent event: MXEvent,
andUserId userId: String?,
additionalInfo: [AnyHashable: Any]? = nil) -> [AnyHashable: Any] {
var notificationUserInfo: [AnyHashable: Any] = [
"type": "full",
"room_id": event.roomId as Any,
"event_id": event.eventId as Any
]
if let threadId = event.threadId {
notificationUserInfo["thread_id"] = threadId
}
if let userId = userId {
notificationUserInfo["user_id"] = userId
}
if let additionalInfo = additionalInfo {
for (key, value) in additionalInfo {
notificationUserInfo[key] = value
}
}
return notificationUserInfo
}
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"
}
}
MXLog.debug("Sound name: \(String(describing: soundName))")
return soundName
}
private func notificationCategoryIdentifier(forEvent event: MXEvent) -> String? {
let isNotificationContentShown = (!event.isEncrypted || self.showDecryptedContentInNotifications)
&& !localAuthenticationService.isProtectionSet
guard isNotificationContentShown else {
return Constants.toBeRemovedNotificationCategoryIdentifier
}
if event.eventType == .callInvite {
return Constants.callInviteNotificationCategoryIdentifier
}
guard event.eventType == .roomMessage || event.eventType == .roomEncrypted else {
return Constants.toBeRemovedNotificationCategoryIdentifier
}
// Don't return QUICK_REPLY here as there is an issue
// with crypto corruption when sending from extensions.
return nil
}
/// Attempts to send trigger a VoIP push for the given event
/// - Parameter event: The call invite event.
private func sendVoipPush(forEvent event: MXEvent) {
guard let token = pushNotificationStore.pushKitToken else {
return
}
if #available(iOS 13.0, *) {
if event.isEncrypted {
guard let clearEvent = event.clear else {
MXLog.debug("[NotificationService] sendVoipPush: Do not send a VoIP push for undecrypted event, it'll cause a crash.")
return
}
// Add some original data on the clear event
clearEvent.eventId = event.eventId
clearEvent.originServerTs = event.originServerTs
clearEvent.sender = event.sender
clearEvent.roomId = event.roomId
pushNotificationStore.storeCallInvite(clearEvent)
} else {
pushNotificationStore.storeCallInvite(event)
}
}
ongoingVoIPPushRequests[event.eventId] = true
let appId = BuildSettings.pushKitAppId
pushGatewayRestClient.notifyApp(withId: appId,
pushToken: token,
eventId: event.eventId,
roomId: event.roomId,
eventType: nil,
sender: event.sender,
timeout: NSE.Constants.voipPushRequestTimeout,
success: { [weak self] (rejected) in
MXLog.debug("[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
MXLog.debug("[NotificationService] sendVoipPush failed with error: \(error)")
guard let self = self else { return }
self.ongoingVoIPPushRequests.removeValue(forKey: event.eventId)
self.fallbackToBestAttemptContent(forEventId: event.eventId)
}
}
private func sendReadReceipt(forEvent event: MXEvent) {
guard let mxRestClient = mxRestClient else {
MXLog.error("[NotificationService] sendReadReceipt: Missing mxRestClient for read receipt request.")
return
}
guard let eventId = event.eventId,
let roomId = event.roomId else {
MXLog.error("[NotificationService] sendReadReceipt: Event information missing for read receipt request.")
return
}
mxRestClient.sendReadReceipt(toRoom: roomId, forEvent: eventId, threadId: event.threadId) { response in
if response.isSuccess {
MXLog.debug("[NotificationService] sendReadReceipt: Read receipt send successfully.")
} else if let error = response.error {
MXLog.error("[NotificationService] sendReadReceipt: Read receipt send failed", context: error)
}
}
}
private static func localizedString(forKey key: String, _ args: CVarArg...) -> String {
// The bundle needs to be an MXKLanguageBundle and contain the lproj files.
// MatrixKit now sets the app bundle as the MXKLanguageBundle
let format = NSLocalizedString(key, bundle: Bundle.app, comment: "")
let locale = LocaleProvider.locale ?? Locale.current
return String(format: format, locale: locale, arguments: args)
}
}
private extension MXPushRule {
var dontNotify: Bool {
let actions = (actions as? [MXPushRuleAction]) ?? []
// Support for MSC3987: The dont_notify push rule action is deprecated and replaced by an empty actions list.
return actions.isEmpty || actions.contains { $0.actionType == MXPushRuleActionTypeDontNotify }
}
}