diff --git a/.gitignore b/.gitignore index c6fe5d3f7d402e7f930e53f8d4f913eb6c1e0f54..c383b719517f0288dbad05686f7ab48e68364bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ Ring/Build/ # Carthage Ring/Carthage/ + +# VSCode +.vscode/ \ No newline at end of file diff --git a/Ring/Ring.xcodeproj/project.pbxproj b/Ring/Ring.xcodeproj/project.pbxproj index bb2d27118db5383a7f823029e904e9f1213789dd..a2c4d9f9f1ea229771b87446260469d7e5b9df48 100644 --- a/Ring/Ring.xcodeproj/project.pbxproj +++ b/Ring/Ring.xcodeproj/project.pbxproj @@ -457,6 +457,7 @@ 5CE66F761FBF769B00EE9291 /* InitialLoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE66F741FBF769B00EE9291 /* InitialLoadingViewController.swift */; }; 62006E04203F4DD6003C3197 /* UITextField+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62006E03203F4DD6003C3197 /* UITextField+Helpers.swift */; }; 621231F91F880EDF009B86F0 /* UILabel+Ring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621231F81F880EDF009B86F0 /* UILabel+Ring.swift */; }; + 623196862BAE498900C2252C /* Atomics in Frameworks */ = {isa = PBXBuildFile; productRef = 623196852BAE498900C2252C /* Atomics */; }; 623660AA20092081002598C1 /* src in Resources */ = {isa = PBXBuildFile; fileRef = 623660A920092081002598C1 /* src */; }; 627F11F120348FBF006560B5 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 627F11F020348FBF006560B5 /* AvatarView.swift */; }; 62A88D371F6C2ED400F8AB18 /* PresenceAdapterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A88D361F6C2ED400F8AB18 /* PresenceAdapterDelegate.swift */; }; @@ -1286,6 +1287,7 @@ 269DA02E28D0D2E5007D51D6 /* libgmp.a in Frameworks */, 269DA07F28D0D5D0007D51D6 /* libgit2.a in Frameworks */, 269DA02B28D0D2E5007D51D6 /* libavformat.a in Frameworks */, + 623196862BAE498900C2252C /* Atomics in Frameworks */, 26A88C7826700B7500888EED /* libc++.tbd in Frameworks */, 269DA03E28D0D2E5007D51D6 /* libpjsip-simple.a in Frameworks */, 26CA4C592AB23C4100AEF8F5 /* libixml.a in Frameworks */, @@ -2574,6 +2576,9 @@ dependencies = ( ); name = jamiNotificationExtension; + packageProductDependencies = ( + 623196852BAE498900C2252C /* Atomics */, + ); productName = jamiNotificationExtension; productReference = 26A88C04266FFFC800888EED /* jamiNotificationExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -2692,6 +2697,7 @@ mainGroup = 043999EA1D1C2D9D00E99CD9; packageReferences = ( 5593D7D12B7FE00A00DA109C /* XCRemoteSwiftPackageReference "MCEmojiPicker" */, + 623196842BAE498900C2252C /* XCRemoteSwiftPackageReference "swift-atomics" */, ); productRefGroup = 043999F41D1C2D9D00E99CD9 /* Products */; projectDirPath = ""; @@ -4140,6 +4146,14 @@ minimumVersion = 1.2.3; }; }; + 623196842BAE498900C2252C /* XCRemoteSwiftPackageReference "swift-atomics" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-atomics.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -4148,6 +4162,11 @@ package = 5593D7D12B7FE00A00DA109C /* XCRemoteSwiftPackageReference "MCEmojiPicker" */; productName = MCEmojiPicker; }; + 623196852BAE498900C2252C /* Atomics */ = { + isa = XCSwiftPackageProductDependency; + package = 623196842BAE498900C2252C /* XCRemoteSwiftPackageReference "swift-atomics" */; + productName = Atomics; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 043999EB1D1C2D9D00E99CD9 /* Project object */; diff --git a/Ring/Ring/Bridging/DRingAdapter.mm b/Ring/Ring/Bridging/DRingAdapter.mm index a7851b542e84419d1b41555d819a67e71c3bc4d6..59a7dd75fa36352e3e182e5a6f1f3289ea5ebd45 100644 --- a/Ring/Ring/Bridging/DRingAdapter.mm +++ b/Ring/Ring/Bridging/DRingAdapter.mm @@ -45,7 +45,7 @@ using namespace libjami; - (BOOL) initDaemonInternal { #if DEBUG - int flag = LIBJAMI_FLAG_CONSOLE_LOG | LIBJAMI_FLAG_DEBUG; + int flag = 0; #else int flag = 0; #endif diff --git a/Ring/Ring/Constants/Constants.swift b/Ring/Ring/Constants/Constants.swift index a2fd08bb4b4de4edaa5a952738332a79203cd936..d458c337ed678205313332ec2dacdef7296ac6d0 100644 --- a/Ring/Ring/Constants/Constants.swift +++ b/Ring/Ring/Constants/Constants.swift @@ -106,6 +106,7 @@ public class Constants: NSObject { @objc public static let updatedConversations = "updatedConversations" @objc public static let appGroupIdentifier = "group.com.savoirfairelinux.ring" @objc public static let notificationsCount = "notificationsCount" + @objc public static let appIdentifier = "com.savoirfairelinux.jami" @objc public static let documentsPath: URL? = { return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?.appendingPathComponent("Documents") diff --git a/Ring/jamiNotificationExtension/Adapter.mm b/Ring/jamiNotificationExtension/Adapter.mm index 968341e7f4351cd98918a0deb11331f9e48cc96a..837151e23915238b13ca0cf55cdab1c0d2ff66dd 100644 --- a/Ring/jamiNotificationExtension/Adapter.mm +++ b/Ring/jamiNotificationExtension/Adapter.mm @@ -179,10 +179,9 @@ std::map<std::string, std::string> nameServers; loadAccountAndConversation(std::string([accountId UTF8String]), loadAll, std::string([convId UTF8String])); return true; } -#if DEBUG - int flag = LIBJAMI_FLAG_CONSOLE_LOG | LIBJAMI_FLAG_DEBUG | LIBJAMI_FLAG_IOS_EXTENSION | LIBJAMI_FLAG_NO_AUTOSYNC | LIBJAMI_FLAG_NO_LOCAL_AUDIO | LIBJAMI_FLAG_NO_AUTOLOAD; -#else int flag = LIBJAMI_FLAG_IOS_EXTENSION | LIBJAMI_FLAG_NO_AUTOSYNC | LIBJAMI_FLAG_NO_LOCAL_AUDIO | LIBJAMI_FLAG_NO_AUTOLOAD; +#if DEBUG + flag |= LIBJAMI_FLAG_CONSOLE_LOG | LIBJAMI_FLAG_DEBUG; #endif if (![[NSThread currentThread] isMainThread]) { __block bool success; @@ -236,6 +235,7 @@ std::map<std::string, std::string> nameServers; return {}; } + // Build the key using the key path argument NSData* data = [[NSFileManager defaultManager] contentsAtPath:keyPath]; const uint8_t* bytes = (const uint8_t*) [data bytes]; dht::crypto::PrivateKey dhtKey(bytes, [data length], ""); diff --git a/Ring/jamiNotificationExtension/AdapterService.swift b/Ring/jamiNotificationExtension/AdapterService.swift index 888d32554558bd0da577d8bdf6c16679aef87bf2..e6db3418964f63d02d00f23bb28417d32e274257 100644 --- a/Ring/jamiNotificationExtension/AdapterService.swift +++ b/Ring/jamiNotificationExtension/AdapterService.swift @@ -108,6 +108,9 @@ class AdapterService { } func decrypt(keyPath: String, accountId: String, messagesPath: String, value: [String: Any]) -> PeerConnectionRequestType { + if self.adapter == nil { + return .unknown + } let result = adapter.decrypt(keyPath, accountId: accountId, treated: messagesPath, value: value) guard let peerId = result?.keys.first, let type = result?.values.first else { diff --git a/Ring/jamiNotificationExtension/NotificationService.swift b/Ring/jamiNotificationExtension/NotificationService.swift index 32f3233b33fb57dd8a30be01b2b3266b9d5729ec..95e269438e252ea3fa87a29e9e769612be3fc567 100644 --- a/Ring/jamiNotificationExtension/NotificationService.swift +++ b/Ring/jamiNotificationExtension/NotificationService.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021-2022 Savoir-faire Linux Inc. + * Copyright (C) 2021-2024 Savoir-faire Linux Inc. * * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> * @@ -26,6 +26,41 @@ import CoreFoundation import os import Darwin import Contacts +import RxSwift +import Atomics + +/* + * This class is responsible for handling incoming notifications from the DHT proxy server. + * The steps are as follows: + * 1. The notification request is received as a JSON object and is processed to extract the necessary data + * 2. If the main app is active, the notification data is saved and the app is notified to handle synchronization + * instead of the extension + * 3. If the main app is not active, the notification data is used to start a data stream from the proxy server + * over HTTP + * 4. The data stream is processed line by line, and each line is decrypted and processed + * 5. The decrypted data is used to obtain the information needed to determine the action to take + * 6. The action is taken, which may involve presenting a local notification or stopping the current backend + * instance and handing off control the foreground app (in the case of an incoming call) + * + * The class also handles the retrieval of contact names from the name server, which is done asynchronously. + * In the case of a name being required, the notification is enqueued and the name is retrieved before the + * notification is presented. + * + * The actions taken based on the notification data are as follows: + * - If the data is a call, the call is presented (the extension can be stopped as the foreground app will take over) + * - If the data is a message, the backend is started (if not already active) and events are parsed until the message + * body is received and enqueue for presentation + * - If the data is a file, the backend is started (if not already active) and events are parsed until the file is + * downloaded and the notification is enqueued for presentation + * - If the data is a clone request, the backend is started and events are parsed until the clone is completed + * + * Backend event handling is kept alive using a simple reference counting mechanism `itemsToPresent` and `syncCompleted` + * which are incremented and decremented as events are processed and completed. When all events are processed and the + * clone is completed, the backend is stopped. The HTTP stream is cancelled once all value IDs are processed or the + * notification times out (25 seconds). + */ + +// swiftlint:disable file_length protocol DarwinNotificationHandler { func listenToMainAppResponse(completion: @escaping (Bool) -> Void) @@ -43,131 +78,317 @@ enum LocalNotificationType: String { case file } -class NotificationService: UNNotificationServiceExtension { +struct NotificationConfig { + let from: String + var url: URL? + let body: String + let conversationId: String + let groupTitle: String +} - private static let localNotificationName = Notification.Name("com.savoirfairelinux.jami.appActive.internal") +// A local log helper that prints an easy to see log with the thread info +func log(_ messages: String...) { + let message = messages.joined(separator: " ") + print("------ [\(Unmanaged.passUnretained(Thread.current).toOpaque())] \(message)") +} - private let notificationTimeout = DispatchTimeInterval.seconds(25) +// MARK: AutoDispatchGroup helper +class AutoDispatchGroup { + private var taskIds = Set<String>() + private let group = DispatchGroup() + private let tasksQueue = DispatchQueue(label: Constants.appIdentifier + ".AutoDispatchGroup.queue") - private let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() + func enter(id: String) { + tasksQueue.sync { + if taskIds.contains(id) { + log("Task with ID \(id) already exists") + } else { + log("AutoDispatchGroup entering new task: \(id)") + taskIds.insert(id) + group.enter() + } + } + } - private var contentHandler: ((UNNotificationContent) -> Void)? - private var bestAttemptContent = UNMutableNotificationContent() + func leave(id: String) { + tasksQueue.sync { + guard taskIds.contains(id) else { return } + log("AutoDispatchGroup leaving task: \(id)") + taskIds.remove(id) + group.leave() + } + } - private var adapterService: AdapterService = AdapterService(withAdapter: Adapter()) + func wait(timeout: DispatchTime = .distantFuture) -> DispatchTimeoutResult { + group.wait(timeout: timeout) + } +} + +class HTTPStreamHandler: NSObject, URLSessionDataDelegate { + private lazy var session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 10 + config.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + private var dataBuffer = Data() + private var task: URLSessionDataTask? + private var subject = PublishSubject<String>() + private let taskQueue = DispatchQueue(label: Constants.appIdentifier + ".HTTPStreamHandler.queue") + + func invalidateAndCancelSession() { + session.invalidateAndCancel() + } + + private func startTask(url: URL) { + taskQueue.sync { [weak self] in + guard let self = self else { return } + self.task = self.session.dataTask(with: url) + log("Starting URL Stream: \(url)") + self.task?.resume() + } + } + + func startStreaming(from url: URL) -> Observable<String> { + return subject + .do(onSubscribe: { [weak self] in + self?.startTask(url: url) + }) + .do(onDispose: { [weak self] in + _ = self?.cancelPendingDataTask() + }) + } + + private func cancelPendingDataTask() -> Bool { + taskQueue.sync { [weak self] in + guard let self = self else { return false } + if self.task?.state == .running { + self.task?.cancel() + return true + } + return false + } + } - 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 - var waitForCloning = false - private let tasksGroup = DispatchGroup() - var accountId = "" - let thumbnailSize = 100 + func cancelStreaming() { + if cancelPendingDataTask() { + log("Stream handling canceled") + subject.onCompleted() + } + } + // MARK: URLSessionDataDelegate + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + var receivedStrings = [String]() + taskQueue.sync { [weak self] in + guard let self = self else { return } + self.dataBuffer.append(data) + while let range = self.dataBuffer.range(of: "\n".data(using: .utf8)!) { + let lineData = self.dataBuffer.subdata(in: 0..<range.lowerBound) + self.dataBuffer.removeSubrange(0..<range.upperBound) + if let lineString = String(data: lineData, encoding: .utf8) { + receivedStrings.append(lineString) + } + } + } + for string in receivedStrings { + subject.onNext(string) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + self.subject.onError(error) + } else { + self.subject.onCompleted() + } + } +} + +// MARK: NotificationService +class NotificationService: UNNotificationServiceExtension { 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 + private static let localNotificationName = Notification.Name(Constants.appIdentifier + ".appActive.internal") + + private let notificationTimeout = DispatchTimeInterval.seconds(25) + private let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent = UNMutableNotificationContent() + + // All asynchronous tasks are managed using the AutoDispatchGroup which tracks tasks using + // IDs. Both the streaming and Jami backend tasks will be waited upon using this group. + private let autoDispatchGroup = AutoDispatchGroup() + private let httpStreamHandler = HTTPStreamHandler() + private let disposeBag = DisposeBag() + + // The following objects are used to manage access to the Jami backend for synchronization + private var accountIsActive = ManagedAtomic<Bool>(false) + private var accountId: String = "" + private var adapterService: AdapterService = AdapterService(withAdapter: Adapter()) + private var jamiTaskId: String = "" + private var idsToProcess: Set<String> = [] + private var processAll: Bool = false + private let taskPropertyQueue = DispatchQueue(label: Constants.appIdentifier + ".TaskProperty.queue") + // The following describe scheduled events and will be synchronized with the DispatchQueue + private var itemsToPresent = 0 + private var syncCompleted = false + private var waitForCloning = false + + // A queue of pending local notifications, waiting for a name lookup + private let notificationQueue = DispatchQueue(label: Constants.appIdentifier + ".Notification.queue") + 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 + private let thumbnailSize = 100 + + deinit { + httpStreamHandler.invalidateAndCancelSession() + } + + // Entry point for processing incoming notification requests. override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler - defer { - finish() + + Task { + self.processNotificationRequest(request) + _ = autoDispatchGroup.wait(timeout: .now() + notificationTimeout) + self.finish() } + } + + // Handles the initial processing of the notification request. + private func processNotificationRequest(_ request: UNNotificationRequest) { let requestData = requestToDictionary(request: request) - if requestData.isEmpty { + guard !requestData.isEmpty, + let accountId = requestData[NotificationField.accountId.rawValue] else { + log("There was an error processing the notification request") return } - /// if main app is active extension should save notification data and let app handle notification - saveData(data: requestData) - if appIsActive() { + // Save notification data so that if the main app is active, the extension can let the app handle notification + saveDataIfNeeded(data: requestData) + guard !appIsActive() else { + log("App is in foreground") return } - guard let account = requestData[NotificationField.accountId.rawValue] else { return } - accountId = account - if isResubscribe(accountId: accountId, data: requestData) { + guard !isResubscribe(accountId: accountId, data: requestData) else { + log("This is a resubscribe notification") return } - /// app is not active. Querry value from dht - guard let proxyURL = getProxyCaches(data: requestData), + + log("Handling new notification (app in background)") + + self.accountId = accountId + prepareAndStartStreaming(for: request, with: requestData) + } + + // Prepares for and starts the data stream based on notification data. + private func prepareAndStartStreaming(for request: UNNotificationRequest, with requestData: [String: String]) { + guard let keyURL = getKeyURL(data: requestData), + let treatedMessagesURL = getTreatedMessagesURL(data: requestData), + 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() + + // Transform the comma-separated ids string + if let idsString = requestData["ids"] { + self.idsToProcess = Set(idsString.split(separator: ",").map { String($0) }) + } + self.processAll = self.idsToProcess.isEmpty + + startStreaming(from: url, for: request, keyURL: keyURL, treatedMessagesURL: treatedMessagesURL) + } + + // Starts streaming data from a specified URL and processes received lines. + private func startStreaming(from url: URL, for request: UNNotificationRequest, keyURL: URL, treatedMessagesURL: URL) { + let taskId = UUID().uuidString + autoDispatchGroup.enter(id: taskId) + + httpStreamHandler.startStreaming(from: url) + .subscribe(onNext: { [weak self] line in + self?.processStreamLine(line, with: request, keyURL: keyURL, treatedMessagesURL: treatedMessagesURL) + }, onError: { [weak self] error in + log("Error streaming data: \(error)") + self?.autoDispatchGroup.leave(id: taskId) + }, onCompleted: { [weak self] in + self?.autoDispatchGroup.leave(id: taskId) + }) + .disposed(by: disposeBag) + } + + // Processes each line received from the data stream. + private func processStreamLine(_ line: String, with request: UNNotificationRequest, keyURL: URL, treatedMessagesURL: URL) { + do { + guard let jsonData = line.data(using: .utf8), + let map = try JSONSerialization.jsonObject(with: jsonData, options: .allowFragments) as? [String: Any], + let id = map["id"] as? String, + map["cypher"] != nil else { + log("Line doesn't contain a valid schema") 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, accountId: self.accountId, messagesPath: treatedMessages.path, value: map) - let handleCall: (String, String) -> Void = { [weak self] (peerId, hasVideo) in - guard let self = self else { - return - } - var info = request.content.userInfo - info["peerId"] = peerId - info["hasVideo"] = hasVideo - let name = self.bestName(accountId: self.accountId, contactId: peerId) - /// jami will be started. Set accounts to not active state - if self.accountIsActive { - self.accountIsActive = false - self.adapterService.stop(accountId: self.accountId) - } - 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) - } - switch result { - case .call(let peerId, let hasVideo): - handleCall(peerId, "\(hasVideo)") - return - case .gitMessage(let convId): - self.handleGitMessage(convId: convId, loadAll: convId.isEmpty) - case .clone: - // Should start daemon and wait until clone completed - self.waitForCloning = true - self.handleGitMessage(convId: "", loadAll: false) - case .unknown: - break - } - } catch { - print("serialization failed , \(error)") - } + + guard idsToProcess.contains(id) || processAll else { + log("Skipping line; ID is not in the list: \(id)") + return } - self.verifyTasksStatus() + + log("Processing ID: \(id)") + idsToProcess.remove(id) + processMap(map: map, keyURL: keyURL, treatedMessagesURL: treatedMessagesURL, userInfo: request.content.userInfo) + if !processAll && idsToProcess.isEmpty { + log("All IDs processed; Canceling stream") + httpStreamHandler.cancelStreaming() + } + } catch { + log("Stream decoding error: \(error) line: \(line)") } - task.resume() - _ = tasksGroup.wait(timeout: .now() + notificationTimeout) } - override func serviceExtensionTimeWillExpire() { - if !self.tasksCompleted { - self.tasksCompleted = true - self.tasksGroup.leave() + private func processMap(map: [String: Any], keyURL: URL, treatedMessagesURL: URL, userInfo: [AnyHashable: Any]) { + let result = adapterService.decrypt(keyPath: keyURL.path, accountId: self.accountId, messagesPath: treatedMessagesURL.path, value: map) + log("Notification type: \(result)") + switch result { + case .call(let peerId, let hasVideo): + ({ [weak self] (peerId, hasVideo) in + guard let self = self else { + return + } + var info = userInfo + info["peerId"] = peerId + info["hasVideo"] = hasVideo + let name = self.bestName(accountId: self.accountId, contactId: peerId) + // jami will be started. Set accounts to not active state + if self.accountIsActive.compareExchange(expected: true, desired: false, ordering: .relaxed).original { + self.adapterService.stop(accountId: self.accountId) + } + if name.isEmpty { + info["displayName"] = peerId + self.pendingCalls[peerId] = info + self.startAddressLookup(address: peerId) + return + } + info["displayName"] = name + self.presentCall(info: info) + })(peerId, "\(hasVideo)") + return + case .gitMessage(let convId): + self.handleGitMessage(convId: convId, loadAll: convId.isEmpty) // async + case .clone: + // Should start daemon and wait until clone completed + self.taskPropertyQueue.sync { self.waitForCloning = true } + self.handleGitMessage(convId: "", loadAll: false) // async + case .unknown: + break } + } + + override func serviceExtensionTimeWillExpire() { + log("Notification handling timeout") finish() } @@ -176,92 +397,108 @@ class NotificationService: UNNotificationServiceExtension { if !isResubscribe { return false } - self.accountIsActive = true + self.accountIsActive.store(true, ordering: .relaxed) self.adapterService.startAccount(accountId: accountId, convId: "", loadAll: false) self.adapterService.pushNotificationReceived(accountId: accountId, data: data) + // TODO: comment this a bit more // wait to proceed pushNotificationReceived sleep(5) return true } private func handleGitMessage(convId: String, loadAll: Bool) { - /// check if account already acive - guard !self.accountIsActive else { return } - self.accountIsActive = true + // If the account is already active, return, otherwise we set it to active and continue + if self.accountIsActive.compareExchange(expected: false, desired: true, ordering: .relaxed).original { + return + } + + jamiTaskId = UUID().uuidString + self.autoDispatchGroup.enter(id: jamiTaskId) self.adapterService.startAccountsWithListener(accountId: self.accountId, convId: convId, loadAll: loadAll) { [weak self] event, eventData in guard let self = self else { return } + + var notifConfig = NotificationConfig(from: eventData.jamiId, url: nil, body: eventData.content, + conversationId: eventData.conversationId, groupTitle: eventData.groupTitle) + switch event { case .message: self.conversationUpdated(conversationId: eventData.conversationId, accountId: self.accountId) - self.numberOfMessages += 1 - self.configureMessageNotification(from: eventData.jamiId, body: eventData.content, accountId: self.accountId, conversationId: eventData.conversationId, groupTitle: "") + self.taskPropertyQueue.sync { self.itemsToPresent += 1 } + self.configureAndPresentNotification(config: notifConfig, type: LocalNotificationType.message) case .fileTransferDone: self.conversationUpdated(conversationId: eventData.conversationId, accountId: self.accountId) + // If the content is a URL then we have already downloaded the file and can present the notification, + // otherwise we need to download the file first, so add it to the items to present if let url = URL(string: eventData.content) { - self.configureFileNotification(from: eventData.jamiId, url: url, accountId: self.accountId, conversationId: eventData.conversationId) + notifConfig.url = url + self.configureAndPresentNotification(config: notifConfig, type: LocalNotificationType.file) } else { - self.numberOfFiles -= 1 + self.taskPropertyQueue.sync { self.itemsToPresent -= 1 } self.verifyTasksStatus() } case .syncCompleted: - self.syncCompleted = true + self.taskPropertyQueue.sync { self.syncCompleted = true } self.verifyTasksStatus() case .fileTransferInProgress: - self.numberOfFiles += 1 + self.taskPropertyQueue.sync { self.itemsToPresent += 1 } case .invitation: self.conversationUpdated(conversationId: eventData.conversationId, accountId: self.accountId) - self.syncCompleted = true - self.numberOfMessages += 1 - self.configureMessageNotification(from: eventData.jamiId, - body: eventData.content, - accountId: self.accountId, - conversationId: eventData.conversationId, - groupTitle: eventData.groupTitle) + self.taskPropertyQueue.sync { + self.syncCompleted = true + self.itemsToPresent += 1 + } + self.configureAndPresentNotification(config: notifConfig, type: LocalNotificationType.message) case .conversationCloned: - self.waitForCloning = false + self.taskPropertyQueue.sync { self.waitForCloning = false } self.verifyTasksStatus() } } } private func verifyTasksStatus() { - guard !self.tasksCompleted else { return } /// we already left taskGroup - /// waiting for lookup - if !pendingCalls.isEmpty || !pendingLocalNotifications.isEmpty { - return + // waiting for lookup + self.notificationQueue.sync { + 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.waitForCloning) { - self.tasksCompleted = true - self.tasksGroup.leave() + self.taskPropertyQueue.sync { + // 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.load(ordering: .relaxed) || + (self.syncCompleted && self.itemsToPresent == 0 && !self.waitForCloning) { + self.autoDispatchGroup.leave(id: jamiTaskId) + } } } private func finish() { - if self.accountIsActive { - self.accountIsActive = false + if self.accountIsActive.compareExchange(expected: true, desired: false, ordering: .relaxed).original { self.adapterService.stop(accountId: self.accountId) } else { self.adapterService.removeDelegate() } - /// 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) + // cleanup pending notifications + self.notificationQueue.sync { + 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() } - pendingLocalNotifications.removeAll() } + self.httpStreamHandler.cancelStreaming() if let contentHandler = contentHandler { contentHandler(self.bestAttemptContent) } + log("Finished handling notification") } private func appIsActive() -> Bool { @@ -272,18 +509,18 @@ class NotificationService: UNNotificationServiceExtension { } var appIsActive = false group.enter() - /// post darwin notification and wait for the answer from the main app. If answer received app is active + // 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. + // wait fro 300 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]) { + private func saveDataIfNeeded(data: [String: String]) { guard let userDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) else { return } @@ -324,18 +561,6 @@ class NotificationService: UNNotificationServiceExtension { userDefaults.set(conversationData, forKey: Constants.updatedConversations) } - 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 @@ -352,16 +577,10 @@ class NotificationService: UNNotificationServiceExtension { } 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)") - } - +// MARK: Name retrieval +extension NotificationService { private func bestName(accountId: String, contactId: String) -> String { if let name = self.names[contactId], !name.isEmpty { return name @@ -378,12 +597,12 @@ class NotificationService: UNNotificationServiceExtension { return registeredName } - private func startAddressLookup(address: String, accountId: String) { - var nameServer = self.adapterService.getNameServerFor(accountId: accountId) + private func startAddressLookup(address: String) { + var nameServer = self.adapterService.getNameServerFor(accountId: self.accountId) nameServer = ensureURLPrefix(urlString: nameServer) let urlString = nameServer + "/addr/" + address guard let url = URL(string: urlString) else { - self.lookupCompleted(address: address, name: nil) + self.lookupCompleted(address: address) return } let defaultSession = URLSession(configuration: .default) @@ -391,7 +610,7 @@ class NotificationService: UNNotificationServiceExtension { guard let self = self else { return } var name: String? defer { - self.lookupCompleted(address: address, name: name) + self.lookupCompleted(address: address) } guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let data = data else { @@ -404,7 +623,7 @@ class NotificationService: UNNotificationServiceExtension { self.names[address] = name } } catch { - print("serialization failed , \(error)") + log("Serialization failed: \(error)") } } task.resume() @@ -418,7 +637,8 @@ class NotificationService: UNNotificationServiceExtension { return urlWithPrefix } - private func lookupCompleted(address: String, name: String?) { + private func lookupCompleted(address: String) { + let name = self.names[address] for call in pendingCalls where call.key == address { var info = call.value if let name = name { @@ -438,20 +658,10 @@ class NotificationService: UNNotificationServiceExtension { 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 { +// MARK: Paths and URLs +extension NotificationService { private func getRequestURL(data: [String: String], proxyURL: URL) -> URL? { guard let key = data[NotificationField.key.rawValue] else { return nil @@ -474,7 +684,7 @@ extension NotificationService { return urlPrpxy.appendingPathComponent(key) } - private func getKeyPath(data: [String: String]) -> URL? { + private func getKeyURL(data: [String: String]) -> URL? { guard let documentsPath = Constants.documentsPath, let accountId = data[NotificationField.accountId.rawValue] else { return nil @@ -482,7 +692,7 @@ extension NotificationService { return documentsPath.appendingPathComponent(accountId).appendingPathComponent("ring_device.key") } - private func getTreatedMessagesPath(data: [String: String]) -> URL? { + private func getTreatedMessagesURL(data: [String: String]) -> URL? { guard let cachesPath = Constants.cachesPath, let accountId = data[NotificationField.accountId.rawValue] else { return nil @@ -534,7 +744,7 @@ extension NotificationService: DarwinNotificationHandler { } -// MARK: present notifications +// MARK: Present and update notifications extension NotificationService { private func createAttachment(identifier: String, image: UIImage, options: [NSObject: AnyObject]?) -> UNNotificationAttachment? { let fileManager = FileManager.default @@ -587,45 +797,46 @@ extension NotificationService { return CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) } - private func configureFileNotification(from: String, url: URL, accountId: String, conversationId: String) { + // A generic function that configures a notification with the given content and type, and returns the notification + private func configureNotification(config: NotificationConfig, type: LocalNotificationType) -> LocalNotification { 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 + data[Constants.NotificationUserInfoKeys.participantID.rawValue] = config.from + data[Constants.NotificationUserInfoKeys.accountID.rawValue] = self.accountId + data[Constants.NotificationUserInfoKeys.conversationID.rawValue] = config.conversationId content.userInfo = data - if let image = createThumbnailImage(fileURLString: url.path), let attachement = createAttachment(identifier: imageName, image: image, options: nil) { - content.attachments = [ attachement ] + switch type { + case .message: + content.body = config.body + case .file: + if let url = config.url { + let imageName = url.lastPathComponent + content.body = imageName + if let image = createThumbnailImage(fileURLString: 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) + if !config.groupTitle.isEmpty { + content.title = config.groupTitle } else { - content.title = title - presentLocalNotification(notification: LocalNotification(content, .file)) + content.title = self.bestName(accountId: self.accountId, contactId: config.from) } + if content.title.isEmpty { + content.title = config.from + } + return (content, type) } - 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) + private func configureAndPresentNotification(config: NotificationConfig, type: LocalNotificationType) { + let notif = self.configureNotification(config: config, type: type) + // If the title is a URI, do a lookup and queue presentation + if notif.content.title == config.from { + enqueueNotificationForNameUpdate(notification: notif, peerId: config.from) } else { - content.title = title - presentLocalNotification(notification: LocalNotification(content, .message)) + self.presentLocalNotification(notification: notif) } } @@ -635,24 +846,48 @@ extension NotificationService { 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))") + log("Unable to Add Notification Request: (\(error), \(error.localizedDescription))") } + guard let self = self else { return } + self.taskPropertyQueue.sync { self.itemsToPresent -= 1 } + self.verifyTasksStatus() } } private func presentCall(info: [AnyHashable: Any]) { + // TODO: see if this should sync after daemon stop CXProvider.reportNewIncomingVoIPPushPayload(info, completion: { error in - print("NotificationService", "Did report voip notification, error: \(String(describing: error))") + log("NotificationService", "Did report voip notification, error: \(String(describing: error))") }) - self.pendingCalls.removeAll() - self.pendingLocalNotifications.removeAll() + self.notificationQueue.sync { + self.pendingCalls.removeAll() + self.pendingLocalNotifications.removeAll() + } self.verifyTasksStatus() } + + private func enqueueNotificationForNameUpdate(notification: LocalNotification, peerId: String) { + self.notificationQueue.sync { + if var pending = pendingLocalNotifications[peerId] { + pending.append(notification) + pendingLocalNotifications[peerId] = pending + } else { + pendingLocalNotifications[peerId] = [notification] + } + } + startAddressLookup(address: peerId) + } + + 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) + } + } }