| /* |
| * Copyright (C) 2021-2022 Savoir-faire Linux Inc. |
| * |
| * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> |
| * |
| * This program is free software; you can redistribute it and/or modify |
| * it under the terms of the GNU General Public License as published by |
| * the Free Software Foundation; either version 3 of the License, or |
| * (at your option) any later version. |
| * |
| * This program is distributed in the hope that it will be useful, |
| * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| * GNU General Public License for more details. |
| * |
| * You should have received a copy of the GNU General Public License |
| * along with this program; if not, write to the Free Software |
| * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
| */ |
| |
| import UserNotifications |
| import UIKit |
| import CallKit |
| import Foundation |
| import CoreFoundation |
| import os |
| import Darwin |
| import Contacts |
| |
| protocol DarwinNotificationHandler { |
| func listenToMainAppResponse(completion: @escaping (Bool) -> Void) |
| func removeObserver() |
| } |
| |
| enum NotificationField: String { |
| case key |
| case accountId = "to" |
| case aps |
| } |
| |
| enum LocalNotificationType: String { |
| case message |
| case file |
| } |
| |
| class NotificationService: UNNotificationServiceExtension { |
| |
| private static let localNotificationName = Notification.Name("com.savoirfairelinux.jami.appActive.internal") |
| |
| private let notificationTimeout = DispatchTimeInterval.seconds(25) |
| |
| private let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() |
| |
| private var contentHandler: ((UNNotificationContent) -> Void)? |
| private var bestAttemptContent = UNMutableNotificationContent() |
| |
| private var adapterService: AdapterService = AdapterService(withAdapter: Adapter()) |
| |
| private var accountIsActive = false |
| var tasksCompleted = false /// all values from dht parsed, conversation synchronized if needed and files downloaded |
| var numberOfFiles = 0 /// number of files need to be downloaded |
| var numberOfMessages = 0 /// number of scheduled messages |
| var syncCompleted = false |
| private let tasksGroup = DispatchGroup() |
| var accountId = "" |
| |
| typealias LocalNotification = (content: UNMutableNotificationContent, type: LocalNotificationType) |
| |
| private var pendingLocalNotifications = [String: [LocalNotification]]() /// local notification waiting for name lookup |
| private var pendingCalls = [String: [AnyHashable: Any]]() /// calls waiting for name lookup |
| private var names = [String: String]() /// map of peerId and best name |
| // swiftlint:disable cyclomatic_complexity |
| override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { |
| self.contentHandler = contentHandler |
| defer { |
| finish() |
| } |
| let requestData = requestToDictionary(request: request) |
| if requestData.isEmpty { |
| return |
| } |
| |
| /// if main app is active extension should save notification data and let app handle notification |
| saveData(data: requestData) |
| if appIsActive() { |
| return |
| } |
| |
| guard let account = requestData[NotificationField.accountId.rawValue] else { return } |
| accountId = account |
| |
| /// app is not active. Querry value from dht |
| guard let proxyURL = getProxyCaches(data: requestData), |
| let url = getRequestURL(data: requestData, path: proxyURL) else { |
| return |
| } |
| tasksGroup.enter() |
| let defaultSession = URLSession(configuration: .default) |
| let task = defaultSession.dataTask(with: url) {[weak self] (data, _, _) in |
| guard let self = self, |
| let data = data else { |
| self?.verifyTasksStatus() |
| return |
| } |
| let str = String(decoding: data, as: UTF8.self) |
| let lines = str.split(whereSeparator: \.isNewline) |
| for line in lines { |
| do { |
| guard let jsonData = line.data(using: .utf8), |
| let map = try JSONSerialization.jsonObject(with: jsonData, options: .allowFragments) as? [String: Any], |
| let keyPath = self.getKeyPath(data: requestData), |
| let treatedMessages = self.getTreatedMessagesPath(data: requestData) else { |
| self.verifyTasksStatus() |
| return |
| } |
| let result = self.adapterService.decrypt(keyPath: keyPath.path, messagesPath: treatedMessages.path, value: map) |
| let handleCall: (String, String) -> Void = { [weak self] (peerId, hasVideo) in |
| guard let self = self else { |
| return |
| } |
| /// jami will be started. Set accounts to not active state |
| if self.accountIsActive { |
| self.accountIsActive = false |
| self.adapterService.stop() |
| } |
| var info = request.content.userInfo |
| info["peerId"] = peerId |
| info["hasVideo"] = hasVideo |
| let name = self.bestName(accountId: self.accountId, contactId: peerId) |
| if name.isEmpty { |
| info["displayName"] = peerId |
| self.pendingCalls[peerId] = info |
| self.startAddressLookup(address: peerId, accountId: self.accountId) |
| return |
| } |
| info["displayName"] = name |
| self.presentCall(info: info) |
| } |
| self.handleResult(result: result, handleCall: handleCall) |
| } catch { |
| print("serialization failed , \(error)") |
| } |
| } |
| self.verifyTasksStatus() |
| } |
| task.resume() |
| _ = tasksGroup.wait(timeout: .now() + notificationTimeout) |
| } |
| |
| override func serviceExtensionTimeWillExpire() { |
| finish() |
| } |
| |
| private func handleResult(result: AdapterService.PeerConnectionRequestType, handleCall: @escaping (String, String) -> Void) { |
| switch result { |
| case .call(let peerId, let hasVideo): |
| handleCall(peerId, "\(hasVideo)") |
| return |
| case .gitMessage: |
| /// check if account already acive |
| guard !self.accountIsActive else { break } |
| self.accountIsActive = true |
| self.adapterService.startAccountsWithListener(accountId: self.accountId) { [weak self] event, eventData in |
| guard let self = self else { |
| return |
| } |
| switch event { |
| case .message: |
| self.numberOfMessages += 1 |
| self.configureMessageNotification(from: eventData.jamiId, body: eventData.content, accountId: self.accountId, conversationId: eventData.conversationId, groupTitle: "") |
| case .fileTransferDone: |
| if let url = URL(string: eventData.content) { |
| self.configureFileNotification(from: eventData.jamiId, url: url, accountId: self.accountId, conversationId: eventData.conversationId) |
| } else { |
| self.numberOfFiles -= 1 |
| self.verifyTasksStatus() |
| } |
| case .syncCompleted: |
| self.syncCompleted = true |
| self.verifyTasksStatus() |
| case .fileTransferInProgress: |
| self.numberOfFiles += 1 |
| case .call: |
| handleCall(eventData.jamiId, eventData.content) |
| case .invitation: |
| self.syncCompleted = true |
| self.numberOfMessages += 1 |
| self.configureMessageNotification(from: eventData.jamiId, body: eventData.content, accountId: self.accountId, conversationId: eventData.conversationId, groupTitle: eventData.groupTitle) |
| } |
| } |
| case .unknown: |
| break |
| } |
| } |
| |
| private func verifyTasksStatus() { |
| guard !self.tasksCompleted else { return } /// we already left taskGroup |
| /// waiting for lookup |
| if !pendingCalls.isEmpty || !pendingLocalNotifications.isEmpty { |
| return |
| } |
| /// We could finish in two cases: |
| /// 1. we did not start account we are not waiting for the signals from the daemon |
| /// 2. conversation synchronization completed and all files downloaded |
| if !self.accountIsActive || (self.syncCompleted && self.numberOfFiles == 0 && self.numberOfMessages == 0) { |
| self.tasksCompleted = true |
| self.tasksGroup.leave() |
| } |
| } |
| |
| private func finish() { |
| if self.accountIsActive { |
| self.accountIsActive = false |
| self.adapterService.stop() |
| } |
| /// cleanup pending notifications |
| if !self.pendingCalls.isEmpty, let info = self.pendingCalls.first?.value { |
| self.presentCall(info: info) |
| } else { |
| for notifications in pendingLocalNotifications { |
| for notification in notifications.value { |
| self.presentLocalNotification(notification: notification) |
| } |
| } |
| pendingLocalNotifications.removeAll() |
| } |
| if let contentHandler = contentHandler { |
| contentHandler(self.bestAttemptContent) |
| } |
| } |
| |
| private func appIsActive() -> Bool { |
| let group = DispatchGroup() |
| defer { |
| self.removeObserver() |
| group.leave() |
| } |
| var appIsActive = false |
| group.enter() |
| /// post darwin notification and wait for the answer from the main app. If answer received app is active |
| self.listenToMainAppResponse { _ in |
| appIsActive = true |
| } |
| CFNotificationCenterPostNotification(notificationCenter, CFNotificationName(Constants.notificationReceived), nil, nil, true) |
| /// wait fro 100 milliseconds. If no answer from main app is received app is not active. |
| _ = group.wait(timeout: .now() + 0.3) |
| |
| return appIsActive |
| } |
| |
| private func saveData(data: [String: String]) { |
| guard let userDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) else { |
| return |
| } |
| var notificationData = [[String: String]]() |
| if let existingData = userDefaults.object(forKey: Constants.notificationData) as? [[String: String]] { |
| notificationData = existingData |
| } |
| notificationData.append(data) |
| userDefaults.set(notificationData, forKey: Constants.notificationData) |
| } |
| |
| private func setNotificationCount(notification: UNMutableNotificationContent) { |
| guard let userDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) else { |
| return |
| } |
| |
| if let count = userDefaults.object(forKey: Constants.notificationsCount) as? NSNumber { |
| let new: NSNumber = count.intValue + 1 as NSNumber |
| notification.badge = new |
| userDefaults.set(new, forKey: Constants.notificationsCount) |
| } |
| } |
| |
| private func requestToDictionary(request: UNNotificationRequest) -> [String: String] { |
| var dictionary = [String: String]() |
| let userInfo = request.content.userInfo |
| for key in userInfo.keys { |
| /// "aps" is a field added for alert notification type, so it could be received in the extension. This field is not needed by dht |
| if String(describing: key) == NotificationField.aps.rawValue { |
| continue |
| } |
| if let value = userInfo[key] { |
| let keyString = String(describing: key) |
| let valueString = String(describing: value) |
| dictionary[keyString] = valueString |
| } |
| } |
| return dictionary |
| } |
| |
| private func requestedData(request: UNNotificationRequest, map: [String: Any]) -> Bool { |
| guard let userInfo = request.content.userInfo as? [String: Any] else { return false } |
| guard let valueIds = userInfo["valueIds"] as? [String: String], |
| let id = map["id"] else { |
| return false |
| } |
| return valueIds.values.contains("\(id)") |
| } |
| |
| private func bestName(accountId: String, contactId: String) -> String { |
| if let name = self.names[contactId], !name.isEmpty { |
| return name |
| } |
| if let contactProfileName = self.contactProfileName(accountId: accountId, contactId: contactId), |
| !contactProfileName.isEmpty { |
| self.names[contactId] = contactProfileName |
| return contactProfileName |
| } |
| let registeredName = self.adapterService.getNameFor(address: contactId, accountId: accountId) |
| if !registeredName.isEmpty { |
| self.names[contactId] = registeredName |
| } |
| return registeredName |
| } |
| |
| private func startAddressLookup(address: String, accountId: String) { |
| var nameServer = self.adapterService.getNameServerFor(accountId: accountId) |
| nameServer = ensureURLPrefix(urlString: nameServer) |
| let urlString = nameServer + "/addr/" + address |
| guard let url = URL(string: urlString) else { |
| self.lookupCompleted(address: address, name: nil) |
| return |
| } |
| let defaultSession = URLSession(configuration: .default) |
| let task = defaultSession.dataTask(with: url) {[weak self](data, response, _) in |
| guard let self = self else { return } |
| var name: String? |
| defer { |
| self.lookupCompleted(address: address, name: name) |
| } |
| guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, |
| let data = data else { |
| return |
| } |
| do { |
| guard let map = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: String] else { return } |
| if map["name"] != nil { |
| name = map["name"] |
| self.names[address] = name |
| } |
| } catch { |
| print("serialization failed , \(error)") |
| } |
| } |
| task.resume() |
| } |
| |
| private func ensureURLPrefix(urlString: String) -> String { |
| var urlWithPrefix = urlString |
| if !urlWithPrefix.hasPrefix("http://") && !urlWithPrefix.hasPrefix("https://") { |
| urlWithPrefix = "http://" + urlWithPrefix |
| } |
| return urlWithPrefix |
| } |
| |
| private func lookupCompleted(address: String, name: String?) { |
| for call in pendingCalls where call.key == address { |
| var info = call.value |
| if let name = name { |
| info["displayName"] = name |
| } |
| presentCall(info: info) |
| return |
| } |
| for pending in pendingLocalNotifications where pending.key == address { |
| let notifications = pending.value |
| for notification in notifications { |
| if let name = name { |
| notification.content.title = name |
| } |
| presentLocalNotification(notification: notification) |
| } |
| pendingLocalNotifications.removeValue(forKey: address) |
| } |
| } |
| |
| private func needUpdateNotification(notification: LocalNotification, peerId: String, accountId: String) { |
| if var pending = pendingLocalNotifications[peerId] { |
| pending.append(notification) |
| pendingLocalNotifications[peerId] = pending |
| } else { |
| pendingLocalNotifications[peerId] = [notification] |
| } |
| startAddressLookup(address: peerId, accountId: accountId) |
| } |
| } |
| // MARK: paths |
| extension NotificationService { |
| |
| private func getRequestURL(data: [String: String], proxyURL: URL) -> URL? { |
| guard let key = data[NotificationField.key.rawValue] else { |
| return nil |
| } |
| return proxyURL.appendingPathComponent(key) |
| } |
| |
| private func getRequestURL(data: [String: String], path: URL) -> URL? { |
| guard let key = data[NotificationField.key.rawValue], |
| let jsonData = NSData(contentsOf: path) as? Data else { |
| return nil |
| } |
| guard let map = try? JSONSerialization.jsonObject(with: jsonData, options: .allowFragments) as? [String: String], |
| var proxyAddress = map.first?.value else { |
| return nil |
| } |
| |
| proxyAddress = ensureURLPrefix(urlString: proxyAddress) |
| guard let urlPrpxy = URL(string: proxyAddress) else { return nil } |
| return urlPrpxy.appendingPathComponent(key) |
| } |
| |
| private func getKeyPath(data: [String: String]) -> URL? { |
| guard let documentsPath = Constants.documentsPath, |
| let accountId = data[NotificationField.accountId.rawValue] else { |
| return nil |
| } |
| return documentsPath.appendingPathComponent(accountId).appendingPathComponent("ring_device.key") |
| } |
| |
| private func getTreatedMessagesPath(data: [String: String]) -> URL? { |
| guard let cachesPath = Constants.cachesPath, |
| let accountId = data[NotificationField.accountId.rawValue] else { |
| return nil |
| } |
| return cachesPath.appendingPathComponent(accountId).appendingPathComponent("treatedMessages") |
| } |
| |
| private func getProxyCaches(data: [String: String]) -> URL? { |
| guard let cachesPath = Constants.cachesPath, |
| let accountId = data[NotificationField.accountId.rawValue] else { |
| return nil |
| } |
| return cachesPath.appendingPathComponent(accountId).appendingPathComponent("dhtproxy") |
| } |
| |
| private func contactProfileName(accountId: String, contactId: String) -> String? { |
| guard let documents = Constants.documentsPath else { return nil } |
| let profileURI = "ring:" + contactId |
| let profilePath = documents.path + "/" + "\(accountId)" + "/profiles/" + "\(Data(profileURI.utf8).base64EncodedString()).vcf" |
| if !FileManager.default.fileExists(atPath: profilePath) { return nil } |
| |
| guard let data = FileManager.default.contents(atPath: profilePath), |
| let vCards = try? CNContactVCardSerialization.contacts(with: data), |
| let vCard = vCards.first else { return nil } |
| return vCard.familyName.isEmpty ? vCard.givenName : vCard.familyName |
| } |
| } |
| |
| // MARK: DarwinNotificationHandler |
| extension NotificationService: DarwinNotificationHandler { |
| func listenToMainAppResponse(completion: @escaping (Bool) -> Void) { |
| let observer = Unmanaged.passUnretained(self).toOpaque() |
| CFNotificationCenterAddObserver(notificationCenter, |
| observer, { (_, _, _, _, _) in |
| NotificationCenter.default.post(name: NotificationService.localNotificationName, |
| object: nil, |
| userInfo: nil) |
| }, |
| Constants.notificationAppIsActive, |
| nil, |
| .deliverImmediately) |
| NotificationCenter.default.addObserver(forName: NotificationService.localNotificationName, object: nil, queue: nil) { _ in |
| completion(true) |
| } |
| } |
| |
| func removeObserver() { |
| let observer = Unmanaged.passUnretained(self).toOpaque() |
| CFNotificationCenterRemoveEveryObserver(notificationCenter, observer) |
| NotificationCenter.default.removeObserver(self, name: NotificationService.localNotificationName, object: nil) |
| } |
| |
| } |
| |
| // MARK: present notifications |
| extension NotificationService { |
| private func createAttachment(identifier: String, image: UIImage, options: [NSObject: AnyObject]?) -> UNNotificationAttachment? { |
| let fileManager = FileManager.default |
| let tmpSubFolderName = ProcessInfo.processInfo.globallyUniqueString |
| let tmpSubFolderURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(tmpSubFolderName, isDirectory: true) |
| do { |
| try fileManager.createDirectory(at: tmpSubFolderURL, withIntermediateDirectories: true, attributes: nil) |
| let imageFileIdentifier = identifier |
| let fileURL = tmpSubFolderURL.appendingPathComponent(imageFileIdentifier) |
| let imageData = UIImage.pngData(image) |
| try imageData()?.write(to: fileURL) |
| let imageAttachment = try UNNotificationAttachment.init(identifier: identifier, url: fileURL, options: options) |
| return imageAttachment |
| } catch {} |
| return nil |
| } |
| |
| private func configureFileNotification(from: String, url: URL, accountId: String, conversationId: String) { |
| let content = UNMutableNotificationContent() |
| content.sound = UNNotificationSound.default |
| let imageName = url.lastPathComponent |
| content.body = imageName |
| var data = [String: String]() |
| data[Constants.NotificationUserInfoKeys.participantID.rawValue] = from |
| data[Constants.NotificationUserInfoKeys.accountID.rawValue] = accountId |
| data[Constants.NotificationUserInfoKeys.conversationID.rawValue] = conversationId |
| content.userInfo = data |
| if let image = UIImage(contentsOfFile: url.path), let attachement = createAttachment(identifier: imageName, image: image, options: nil) { |
| content.attachments = [ attachement ] |
| } |
| let title = self.bestName(accountId: accountId, contactId: from) |
| if title.isEmpty { |
| content.title = from |
| needUpdateNotification(notification: LocalNotification(content, .file), peerId: from, accountId: accountId) |
| } else { |
| content.title = title |
| presentLocalNotification(notification: LocalNotification(content, .file)) |
| } |
| } |
| |
| private func configureMessageNotification(from: String, body: String, accountId: String, conversationId: String, groupTitle: String) { |
| let content = UNMutableNotificationContent() |
| content.body = body |
| content.sound = UNNotificationSound.default |
| var data = [String: String]() |
| data[Constants.NotificationUserInfoKeys.participantID.rawValue] = from |
| data[Constants.NotificationUserInfoKeys.accountID.rawValue] = accountId |
| data[Constants.NotificationUserInfoKeys.conversationID.rawValue] = conversationId |
| content.userInfo = data |
| let title = !groupTitle.isEmpty ? groupTitle : self.bestName(accountId: accountId, contactId: from) |
| if title.isEmpty { |
| content.title = from |
| needUpdateNotification(notification: LocalNotification(content, .message), peerId: from, accountId: accountId) |
| } else { |
| content.title = title |
| presentLocalNotification(notification: LocalNotification(content, .message)) |
| } |
| } |
| |
| private func presentLocalNotification(notification: LocalNotification) { |
| let content = notification.content |
| setNotificationCount(notification: content) |
| let notificationTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.01, repeats: false) |
| let notificationRequest = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: notificationTrigger) |
| UNUserNotificationCenter.current().add(notificationRequest) { [weak self] (error) in |
| if notification.type == .message { |
| self?.numberOfMessages -= 1 |
| } else { |
| self?.numberOfFiles -= 1 |
| } |
| self?.verifyTasksStatus() |
| if let error = error { |
| print("Unable to Add Notification Request (\(error), \(error.localizedDescription))") |
| } |
| } |
| } |
| |
| private func presentCall(info: [AnyHashable: Any]) { |
| CXProvider.reportNewIncomingVoIPPushPayload(info, completion: { error in |
| print("NotificationService", "Did report voip notification, error: \(String(describing: error))") |
| }) |
| self.pendingCalls.removeAll() |
| self.pendingLocalNotifications.removeAll() |
| self.verifyTasksStatus() |
| } |
| } |