diff --git a/apps/ios/Shared/Model/NSESubscriber.swift b/apps/ios/Shared/Model/NSESubscriber.swift new file mode 100644 index 000000000..f52e72bea --- /dev/null +++ b/apps/ios/Shared/Model/NSESubscriber.swift @@ -0,0 +1,83 @@ +// +// NSESubscriber.swift +// SimpleXChat +// +// Created by Evgeny on 09/12/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation +import SimpleXChat + +private var nseSubscribers: [UUID:NSESubscriber] = [:] + +// timeout for active notification service extension going into "suspending" state. +// If in two seconds the state does not change, we assume that it was not running and proceed with app activation/answering call. +private let SUSPENDING_TIMEOUT: TimeInterval = 2 + +// timeout should be larger than SUSPENDING_TIMEOUT +func waitNSESuspended(timeout: TimeInterval, dispatchQueue: DispatchQueue = DispatchQueue.main, suspended: @escaping (Bool) -> Void) { + if timeout <= SUSPENDING_TIMEOUT { + logger.warning("waitNSESuspended: small timeout \(timeout), using \(SUSPENDING_TIMEOUT + 1)") + } + var state = nseStateGroupDefault.get() + if case .suspended = state { + dispatchQueue.async { suspended(true) } + return + } + let id = UUID() + var suspendedCalled = false + checkTimeout() + nseSubscribers[id] = nseMessageSubscriber { msg in + if case let .state(newState) = msg { + state = newState + logger.debug("waitNSESuspended state: \(state.rawValue)") + if case .suspended = newState { + notifySuspended(true) + } + } + } + return + + func notifySuspended(_ ok: Bool) { + logger.debug("waitNSESuspended notifySuspended: \(ok)") + if !suspendedCalled { + logger.debug("waitNSESuspended notifySuspended: calling suspended(\(ok))") + suspendedCalled = true + nseSubscribers.removeValue(forKey: id) + dispatchQueue.async { suspended(ok) } + } + } + + func checkTimeout() { + if !suspending() { + checkSuspendingTimeout() + } else if state == .suspending { + checkSuspendedTimeout() + } + } + + func suspending() -> Bool { + suspendedCalled || state == .suspended || state == .suspending + } + + func checkSuspendingTimeout() { + DispatchQueue.global().asyncAfter(deadline: .now() + SUSPENDING_TIMEOUT) { + logger.debug("waitNSESuspended check suspending timeout") + if !suspending() { + notifySuspended(false) + } else if state != .suspended { + checkSuspendedTimeout() + } + } + } + + func checkSuspendedTimeout() { + DispatchQueue.global().asyncAfter(deadline: .now() + min(timeout - SUSPENDING_TIMEOUT, 1)) { + logger.debug("waitNSESuspended check suspended timeout") + if state != .suspended { + notifySuspended(false) + } + } + } +} diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 3776f9cd4..7ced1351a 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -12,26 +12,24 @@ import SimpleXChat private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") -let appSuspendTimeout: Int = 15 // seconds - let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds -let activationDelay: Double = 1.5 // seconds +let activationDelay: TimeInterval = 1.5 private func _suspendChat(timeout: Int) { // this is a redundant check to prevent logical errors, like the one fixed in this PR - let state = appStateGroupDefault.get() + let state = AppChatState.shared.value if !state.canSuspend { logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)") } else if ChatModel.ok { - appStateGroupDefault.set(.suspending) + AppChatState.shared.set(.suspending) apiSuspendChat(timeoutMicroseconds: timeout * 1000000) let endTask = beginBGTask(chatSuspended) DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) } else { - appStateGroupDefault.set(.suspended) + AppChatState.shared.set(.suspended) } } @@ -43,7 +41,7 @@ func suspendChat() { func suspendBgRefresh() { suspendLockQueue.sync { - if case .bgRefresh = appStateGroupDefault.get() { + if case .bgRefresh = AppChatState.shared.value { _suspendChat(timeout: bgSuspendTimeout) } } @@ -52,7 +50,7 @@ func suspendBgRefresh() { func terminateChat() { logger.debug("terminateChat") suspendLockQueue.sync { - switch appStateGroupDefault.get() { + switch AppChatState.shared.value { case .suspending: // suspend instantly if already suspending _chatSuspended() @@ -72,7 +70,7 @@ func terminateChat() { func chatSuspended() { suspendLockQueue.sync { - if case .suspending = appStateGroupDefault.get() { + if case .suspending = AppChatState.shared.value { _chatSuspended() } } @@ -80,7 +78,7 @@ func chatSuspended() { private func _chatSuspended() { logger.debug("_chatSuspended") - appStateGroupDefault.set(.suspended) + AppChatState.shared.set(.suspended) if ChatModel.shared.chatRunning == true { ChatReceiver.shared.stop() } @@ -89,14 +87,14 @@ private func _chatSuspended() { func setAppState(_ appState: AppState) { suspendLockQueue.sync { - appStateGroupDefault.set(appState) + AppChatState.shared.set(appState) } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") suspendLockQueue.sync { - appStateGroupDefault.set(appState) + AppChatState.shared.set(appState) if ChatModel.ok { apiActivateChat() } logger.debug("DEBUGGING: activateChat: after apiActivateChat") } @@ -120,17 +118,22 @@ func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ c ChatReceiver.shared.start() logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") } - if .active == appStateGroupDefault.get() { + if .active == AppChatState.shared.value { completion() } else if nseStateGroupDefault.get().inactive { activate() } else { - suspendLockQueue.sync { - appStateGroupDefault.set(.activating) - } - // TODO can be replaced with Mach messenger to notify the NSE to terminate and continue after reply, with timeout - dispatchQueue.asyncAfter(deadline: .now() + activationDelay) { - if appStateGroupDefault.get() == .activating { + // setting app state to "activating" to notify NSE that it should suspend + setAppState(.activating) + waitNSESuspended(timeout: 10, dispatchQueue: dispatchQueue) { ok in + if !ok { + // if for some reason NSE failed to suspend, + // e.g., it crashed previously without setting its state to "suspended", + // set it to "suspended" state anyway, so that next time app + // does not have to wait when activating. + nseStateGroupDefault.set(.suspended) + } + if AppChatState.shared.value == .activating { activate() } } @@ -143,3 +146,19 @@ func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ c logger.debug("DEBUGGING: startChatAndActivate: after activateChat") } } + +// appStateGroupDefault must not be used in the app directly, only via this singleton +class AppChatState { + static let shared = AppChatState() + private var value_ = appStateGroupDefault.get() + + var value: AppState { + value_ + } + + func set(_ state: AppState) { + appStateGroupDefault.set(state) + sendAppState(state) + value_ = state + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 991cb1a29..d75738d04 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -76,7 +76,7 @@ struct SimpleXApp: App { NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.shouldSuspendChat = false - let appState = appStateGroupDefault.get() + let appState = AppChatState.shared.value startChatAndActivate { if appState.inactive && chatModel.chatRunning == true { updateChats() diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 65ec9ef94..72515a1fa 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -415,7 +415,7 @@ struct DatabaseView: View { do { try initializeChat(start: true) m.chatDbChanged = false - appStateGroupDefault.set(.active) + AppChatState.shared.set(.active) } catch let error { fatalError("Error starting chat \(responseError(error))") } @@ -427,7 +427,7 @@ struct DatabaseView: View { m.chatRunning = true ChatReceiver.shared.start() chatLastStartGroupDefault.set(Date.now) - appStateGroupDefault.set(.active) + AppChatState.shared.set(.active) } catch let error { runChat = false alert = .error(title: "Error starting chat", error: responseError(error)) @@ -477,7 +477,7 @@ func stopChatAsync() async throws { try await apiStopChat() ChatReceiver.shared.stop() await MainActor.run { ChatModel.shared.chatRunning = false } - appStateGroupDefault.set(.stopped) + AppChatState.shared.set(.stopped) } func deleteChatAsync() async throws { diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 59b13e45b..bdb5b03e8 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -52,7 +52,7 @@ struct LocalAuthView: View { resetChatCtrl() try initializeChat(start: true) m.chatDbChanged = false - appStateGroupDefault.set(.active) + AppChatState.shared.set(.active) if m.currentUser != nil { return } var profile: Profile? = nil if let displayName = displayName, displayName != "" { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 6732bb766..c937c9ec9 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -144,19 +144,37 @@ enum NSENotification { class NSEThreads { static let shared = NSEThreads() private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") - private var threads: Set = [] + private var allThreads: Set = [] + private var activeThreads: Set = [] - func startThread() -> UUID { + func newThread() -> UUID { NSEThreads.queue.sync { - let (_, t) = threads.insert(UUID()) + let (_, t) = allThreads.insert(UUID()) return t } } + func startThread(_ t: UUID) { + NSEThreads.queue.sync { + if allThreads.contains(t) { + _ = activeThreads.insert(t) + } else { + logger.warning("NotificationService startThread: thread \(t) was removed before it started") + } + } + } + func endThread(_ t: UUID) -> Bool { NSEThreads.queue.sync { - let t_ = threads.remove(t) - return t_ != nil && threads.isEmpty + let tActive = activeThreads.remove(t) + let t = allThreads.remove(t) + if tActive != nil && activeThreads.isEmpty { + return true + } + if t != nil && allThreads.isEmpty { + NSEChatState.shared.set(.suspended) + } + return false } } } @@ -169,16 +187,20 @@ class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptNtf: NSENotification? var badgeCount: Int = 0 + // thread is added to allThreads here - if thread did not start chat, + // chat does not need to be suspended but NSE state still needs to be set to "suspended". var threadId: UUID? var receiveEntityId: String? var cancelRead: (() -> Void)? + var appSubscriber: AppSubscriber? + var returnedSuspension = false override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - threadId = NSEThreads.shared.startThread() logger.debug("DEBUGGING: NotificationService.didReceive") - if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent { - setBestAttemptNtf(ntf) - } + let newThreadId = NSEThreads.shared.newThread() + threadId = newThreadId + let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } + setBestAttemptNtf(ntf) self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() @@ -186,20 +208,32 @@ class NotificationService: UNNotificationServiceExtension { case .suspended: logger.debug("NotificationService: app is suspended") setBadgeCount() - receiveNtfMessages(request, contentHandler) + receiveNtfMessages(newThreadId, request, contentHandler) case .suspending: logger.debug("NotificationService: app is suspending") setBadgeCount() Task { - var state = appState - for _ in 1...6 { - _ = try await Task.sleep(nanoseconds: suspendingDelay) - state = appStateGroupDefault.get() - if state == .suspended || state != .suspending { break } + let state: AppState = await withCheckedContinuation { cont in + appSubscriber = appStateSubscriber { s in + if s == .suspended { appSuspension(s) } + } + DispatchQueue.global().asyncAfter(deadline: .now() + Double(appSuspendTimeout) + 1) { + logger.debug("NotificationService: appSuspension timeout") + appSuspension(appStateGroupDefault.get()) + } + + @Sendable + func appSuspension(_ s: AppState) { + if !self.returnedSuspension { + self.returnedSuspension = true + self.appSubscriber = nil // this disposes of appStateSubscriber + cont.resume(returning: s) + } + } } logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)") if state.inactive { - receiveNtfMessages(request, contentHandler) + receiveNtfMessages(newThreadId, request, contentHandler) } else { deliverBestAttemptNtf() } @@ -210,7 +244,7 @@ class NotificationService: UNNotificationServiceExtension { } } - func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { + func receiveNtfMessages(_ newThreadId: UUID, _ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { deliverBestAttemptNtf() @@ -220,7 +254,11 @@ class NotificationService: UNNotificationServiceExtension { if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], let nonce = ntfData["nonce"] as? String, let encNtfInfo = ntfData["message"] as? String, - let dbStatus = startChat() { + // check it here again + appStateGroupDefault.get().inactive { + // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended + NSEThreads.shared.startThread(newThreadId) + let dbStatus = startChat() if case .ok = dbStatus, let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo), privacy: .public)") @@ -244,7 +282,7 @@ class NotificationService: UNNotificationServiceExtension { return } } - } else { + } else if let dbStatus = dbStatus { setBestAttemptNtf(createErrorNtf(dbStatus)) } } @@ -324,6 +362,7 @@ class NotificationService: UNNotificationServiceExtension { } } +// nseStateGroupDefault must not be used in NSE directly, only via this singleton class NSEChatState { static let shared = NSEChatState() private var value_ = NSEState.created @@ -334,14 +373,34 @@ class NSEChatState { func set(_ state: NSEState) { nseStateGroupDefault.set(state) + sendNSEState(state) value_ = state } init() { + // This is always set to .created state, as in case previous start of NSE crashed in .active state, it is stored correctly. + // Otherwise the app will be activating slower set(.created) } } +var appSubscriber: AppSubscriber = appStateSubscriber { state in + logger.debug("NotificationService: appSubscriber") + if state.running && NSEChatState.shared.value.canSuspend { + logger.debug("NotificationService: appSubscriber app state \(state.rawValue), suspending") + suspendChat(nseSuspendTimeout) + } +} + +func appStateSubscriber(onState: @escaping (AppState) -> Void) -> AppSubscriber { + appMessageSubscriber { msg in + if case let .state(state) = msg { + logger.debug("NotificationService: appStateSubscriber \(state.rawValue, privacy: .public)") + onState(state) + } + } +} + var receiverStarted = false let startLock = DispatchSemaphore(value: 1) let suspendLock = DispatchSemaphore(value: 1) @@ -359,6 +418,7 @@ func startChat() -> DBMigrationResult? { return switch NSEChatState.shared.value { case .created: doStartChat() + case .starting: .ok // it should never get to this branch, as it would be waiting for start on startLock case .active: .ok case .suspending: activateChat() case .suspended: activateChat() @@ -374,6 +434,8 @@ func doStartChat() -> DBMigrationResult? { NSEChatState.shared.set(.created) return dbStatus } + let state = NSEChatState.shared.value + NSEChatState.shared.set(.starting) if let user = apiGetActiveUser() { logger.debug("NotificationService active user \(String(describing: user))") do { @@ -382,24 +444,31 @@ func doStartChat() -> DBMigrationResult? { try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) - let justStarted = try apiStartChat() - NSEChatState.shared.set(.active) - if justStarted { - chatLastStartGroupDefault.set(Date.now) - Task { - if !receiverStarted { - receiverStarted = true - await receiveMessages() + // prevent suspension while starting chat + suspendLock.wait() + defer { suspendLock.signal() } + if NSEChatState.shared.value == .starting { + updateNetCfg() + let justStarted = try apiStartChat() + NSEChatState.shared.set(.active) + if justStarted { + chatLastStartGroupDefault.set(Date.now) + Task { + if !receiverStarted { + receiverStarted = true + await receiveMessages() + } } } + return .ok } - return .ok } catch { logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") } } else { logger.debug("NotificationService: no active user") } + if NSEChatState.shared.value == .starting { NSEChatState.shared.set(state) } return nil } @@ -450,15 +519,10 @@ func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { switch NSEChatState.shared.value { + // it should never get to "created" and "starting" branches, as NSE state is set to .active before the loop start case .created: await delayWhenInactive() - case .active: - if appStateGroupDefault.get().running { - suspendChat(nseSuspendTimeout) - await delayWhenInactive() - } else { - updateNetCfg() - await receiveMsg() - } + case .starting: await delayWhenInactive() + case .active: await receiveMsg() case .suspending: await receiveMsg() case .suspended: await delayWhenInactive() } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7f6a1a252..64ed240e4 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,11 +43,6 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; - 5C4BB4CC2B20E177007981AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C72B20E176007981AA /* libffi.a */; }; - 5C4BB4CD2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */; }; - 5C4BB4CE2B20E177007981AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C92B20E177007981AA /* libgmpxx.a */; }; - 5C4BB4CF2B20E177007981AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4CA2B20E177007981AA /* libgmp.a */; }; - 5C4BB4D02B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -68,6 +63,11 @@ 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; + 5C8EA13D2B25206A001DE5E4 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA1382B25206A001DE5E4 /* libgmp.a */; }; + 5C8EA13E2B25206A001DE5E4 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */; }; + 5C8EA13F2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */; }; + 5C8EA1402B25206A001DE5E4 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13B2B25206A001DE5E4 /* libffi.a */; }; + 5C8EA1412B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; }; @@ -151,6 +151,8 @@ 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; + 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; }; + 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -291,11 +293,6 @@ 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; - 5C4BB4C72B20E176007981AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a"; sourceTree = ""; }; - 5C4BB4C92B20E177007981AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C4BB4CA2B20E177007981AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; @@ -336,6 +333,11 @@ 5C8B41C929AF41BC00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 5C8B41CB29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = "cs.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C8B41CC29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = ""; }; + 5C8EA1382B25206A001DE5E4 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a"; sourceTree = ""; }; + 5C8EA13B2B25206A001DE5E4 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a"; sourceTree = ""; }; 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServersView.swift; sourceTree = ""; }; 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServerView.swift; sourceTree = ""; }; 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecPlay.swift; sourceTree = ""; }; @@ -436,6 +438,8 @@ 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = ""; }; + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -513,12 +517,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C4BB4D02B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a in Frameworks */, + 5C8EA13F2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a in Frameworks */, + 5C8EA1402B25206A001DE5E4 /* libffi.a in Frameworks */, + 5C8EA13E2B25206A001DE5E4 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C4BB4CE2B20E177007981AA /* libgmpxx.a in Frameworks */, - 5C4BB4CC2B20E177007981AA /* libffi.a in Frameworks */, - 5C4BB4CD2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a in Frameworks */, - 5C4BB4CF2B20E177007981AA /* libgmp.a in Frameworks */, + 5C8EA13D2B25206A001DE5E4 /* libgmp.a in Frameworks */, + 5C8EA1412B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -581,11 +585,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C4BB4C72B20E176007981AA /* libffi.a */, - 5C4BB4CA2B20E177007981AA /* libgmp.a */, - 5C4BB4C92B20E177007981AA /* libgmpxx.a */, - 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */, - 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */, + 5C8EA13B2B25206A001DE5E4 /* libffi.a */, + 5C8EA1382B25206A001DE5E4 /* libgmp.a */, + 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */, + 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */, + 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */, ); path = Libraries; sourceTree = ""; @@ -610,6 +614,7 @@ 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, @@ -811,6 +816,7 @@ 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, @@ -1183,6 +1189,7 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, + 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */, @@ -1270,6 +1277,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */, 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index eebdefb09..0804741c9 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -9,6 +9,8 @@ import Foundation import SwiftUI +public let appSuspendTimeout: Int = 15 // seconds + let GROUP_DEFAULT_APP_STATE = "appState" let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" @@ -67,7 +69,7 @@ public func registerGroupDefaults() { ]) } -public enum AppState: String { +public enum AppState: String, Codable { case active case activating case bgRefresh @@ -102,8 +104,9 @@ public enum AppState: String { } } -public enum NSEState: String { +public enum NSEState: String, Codable { case created + case starting case active case suspending case suspended @@ -126,16 +129,18 @@ public enum DBContainer: String { case group } +// appStateGroupDefault must not be used in the app directly, only via AppChatState singleton public let appStateGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_APP_STATE, withDefault: .active ) +// nseStateGroupDefault must not be used in NSE directly, only via NSEChatState singleton public let nseStateGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_NSE_STATE, - withDefault: .created + withDefault: .suspended // so that NSE that was never launched does not delay the app from resuming ) public func allowBackgroundRefresh() -> Bool { diff --git a/apps/ios/SimpleXChat/SharedFileSubscriber.swift b/apps/ios/SimpleXChat/SharedFileSubscriber.swift new file mode 100644 index 000000000..f496e6999 --- /dev/null +++ b/apps/ios/SimpleXChat/SharedFileSubscriber.swift @@ -0,0 +1,99 @@ +// +// SharedFileSubscriber.swift +// SimpleXChat +// +// Created by Evgeny on 09/12/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation + +public typealias AppSubscriber = SharedFileSubscriber> + +public typealias NSESubscriber = SharedFileSubscriber> + +public class SharedFileSubscriber: NSObject, NSFilePresenter { + var fileURL: URL + public var presentedItemURL: URL? + public var presentedItemOperationQueue: OperationQueue = .main + var subscriber: (Message) -> Void + + init(fileURL: URL, onMessage: @escaping (Message) -> Void) { + self.fileURL = fileURL + presentedItemURL = fileURL + subscriber = onMessage + super.init() + NSFileCoordinator.addFilePresenter(self) + } + + public func presentedItemDidChange() { + do { + let data = try Data(contentsOf: fileURL) + let msg = try jsonDecoder.decode(Message.self, from: data) + subscriber(msg) + } catch let error { + logger.error("presentedItemDidChange error: \(error)") + } + } + + static func notify(url: URL, message: Message) { + let fc = NSFileCoordinator(filePresenter: nil) + fc.coordinate(writingItemAt: url, options: [], error: nil) { newURL in + do { + let data = try jsonEncoder.encode(message) + try data.write(to: newURL, options: [.atomic]) + } catch { + logger.error("notifyViaSharedFile error: \(error)") + } + } + } + + deinit { + NSFileCoordinator.removeFilePresenter(self) + } +} + +let appMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.messages", isDirectory: false) + +let nseMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.SimpleX-NSE.messages", isDirectory: false) + +public struct ProcessMessage: Codable { + var createdAt: Date = Date.now + var message: Message +} + +public enum AppProcessMessage: Codable { + case state(state: AppState) +} + +public enum NSEProcessMessage: Codable { + case state(state: NSEState) +} + +public func sendAppProcessMessage(_ message: AppProcessMessage) { + SharedFileSubscriber.notify(url: appMessagesSharedFile, message: ProcessMessage(message: message)) +} + +public func sendNSEProcessMessage(_ message: NSEProcessMessage) { + SharedFileSubscriber.notify(url: nseMessagesSharedFile, message: ProcessMessage(message: message)) +} + +public func appMessageSubscriber(onMessage: @escaping (AppProcessMessage) -> Void) -> AppSubscriber { + SharedFileSubscriber(fileURL: appMessagesSharedFile) { (msg: ProcessMessage) in + onMessage(msg.message) + } +} + +public func nseMessageSubscriber(onMessage: @escaping (NSEProcessMessage) -> Void) -> NSESubscriber { + SharedFileSubscriber(fileURL: nseMessagesSharedFile) { (msg: ProcessMessage) in + onMessage(msg.message) + } +} + +public func sendAppState(_ state: AppState) { + sendAppProcessMessage(.state(state: state)) +} + +public func sendNSEState(_ state: NSEState) { + sendNSEProcessMessage(.state(state: state)) +}