diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 9e6073c10..b083361a0 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -80,7 +80,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") - if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic { + if m.ntfEnablePeriodic && allowBackgroundRefresh() { receiveMessages(completionHandler) } else { completionHandler(.noData) diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 5ee52407b..aae1e15fa 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -15,7 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive" // TCP timeout + 2 sec private let waitForMessages: TimeInterval = 6 -private let bgRefreshInterval: TimeInterval = 450 +private let bgRefreshInterval: TimeInterval = 600 private let maxTimerCount = 9 @@ -55,7 +55,7 @@ class BGManager { } logger.debug("BGManager.handleRefresh") schedule() - if appStateGroupDefault.get().inactive { + if allowBackgroundRefresh() { let completeRefresh = completionHandler { task.setTaskCompleted(success: true) } @@ -92,18 +92,19 @@ class BGManager { DispatchQueue.main.async { let m = ChatModel.shared if (!m.chatInitialized) { + setAppState(.bgRefresh) do { try initializeChat(start: true) } catch let error { fatalError("Failed to start or load chats: \(responseError(error))") } } + activateChat(appState: .bgRefresh) if m.currentUser == nil { completeReceiving("no current user") return } logger.debug("BGManager.receiveMessages: starting chat") - activateChat(appState: .bgRefresh) let cr = ChatReceiver() self.chatReceiver = cr cr.start() diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 13fe0737e..a7f4bcdbe 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -105,11 +105,13 @@ final class ChatModel: ObservableObject { static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } var ntfEnableLocal: Bool { - notificationMode == .off || ntfEnableLocalGroupDefault.get() + true +// notificationMode == .off || ntfEnableLocalGroupDefault.get() } var ntfEnablePeriodic: Bool { - notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() + notificationMode != .off +// notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() } var activeRemoteCtrl: Bool { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 19030a284..e2161cbf9 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -228,7 +228,8 @@ func apiStopChat() async throws { } func apiActivateChat() { - let r = chatSendCmdSync(.apiActivateChat) + chatReopenStore() + let r = chatSendCmdSync(.apiActivateChat(restoreChat: true)) if case .cmdOk = r { return } logger.error("apiActivateChat error: \(String(describing: r))") } diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 1c8c32f8b..3776f9cd4 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -18,6 +18,8 @@ let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds +let activationDelay: Double = 1.5 // seconds + 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() @@ -47,8 +49,6 @@ func suspendBgRefresh() { } } -private var terminating = false - func terminateChat() { logger.debug("terminateChat") suspendLockQueue.sync { @@ -64,7 +64,6 @@ func terminateChat() { case .stopped: chatCloseStore() default: - terminating = true // the store will be closed in _chatSuspended when event is received _suspendChat(timeout: terminationTimeout) } @@ -85,14 +84,17 @@ private func _chatSuspended() { if ChatModel.shared.chatRunning == true { ChatReceiver.shared.stop() } - if terminating { - chatCloseStore() + chatCloseStore() +} + +func setAppState(_ appState: AppState) { + suspendLockQueue.sync { + appStateGroupDefault.set(appState) } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") - terminating = false suspendLockQueue.sync { appStateGroupDefault.set(appState) if ChatModel.ok { apiActivateChat() } @@ -101,7 +103,6 @@ func activateChat(appState: AppState = .active) { } func initChatAndMigrate(refreshInvitations: Bool = true) { - terminating = false let m = ChatModel.shared if (!m.chatInitialized) { do { @@ -113,16 +114,32 @@ func initChatAndMigrate(refreshInvitations: Bool = true) { } } -func startChatAndActivate() { - terminating = false +func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ completion: @escaping () -> Void) { logger.debug("DEBUGGING: startChatAndActivate") if ChatModel.shared.chatRunning == true { ChatReceiver.shared.start() logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") } - if .active != appStateGroupDefault.get() { + if .active == appStateGroupDefault.get() { + 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 { + activate() + } + } + } + + func activate() { logger.debug("DEBUGGING: startChatAndActivate: before activateChat") activateChat() + completion() logger.debug("DEBUGGING: startChatAndActivate: after activateChat") } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 448ed8b5c..991cb1a29 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -77,15 +77,16 @@ struct SimpleXApp: App { case .active: CallController.shared.shouldSuspendChat = false let appState = appStateGroupDefault.get() - startChatAndActivate() - if appState.inactive && chatModel.chatRunning == true { - updateChats() - if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { - updateCallInvitations() + startChatAndActivate { + if appState.inactive && chatModel.chatRunning == true { + updateChats() + if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { + updateCallInvitations() + } } + doAuthenticate = authenticationExpired() + canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() } - doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() default: break } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 9ca894ea8..fcd3a8558 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -155,31 +155,32 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse if (!ChatModel.shared.chatInitialized) { initChatAndMigrate(refreshInvitations: false) } - startChatAndActivate() - shouldSuspendChat = true - // There are no invitations in the model, as it was processed by NSE - _ = try? justRefreshCallInvitations() - // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") - // Extract the call information from the push notification payload - let m = ChatModel.shared - if let contactId = payload.dictionaryPayload["contactId"] as? String, - let invitation = m.callInvitations[contactId] { - let update = cxCallUpdate(invitation: invitation) - if let uuid = invitation.callkitUUID { - logger.debug("CallController: report pushkit call via CallKit") - let update = cxCallUpdate(invitation: invitation) - provider.reportNewIncomingCall(with: uuid, update: update) { error in - if error != nil { - m.callInvitations.removeValue(forKey: contactId) + startChatAndActivate(dispatchQueue: DispatchQueue.global()) { + self.shouldSuspendChat = true + // There are no invitations in the model, as it was processed by NSE + _ = try? justRefreshCallInvitations() + // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") + // Extract the call information from the push notification payload + let m = ChatModel.shared + if let contactId = payload.dictionaryPayload["contactId"] as? String, + let invitation = m.callInvitations[contactId] { + let update = self.cxCallUpdate(invitation: invitation) + if let uuid = invitation.callkitUUID { + logger.debug("CallController: report pushkit call via CallKit") + let update = self.cxCallUpdate(invitation: invitation) + self.provider.reportNewIncomingCall(with: uuid, update: update) { error in + if error != nil { + m.callInvitations.removeValue(forKey: contactId) + } + // Tell PushKit that the notification is handled. + completion() } - // Tell PushKit that the notification is handled. - completion() + } else { + self.reportExpiredCall(update: update, completion) } } else { - reportExpiredCall(update: update, completion) + self.reportExpiredCall(payload: payload, completion) } - } else { - reportExpiredCall(payload: payload, completion) } } diff --git a/apps/ios/SimpleX NSE/ConcurrentQueue.swift b/apps/ios/SimpleX NSE/ConcurrentQueue.swift new file mode 100644 index 000000000..274a683c0 --- /dev/null +++ b/apps/ios/SimpleX NSE/ConcurrentQueue.swift @@ -0,0 +1,64 @@ +// +// ConcurrentQueue.swift +// SimpleX NSE +// +// Created by Evgeny on 08/12/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation + +struct DequeueElement { + var elementId: UUID? + var task: Task +} + +class ConcurrentQueue { + private var queue: [T] = [] + private var queueLock = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.concurrent-queue.lock.\(UUID())") + private var continuations = [(elementId: UUID, continuation: CheckedContinuation)]() + + func enqueue(_ el: T) { + resumeContinuation(el) { self.queue.append(el) } + } + + func frontEnqueue(_ el: T) { + resumeContinuation(el) { self.queue.insert(el, at: 0) } + } + + private func resumeContinuation(_ el: T, add: @escaping () -> Void) { + queueLock.sync { + if let (_, cont) = continuations.first { + continuations.remove(at: 0) + cont.resume(returning: el) + } else { + add() + } + } + } + + func dequeue() -> DequeueElement { + queueLock.sync { + if queue.isEmpty { + let elementId = UUID() + let task = Task { + await withCheckedContinuation { cont in + continuations.append((elementId, cont)) + } + } + return DequeueElement(elementId: elementId, task: task) + } else { + let el = queue.remove(at: 0) + return DequeueElement(task: Task { el }) + } + } + } + + func cancelDequeue(_ elementId: UUID) { + queueLock.sync { + let cancelled = continuations.filter { $0.elementId == elementId } + continuations.removeAll { $0.elementId == elementId } + cancelled.forEach { $0.continuation.resume(returning: nil) } + } + } +} diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index ea52f4be8..6732bb766 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -14,68 +14,167 @@ import SimpleXChat let logger = Logger() -let suspendingDelay: UInt64 = 2_000_000_000 +let suspendingDelay: UInt64 = 2_500_000_000 -typealias NtfStream = AsyncStream +let nseSuspendTimeout: Int = 10 +typealias NtfStream = ConcurrentQueue + +// Notifications are delivered via concurrent queues, as they are all received from chat controller in a single loop that +// writes to ConcurrentQueue and when notification is processed, the instance of Notification service extension reads from the queue. +// One queue per connection (entity) is used. +// The concurrent queues allow for read cancellation, to ensure that notifications are not lost in case the next the current thread completes +// before expected notification is read (multiple notifications can be expected, because one notification can be delivered for several messages. actor PendingNtfs { static let shared = PendingNtfs() private var ntfStreams: [String: NtfStream] = [:] - private var ntfConts: [String: NtfStream.Continuation] = [:] - func createStream(_ id: String) { - logger.debug("PendingNtfs.createStream: \(id, privacy: .public)") - if ntfStreams.index(forKey: id) == nil { - ntfStreams[id] = AsyncStream { cont in - ntfConts[id] = cont - logger.debug("PendingNtfs.createStream: store continuation") - } + func createStream(_ id: String) async { + logger.debug("NotificationService PendingNtfs.createStream: \(id, privacy: .public)") + if ntfStreams[id] == nil { + ntfStreams[id] = ConcurrentQueue() + logger.debug("NotificationService PendingNtfs.createStream: created ConcurrentQueue") } } - func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1, showNotifications: Bool) async { - logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)") + func readStream(_ id: String, for nse: NotificationService, ntfInfo: NtfMessages) async { + logger.debug("NotificationService PendingNtfs.readStream: \(id, privacy: .public) \(ntfInfo.ntfMessages.count, privacy: .public)") + if !ntfInfo.user.showNotifications { + nse.setBestAttemptNtf(.empty) + } if let s = ntfStreams[id] { - logger.debug("PendingNtfs.readStream: has stream") - var rcvCount = max(1, msgCount) - for await ntf in s { - nse.setBestAttemptNtf(showNotifications ? ntf : .empty) - rcvCount -= 1 - if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break } + logger.debug("NotificationService PendingNtfs.readStream: has stream") + var expected = Set(ntfInfo.ntfMessages.map { $0.msgId }) + logger.debug("NotificationService PendingNtfs.readStream: expecting: \(expected, privacy: .public)") + var readCancelled = false + var dequeued: DequeueElement? + nse.cancelRead = { + readCancelled = true + if let elementId = dequeued?.elementId { + s.cancelDequeue(elementId) + } } - logger.debug("PendingNtfs.readStream: exiting") + while !readCancelled { + dequeued = s.dequeue() + if let ntf = await dequeued?.task.value { + if readCancelled { + logger.debug("NotificationService PendingNtfs.readStream: read cancelled, put ntf to queue front") + s.frontEnqueue(ntf) + break + } else if case let .msgInfo(info) = ntf { + let found = expected.remove(info.msgId) + if found != nil { + logger.debug("NotificationService PendingNtfs.readStream: msgInfo, last: \(expected.isEmpty, privacy: .public)") + if expected.isEmpty { break } + } else if let msgTs = ntfInfo.msgTs, info.msgTs > msgTs { + logger.debug("NotificationService PendingNtfs.readStream: unexpected msgInfo") + s.frontEnqueue(ntf) + break + } + } else if ntfInfo.user.showNotifications { + logger.debug("NotificationService PendingNtfs.readStream: setting best attempt") + nse.setBestAttemptNtf(ntf) + if ntf.isCallInvitation { break } + } + } else { + break + } + } + nse.cancelRead = nil + logger.debug("NotificationService PendingNtfs.readStream: exiting") } } - func writeStream(_ id: String, _ ntf: NSENotification) { - logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)") - if let cont = ntfConts[id] { - logger.debug("PendingNtfs.writeStream: writing ntf") - cont.yield(ntf) + func writeStream(_ id: String, _ ntf: NSENotification) async { + logger.debug("NotificationService PendingNtfs.writeStream: \(id, privacy: .public)") + if let s = ntfStreams[id] { + logger.debug("NotificationService PendingNtfs.writeStream: writing ntf") + s.enqueue(ntf) + } + } +} + +// The current implementation assumes concurrent notification delivery and uses semaphores +// to process only one notification per connection (entity) at a time. +class NtfStreamSemaphores { + static let shared = NtfStreamSemaphores() + private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-semaphores.lock") + private var semaphores: [String: DispatchSemaphore] = [:] + + func waitForStream(_ id: String) { + streamSemaphore(id, value: 0)?.wait() + } + + func signalStreamReady(_ id: String) { + streamSemaphore(id, value: 1)?.signal() + } + + // this function returns nil if semaphore is just created, so passed value shoud be coordinated with the desired end value of the semaphore + private func streamSemaphore(_ id: String, value: Int) -> DispatchSemaphore? { + NtfStreamSemaphores.queue.sync { + if let s = semaphores[id] { + return s + } else { + semaphores[id] = DispatchSemaphore(value: value) + return nil + } } } } enum NSENotification { - case nse(notification: UNMutableNotificationContent) - case callkit(invitation: RcvCallInvitation) + case nse(UNMutableNotificationContent) + case callkit(RcvCallInvitation) case empty + case msgInfo(NtfMsgInfo) - var categoryIdentifier: String? { + var isCallInvitation: Bool { switch self { - case let .nse(ntf): return ntf.categoryIdentifier - case .callkit: return ntfCategoryCallInvitation - case .empty: return nil + case let .nse(ntf): ntf.categoryIdentifier == ntfCategoryCallInvitation + case .callkit: true + case .empty: false + case .msgInfo: false } } } +// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid +// background crashes and contention for database with the application (both UI and background fetch triggered either on schedule +// or when background notification is received. +class NSEThreads { + static let shared = NSEThreads() + private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") + private var threads: Set = [] + + func startThread() -> UUID { + NSEThreads.queue.sync { + let (_, t) = threads.insert(UUID()) + return t + } + } + + func endThread(_ t: UUID) -> Bool { + NSEThreads.queue.sync { + let t_ = threads.remove(t) + return t_ != nil && threads.isEmpty + } + } +} + +// Notification service extension creates a new instance of the class and calls didReceive for each notification. +// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never +// more than one process for notification service extension. +// Soon after notification service delivers the last notification it is either suspended or terminated. class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptNtf: NSENotification? var badgeCount: Int = 0 + var threadId: UUID? + var receiveEntityId: String? + var cancelRead: (() -> Void)? 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) @@ -93,7 +192,7 @@ class NotificationService: UNNotificationServiceExtension { setBadgeCount() Task { var state = appState - for _ in 1...5 { + for _ in 1...6 { _ = try await Task.sleep(nanoseconds: suspendingDelay) state = appStateGroupDefault.get() if state == .suspended || state != .suspending { break } @@ -123,24 +222,28 @@ class NotificationService: UNNotificationServiceExtension { let encNtfInfo = ntfData["message"] as? String, let dbStatus = startChat() { if case .ok = dbStatus, - let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { - logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") - if let connEntity = ntfMsgInfo.connEntity { + let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo), privacy: .public)") + if let connEntity = ntfInfo.connEntity_ { setBestAttemptNtf( - ntfMsgInfo.ntfsEnabled - ? .nse(notification: createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + ntfInfo.ntfsEnabled + ? .nse(createConnectionEventNtf(ntfInfo.user, connEntity)) : .empty ) if let id = connEntity.id { - Task { - logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") - await PendingNtfs.shared.createStream(id) - await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count, showNotifications: ntfMsgInfo.user.showNotifications) - deliverBestAttemptNtf() + receiveEntityId = id + NtfStreamSemaphores.shared.waitForStream(id) + if receiveEntityId != nil { + Task { + logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") + await PendingNtfs.shared.createStream(id) + await PendingNtfs.shared.readStream(id, for: self, ntfInfo: ntfInfo) + deliverBestAttemptNtf() + } } + return } } - return } else { setBestAttemptNtf(createErrorNtf(dbStatus)) } @@ -159,14 +262,14 @@ class NotificationService: UNNotificationServiceExtension { } func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) { - setBestAttemptNtf(.nse(notification: ntf)) + setBestAttemptNtf(.nse(ntf)) } func setBestAttemptNtf(_ ntf: NSENotification) { logger.debug("NotificationService.setBestAttemptNtf") if case let .nse(notification) = ntf { notification.badge = badgeCount as NSNumber - bestAttemptNtf = .nse(notification: notification) + bestAttemptNtf = .nse(notification) } else { bestAttemptNtf = ntf } @@ -174,9 +277,33 @@ class NotificationService: UNNotificationServiceExtension { private func deliverBestAttemptNtf() { logger.debug("NotificationService.deliverBestAttemptNtf") + if let cancel = cancelRead { + cancelRead = nil + cancel() + } + if let id = receiveEntityId { + receiveEntityId = nil + NtfStreamSemaphores.shared.signalStreamReady(id) + } + if let t = threadId { + threadId = nil + if NSEThreads.shared.endThread(t) { + suspendChat(nseSuspendTimeout) + } + } if let handler = contentHandler, let ntf = bestAttemptNtf { + contentHandler = nil + bestAttemptNtf = nil + let deliver: (UNMutableNotificationContent?) -> Void = { ntf in + let useNtf = if let ntf = ntf { + appStateGroupDefault.get().running ? UNMutableNotificationContent() : ntf + } else { + UNMutableNotificationContent() + } + handler(useNtf) + } switch ntf { - case let .nse(content): handler(content) + case let .nse(content): deliver(content) case let .callkit(invitation): CXProvider.reportNewIncomingVoIPPushPayload([ "displayName": invitation.contact.displayName, @@ -184,33 +311,71 @@ class NotificationService: UNNotificationServiceExtension { "media": invitation.callType.media.rawValue ]) { error in if error == nil { - handler(UNMutableNotificationContent()) + deliver(nil) } else { - logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") - handler(createCallInvitationNtf(invitation)) + logger.debug("NotificationService reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") + deliver(createCallInvitationNtf(invitation)) } } - case .empty: handler(UNMutableNotificationContent()) + case .empty: deliver(nil) // used to mute notifications that did not unsubscribe yet + case .msgInfo: deliver(nil) // unreachable, the best attempt is never set to msgInfo } - bestAttemptNtf = nil } } } -var chatStarted = false -var networkConfig: NetCfg = getNetCfg() -var xftpConfig: XFTPFileConfig? = getXFTPCfg() +class NSEChatState { + static let shared = NSEChatState() + private var value_ = NSEState.created + var value: NSEState { + value_ + } + + func set(_ state: NSEState) { + nseStateGroupDefault.set(state) + value_ = state + } + + init() { + set(.created) + } +} + +var receiverStarted = false +let startLock = DispatchSemaphore(value: 1) +let suspendLock = DispatchSemaphore(value: 1) +var networkConfig: NetCfg = getNetCfg() +let xftpConfig: XFTPFileConfig? = getXFTPCfg() + +// startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller +// Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active func startChat() -> DBMigrationResult? { + logger.debug("NotificationService: startChat") + if case .active = NSEChatState.shared.value { return .ok } + + startLock.wait() + defer { startLock.signal() } + + return switch NSEChatState.shared.value { + case .created: doStartChat() + case .active: .ok + case .suspending: activateChat() + case .suspended: activateChat() + } +} + +func doStartChat() -> DBMigrationResult? { + logger.debug("NotificationService: doStartChat") hs_init(0, nil) - if chatStarted { return .ok } let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation()) if dbStatus != .ok { resetChatCtrl() + NSEChatState.shared.set(.created) return dbStatus } if let user = apiGetActiveUser() { - logger.debug("active user \(String(describing: user))") + logger.debug("NotificationService active user \(String(describing: user))") do { try setNetworkConfig(networkConfig) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) @@ -218,32 +383,102 @@ func startChat() -> DBMigrationResult? { try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) let justStarted = try apiStartChat() - chatStarted = true + NSEChatState.shared.set(.active) if justStarted { chatLastStartGroupDefault.set(Date.now) - Task { await receiveMessages() } + Task { + if !receiverStarted { + receiverStarted = true + await receiveMessages() + } + } } return .ok } catch { logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") } } else { - logger.debug("no active user") + logger.debug("NotificationService: no active user") } return nil } +func activateChat() -> DBMigrationResult? { + logger.debug("NotificationService: activateChat") + let state = NSEChatState.shared.value + NSEChatState.shared.set(.active) + if apiActivateChat() { + logger.debug("NotificationService: activateChat: after apiActivateChat") + return .ok + } else { + NSEChatState.shared.set(state) + return nil + } +} + +// suspendChat uses semaphore suspendLock to ensure that only one suspension can happen. +func suspendChat(_ timeout: Int) { + logger.debug("NotificationService: suspendChat") + let state = NSEChatState.shared.value + if !state.canSuspend { + logger.error("NotificationService suspendChat called, current state: \(state.rawValue, privacy: .public)") + } else { + suspendLock.wait() + defer { suspendLock.signal() } + + NSEChatState.shared.set(.suspending) + if apiSuspendChat(timeoutMicroseconds: timeout * 1000000) { + logger.debug("NotificationService: activateChat: after apiActivateChat") + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: chatSuspended) + } else { + NSEChatState.shared.set(state) + } + } +} + +func chatSuspended() { + logger.debug("NotificationService chatSuspended") + if case .suspending = NSEChatState.shared.value { + NSEChatState.shared.set(.suspended) + chatCloseStore() + } +} + +// A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state +// If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will no be received. func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { - updateNetCfg() + switch NSEChatState.shared.value { + case .created: await delayWhenInactive() + case .active: + if appStateGroupDefault.get().running { + suspendChat(nseSuspendTimeout) + await delayWhenInactive() + } else { + updateNetCfg() + await receiveMsg() + } + case .suspending: await receiveMsg() + case .suspended: await delayWhenInactive() + } + } + + func receiveMsg() async { if let msg = await chatRecvMsg() { + logger.debug("NotificationService receiveMsg: message") if let (id, ntf) = await receivedMsgNtf(msg) { + logger.debug("NotificationService receiveMsg: notification") await PendingNtfs.shared.createStream(id) await PendingNtfs.shared.writeStream(id, ntf) } } } + + func delayWhenInactive() async { + logger.debug("NotificationService delayWhenInactive") + _ = try? await Task.sleep(nanoseconds: 1000_000000) + } } func chatRecvMsg() async -> ChatResponse? { @@ -257,14 +492,14 @@ private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { - logger.debug("NotificationService processReceivedMsg: \(res.responseType)") + logger.debug("NotificationService receivedMsgNtf: \(res.responseType, privacy: .public)") switch res { case let .contactConnected(user, contact, _): - return (contact.id, .nse(notification: createContactConnectedNtf(user, contact))) + return (contact.id, .nse(createContactConnectedNtf(user, contact))) // case let .contactConnecting(contact): // TODO profile update case let .receivedContactRequest(user, contactRequest): - return (UserContact(contactRequest: contactRequest).id, .nse(notification: createContactRequestNtf(user, contactRequest))) + return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest))) case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem @@ -274,7 +509,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { if let file = cItem.autoReceiveFile() { cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem } - let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty + let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty return cItem.showNotification ? (aChatItem.chatId, ntf) : nil case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) @@ -292,10 +527,15 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit return ( invitation.contact.id, - useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation)) + useCallKit() ? .callkit(invitation) : .nse(createCallInvitationNtf(invitation)) ) + case let .ntfMessage(_, connEntity, ntfMessage): + return if let id = connEntity.id { (id, .msgInfo(ntfMessage)) } else { nil } + case .chatSuspended: + chatSuspended() + return nil default: - logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") + logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)") return nil } } @@ -334,6 +574,21 @@ func apiStartChat() throws -> Bool { } } +func apiActivateChat() -> Bool { + chatReopenStore() + let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) + if case .cmdOk = r { return true } + logger.error("NotificationService apiActivateChat error: \(String(describing: r))") + return false +} + +func apiSuspendChat(timeoutMicroseconds: Int) -> Bool { + let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + if case .cmdOk = r { return true } + logger.error("NotificationService apiSuspendChat error: \(String(describing: r))") + return false +} + func apiSetTempFolder(tempFolder: String) throws { let r = sendSimpleXCmd(.setTempFolder(tempFolder: tempFolder)) if case .cmdOk = r { return } @@ -364,8 +619,8 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { return nil } let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r, let user = user { - return NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + if case let .ntfMessages(user, connEntity_, msgTs, ntfMessages) = r, let user = user { + return NtfMessages(user: user, connEntity_: connEntity_, msgTs: msgTs, ntfMessages: ntfMessages) } else if case let .chatCmdError(_, error) = r { logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") } else { @@ -405,11 +660,11 @@ func setNetworkConfig(_ cfg: NetCfg) throws { struct NtfMessages { var user: User - var connEntity: ConnectionEntity? + var connEntity_: ConnectionEntity? var msgTs: Date? var ntfMessages: [NtfMsgInfo] var ntfsEnabled: Bool { - user.showNotifications && (connEntity?.ntfsEnabled ?? false) + user.showNotifications && (connEntity_?.ntfsEnabled ?? false) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a3b7580a1..7f6a1a252 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,6 +43,11 @@ 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 */; }; @@ -145,11 +150,7 @@ 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; - 5CF937182B22552700E1D781 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937132B22552700E1D781 /* libffi.a */; }; - 5CF937192B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */; }; - 5CF9371A2B22552700E1D781 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937152B22552700E1D781 /* libgmp.a */; }; - 5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937162B22552700E1D781 /* libgmpxx.a */; }; - 5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */; }; + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.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 */; }; @@ -290,6 +291,11 @@ 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 = ""; }; @@ -429,11 +435,7 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; - 5CF937132B22552700E1D781 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a"; sourceTree = ""; }; - 5CF937152B22552700E1D781 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF937162B22552700E1D781 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a"; sourceTree = ""; }; + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.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; }; @@ -511,12 +513,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */, - 5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */, + 5C4BB4D02B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CF9371A2B22552700E1D781 /* libgmp.a in Frameworks */, - 5CF937182B22552700E1D781 /* libffi.a in Frameworks */, - 5CF937192B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a 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 */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -579,11 +581,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF937132B22552700E1D781 /* libffi.a */, - 5CF937152B22552700E1D781 /* libgmp.a */, - 5CF937162B22552700E1D781 /* libgmpxx.a */, - 5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */, - 5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */, + 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 */, ); path = Libraries; sourceTree = ""; @@ -788,6 +790,7 @@ isa = PBXGroup; children = ( 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, @@ -1259,6 +1262,7 @@ files = ( 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index c7e94a2dc..dfa4caf09 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -41,7 +41,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio var cKey = dbKey.cString(using: .utf8)! var cConfirm = confirm.rawValue.cString(using: .utf8)! // the last parameter of chat_migrate_init is used to return the pointer to chat controller - let cjson = chat_migrate_init(&cPath, &cKey, &cConfirm, &chatController)! + let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, &chatController)! let dbRes = dbMigrationResult(fromCString(cjson)) let encrypted = dbKey != "" let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey) @@ -57,6 +57,13 @@ public func chatCloseStore() { } } +public func chatReopenStore() { + let err = fromCString(chat_reopen_store(getChatCtrl())) + if err != "" { + logger.error("chatReopenStore error: \(err)") + } +} + public func resetChatCtrl() { chatController = nil migrationResult = nil diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3d2c21392..c03951e60 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -27,7 +27,7 @@ public enum ChatCommand { case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) case startChat(subscribe: Bool, expire: Bool, xftp: Bool) case apiStopChat - case apiActivateChat + case apiActivateChat(restoreChat: Bool) case apiSuspendChat(timeoutMicroseconds: Int) case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) @@ -156,7 +156,7 @@ public enum ChatCommand { case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" case let .startChat(subscribe, expire, xftp): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire)) xftp=\(onOff(xftp))" case .apiStopChat: return "/_stop" - case .apiActivateChat: return "/_app activate" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" @@ -604,7 +604,8 @@ public enum ChatResponse: Decodable, Error { case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) - case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) @@ -751,6 +752,7 @@ public enum ChatResponse: Decodable, Error { case .ntfTokenStatus: return "ntfTokenStatus" case .ntfToken: return "ntfToken" case .ntfMessages: return "ntfMessages" + case .ntfMessage: return "ntfMessage" case .contactConnectionDeleted: return "contactConnectionDeleted" case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlFound: return "remoteCtrlFound" @@ -898,6 +900,7 @@ public enum ChatResponse: Decodable, Error { case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index cc61fae53..eebdefb09 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI let GROUP_DEFAULT_APP_STATE = "appState" +let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" @@ -68,11 +69,21 @@ public func registerGroupDefaults() { public enum AppState: String { case active + case activating case bgRefresh case suspending case suspended case stopped + public var running: Bool { + switch self { + case .active: return true + case .activating: return true + case .bgRefresh: return true + default: return false + } + } + public var inactive: Bool { switch self { case .suspending: return true @@ -84,12 +95,32 @@ public enum AppState: String { public var canSuspend: Bool { switch self { case .active: return true + case .activating: return true case .bgRefresh: return true default: return false } } } +public enum NSEState: String { + case created + case active + case suspending + case suspended + + public var inactive: Bool { + switch self { + case .created: true + case .suspended: true + default: false + } + } + + public var canSuspend: Bool { + if case .active = self { true } else { false } + } +} + public enum DBContainer: String { case documents case group @@ -101,6 +132,16 @@ public let appStateGroupDefault = EnumDefault( withDefault: .active ) +public let nseStateGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NSE_STATE, + withDefault: .created +) + +public func allowBackgroundRefresh() -> Bool { + appStateGroupDefault.get().inactive && nseStateGroupDefault.get().inactive +} + public let dbContainerGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_DB_CONTAINER, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index dc4cdda46..a545d3508 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2016,7 +2016,8 @@ public enum ConnectionEntity: Decodable { } public struct NtfMsgInfo: Decodable { - + public var msgId: String + public var msgTs: Date } public struct AChatItem: Decodable { diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 2872922a9..6e37a5177 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -16,10 +16,10 @@ extern void hs_init(int argc, char **argv[]); typedef void* chat_ctrl; // the last parameter is used to return the pointer to chat controller -extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl); +extern char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, chat_ctrl *ctrl); extern char *chat_close_store(chat_ctrl ctl); +extern char *chat_reopen_store(chat_ctrl ctl); extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); -extern char *chat_recv_msg(chat_ctrl ctl); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); diff --git a/cabal.project b/cabal.project index 3de5197d6..7e8dee6a0 100644 --- a/cabal.project +++ b/cabal.project @@ -11,7 +11,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a860936072172e261480fa6bdd95203976e366b2 + tag: 146fb1a6a02a8cadbd3a476089646b57bdd6659c source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fc57b6004..f3bd4d5e0 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a860936072172e261480fa6bdd95203976e366b2" = "16rwnh5zzphmw8d8ypvps6xjvzbmf5ljr6zzy15gz2g0jyh7hd91"; + "https://github.com/simplex-chat/simplexmq.git"."146fb1a6a02a8cadbd3a476089646b57bdd6659c" = "0pbj3k8nygc4dpqhblpvj4rs5c5nh064qmfx3d4zyz11g1n5vpan"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aa489e9a9..eb322bcd9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -9,7 +9,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -28,6 +27,8 @@ import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (bimap, first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -50,7 +51,7 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) -import Data.Time.Clock.System (SystemTime, systemToUTCTime) +import Data.Time.Clock.System (systemToUTCTime) import Data.Word (Word16, Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive @@ -191,10 +192,10 @@ smallGroupsRcptsMemLimit = 20 logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: FilePath -> String -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase filePrefix key confirmMigrations = runExceptT $ do - chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key confirmMigrations - agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key confirmMigrations +createChatDatabase :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) +createChatDatabase filePrefix key keepKey confirmMigrations = runExceptT $ do + chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key keepKey confirmMigrations + agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController @@ -538,16 +539,18 @@ processChatCommand = \case APIStopChat -> do ask >>= stopChatController pure CRChatStopped - APIActivateChat -> withUser $ \_ -> do - restoreCalls + APIActivateChat restoreChat -> withUser $ \_ -> do + when restoreChat restoreCalls withAgent foregroundAgent - users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers - void . forkIO $ subscribeUsers True users - void . forkIO $ startFilesToReceive users - setAllExpireCIFlags True + when restoreChat $ do + users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers + void . forkIO $ subscribeUsers True users + void . forkIO $ startFilesToReceive users + setAllExpireCIFlags True ok_ APISuspendChat t -> do setAllExpireCIFlags False + stopRemoteCtrl withAgent (`suspendAgent` t) ok_ ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers False >> ok_ @@ -1172,16 +1175,13 @@ processChatCommand = \case APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ APIGetNtfMessage nonce encNtfInfo -> withUser $ \_ -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo - let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs - getMsgTs :: SMP.NMsgMeta -> SystemTime - getMsgTs SMP.NMsgMeta {msgTs} = msgTs - msgTs' = systemToUTCTime . getMsgTs <$> ntfMsgMeta + let msgTs' = systemToUTCTime . (\SMP.NMsgMeta {msgTs} -> msgTs) <$> ntfMsgMeta agentConnId = AgentConnId ntfConnId user_ <- withStore' (`getUserByAConnId` agentConnId) - connEntity <- + connEntity_ <- pure user_ $>>= \user -> withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) - pure CRNtfMessages {user_, connEntity, msgTs = msgTs', ntfMessages} + pure CRNtfMessages {user_, connEntity_, msgTs = msgTs', ntfMessages = map ntfMsgInfo msgs} APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do ChatConfig {defaultServers} <- asks config servers <- withStore' (`getProtocolServers` user) @@ -3227,23 +3227,24 @@ processAgentMsgRcvFile _corrId aFileId msg = toView $ CRRcvFileError user ci e processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () -processAgentMessageConn user _ agentConnId END = - withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case - RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct - entity -> toView $ CRSubscriptionEnd user entity processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus - case entity of - RcvDirectMsgConnection conn contact_ -> - processDirectMessage agentMessage entity conn contact_ - RcvGroupMsgConnection conn gInfo m -> - processGroupMessage agentMessage entity conn gInfo m - RcvFileConnection conn ft -> - processRcvFileConn agentMessage entity conn ft - SndFileConnection conn ft -> - processSndFileConn agentMessage entity conn ft - UserContactConnection conn uc -> - processUserContactRequest agentMessage entity conn uc + case agentMessage of + END -> case entity of + RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct + _ -> toView $ CRSubscriptionEnd user entity + MSGNTF smpMsgInfo -> toView $ CRNtfMessage user entity $ ntfMsgInfo smpMsgInfo + _ -> case entity of + RcvDirectMsgConnection conn contact_ -> + processDirectMessage agentMessage entity conn contact_ + RcvGroupMsgConnection conn gInfo m -> + processGroupMessage agentMessage entity conn gInfo m + RcvFileConnection conn ft -> + processRcvFileConn agentMessage entity conn ft + SndFileConnection conn ft -> + processSndFileConn agentMessage entity conn ft + UserContactConnection conn uc -> + processUserContactRequest agentMessage entity conn uc where updateConnStatus :: ConnectionEntity -> m ConnectionEntity updateConnStatus acEntity = case agentMsgConnStatus agentMessage of @@ -5959,7 +5960,8 @@ chatCommandP = "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP), "/_start" $> StartChat True True True, "/_stop" $> APIStopChat, - "/_app activate" $> APIActivateChat, + "/_app activate restore=" *> (APIActivateChat <$> onOffP), + "/_app activate" $> APIActivateChat True, "/_app suspend " *> (APISuspendChat <$> A.decimal), "/_resubscribe all" $> ResubscribeAllConnections, "/_temp_folder " *> (SetTempFolder <$> filePath), @@ -5974,9 +5976,9 @@ chatCommandP = "/_db import " *> (APIImportArchive <$> jsonP), "/_db delete" $> APIDeleteStorage, "/_db encryption " *> (APIStorageEncryption <$> jsonP), - "/db encrypt " *> (APIStorageEncryption . DBEncryptionConfig "" <$> dbKeyP), - "/db key " *> (APIStorageEncryption <$> (DBEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), - "/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP), + "/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP), + "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), + "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, @@ -6317,7 +6319,8 @@ chatCommandP = A.decimal ] dbKeyP = nonEmptyKey <$?> strP - nonEmptyKey k@(DBEncryptionKey s) = if null s then Left "empty key" else Right k + nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k + dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} autoAcceptP = ifM onOffP diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 22e5f1ee2..d386b48d4 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -17,12 +17,14 @@ import qualified Codec.Archive.Zip as Z import Control.Monad import Control.Monad.Except import Control.Monad.Reader +import qualified Data.ByteArray as BA import Data.Functor (($>)) +import Data.Maybe (fromMaybe) import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, sqlString) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, keyString, sqlString, storeKey) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory @@ -118,7 +120,7 @@ storageFiles = do pure StorageFiles {chatStore, agentStore, filesPath} sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () -sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = +sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key', keepKey} = when (key /= key') $ do fs <- storageFiles checkFile `withDBs` fs @@ -134,15 +136,15 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f - checkEncryption SQLiteStore {dbEncrypted} = do - enc <- readTVarIO dbEncrypted - when (enc && null key) $ throwDBError DBErrorEncrypted - when (not enc && not (null key)) $ throwDBError DBErrorPlaintext + checkEncryption SQLiteStore {dbKey} = do + enc <- maybe True (not . BA.null) <$> readTVarIO dbKey + when (enc && BA.null key) $ throwDBError DBErrorEncrypted + when (not enc && not (BA.null key)) $ throwDBError DBErrorPlaintext exported = (<> ".exported") removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f) - moveExported SQLiteStore {dbFilePath = f, dbEncrypted} = do + moveExported SQLiteStore {dbFilePath = f, dbKey} = do renameFile (exported f) f - atomically $ writeTVar dbEncrypted $ not (null key') + atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey) export f = do withDB f (`SQL.exec` exportSQL) DBErrorExport withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen @@ -161,7 +163,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D exportSQL = T.unlines $ keySQL key - <> [ "ATTACH DATABASE " <> sqlString (f <> ".exported") <> " AS exported KEY " <> sqlString key' <> ";", + <> [ "ATTACH DATABASE " <> sqlString (T.pack f <> ".exported") <> " AS exported KEY " <> keyString key' <> ";", "SELECT sqlcipher_export('exported');", "DETACH DATABASE exported;" ] @@ -172,7 +174,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D "PRAGMA secure_delete = ON;", "SELECT count(*) FROM sqlite_master;" ] - keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] + keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)] withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fb2ff89a2..580d6d19d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -29,6 +29,8 @@ import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) @@ -39,7 +41,9 @@ import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.String import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1) import Data.Time (NominalDiffTime, UTCTime) +import Data.Time.Clock.System (systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) @@ -69,7 +73,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -230,7 +234,7 @@ data ChatCommand | DeleteUser UserName Bool (Maybe UserPwd) | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool} | APIStopChat - | APIActivateChat + | APIActivateChat {restoreChat :: Bool} | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections | SetTempFolder FilePath @@ -453,7 +457,7 @@ allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/For allowRemoteCommand = \case StartChat {} -> False APIStopChat -> False - APIActivateChat -> False + APIActivateChat _ -> False APISuspendChat _ -> False QuitChat -> False SetTempFolder _ -> False @@ -654,7 +658,8 @@ data ChatResponse | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} - | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} @@ -825,17 +830,17 @@ deriving instance Show AUserProtoServers data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show) -data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey} +data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey, keepKey :: Maybe Bool} deriving (Show) -newtype DBEncryptionKey = DBEncryptionKey String +newtype DBEncryptionKey = DBEncryptionKey ScrubbedBytes deriving (Show) instance IsString DBEncryptionKey where fromString = parseString $ parseAll strP instance StrEncoding DBEncryptionKey where - strEncode (DBEncryptionKey s) = B.pack s - strP = DBEncryptionKey . B.unpack <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E) + strEncode (DBEncryptionKey s) = BA.convert s + strP = DBEncryptionKey . BA.convert <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E) instance FromJSON DBEncryptionKey where parseJSON = strParseJSON "DBEncryptionKey" @@ -900,9 +905,12 @@ data XFTPFileConfig = XFTPFileConfig defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} +data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) +ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo +ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs} + crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 0706dda08..c409526a0 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -22,7 +22,7 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core withGlobalLogging logCfg initRun _ -> initRun where - initRun = createChatDatabase dbFilePrefix dbKey confirmMigrations >>= either exit run + initRun = createChatDatabase dbFilePrefix dbKey False confirmMigrations >>= either exit run exit e = do putStrLn $ "Error opening database: " <> show e exitFailure diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 69f688740..a7f032c75 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -15,6 +15,8 @@ import Control.Monad.Reader import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -44,7 +46,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore, reopenSQLiteStore) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -70,8 +72,12 @@ $(JQ.deriveToJSON defaultJSON ''APIResponse) foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString +foreign export ccall "chat_migrate_init_key" cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString + foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString +foreign export ccall "chat_reopen_store" cChatReopenStore :: StablePtr ChatController -> IO CString + foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString foreign export ccall "chat_send_remote_cmd" cChatSendRemoteCmd :: StablePtr ChatController -> CInt -> CString -> IO CJSONString @@ -102,7 +108,10 @@ foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString -- | check / migrate database and initialize chat controller on success cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString -cChatMigrateInit fp key conf ctrl = do +cChatMigrateInit fp key = cChatMigrateInitKey fp key 0 + +cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInitKey fp key keepKey conf ctrl = do -- ensure we are set to UTF-8; iOS does not have locale, and will default to -- US-ASCII all the time. setLocaleEncoding utf8 @@ -110,10 +119,10 @@ cChatMigrateInit fp key conf ctrl = do setForeignEncoding utf8 dbPath <- peekCAString fp - dbKey <- peekCAString key + dbKey <- BA.convert <$> B.packCString key confirm <- peekCAString conf r <- - chatMigrateInit dbPath dbKey confirm >>= \case + chatMigrateInitKey dbPath dbKey (keepKey /= 0) confirm >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -121,6 +130,11 @@ cChatMigrateInit fp key conf ctrl = do cChatCloseStore :: StablePtr ChatController -> IO CString cChatCloseStore cPtr = deRefStablePtr cPtr >>= chatCloseStore >>= newCAString +cChatReopenStore :: StablePtr ChatController -> IO CString +cChatReopenStore cPtr = do + c <- deRefStablePtr cPtr + newCAString =<< chatReopenStore c + -- | send command to chat (same syntax as in terminal for now) cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString cChatSendCmd cPtr cCmd = do @@ -162,13 +176,13 @@ cChatPasswordHash cPwd cSalt = do cChatValidName :: CString -> IO CString cChatValidName cName = newCString . mkValidName =<< peekCString cName -mobileChatOpts :: String -> String -> ChatOpts -mobileChatOpts dbFilePrefix dbKey = +mobileChatOpts :: String -> ChatOpts +mobileChatOpts dbFilePrefix = ChatOpts { coreOptions = CoreChatOpts { dbFilePrefix, - dbKey, + dbKey = "", -- for API database is already opened, and the key in options is not used smpServers = [], xftpServers = [], networkConfig = defaultNetworkConfig, @@ -205,8 +219,11 @@ defaultMobileConfig = getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers -chatMigrateInit :: String -> String -> String -> IO (Either DBMigrationResult ChatController) -chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do +chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) +chatMigrateInit dbFilePrefix dbKey = chatMigrateInitKey dbFilePrefix dbKey False + +chatMigrateInitKey :: String -> ScrubbedBytes -> Bool -> String -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey dbFilePrefix dbKey keepKey confirm = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm chatStore <- migrate createChatStore (chatStoreFile dbFilePrefix) confirmMigrations agentStore <- migrate createAgentStore (agentStoreFile dbFilePrefix) confirmMigrations @@ -214,10 +231,10 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do where initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix dbKey) + newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix) migrate createStore dbFile confirmMigrations = ExceptT $ - (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations) + (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations) `catch` (pure . checkDBError) `catchAll` (pure . dbError) where @@ -231,6 +248,11 @@ chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do closeSQLiteStore chatStore closeSQLiteStore $ agentClientStore smpAgent +chatReopenStore :: ChatController -> IO String +chatReopenStore ChatController {chatStore, smpAgent} = handleErr $ do + reopenSQLiteStore chatStore + reopenSQLiteStore (agentClientStore smpAgent) + handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index f8cab1e35..85298ae31 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -18,6 +18,7 @@ where import Control.Logger.Simple (LogLevel (..)) import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteString.Char8 as B import Data.Text (Text) import Numeric.Natural (Natural) @@ -48,7 +49,7 @@ data ChatOpts = ChatOpts data CoreChatOpts = CoreChatOpts { dbFilePrefix :: String, - dbKey :: String, + dbKey :: ScrubbedBytes, smpServers :: [SMPServerWithAuth], xftpServers :: [XFTPServerWithAuth], networkConfig :: NetworkConfig, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index b9989d8af..3d98eb7e3 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -189,7 +189,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ - toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} + toView CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello @@ -260,7 +260,7 @@ cancelRemoteHostSession handlerInfo_ rhKey = do atomically $ TM.lookup rhKey sessions >>= \case Nothing -> pure Nothing - Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> pure Nothing -- ignore cancel from a ghost session handler + Just (sessSeq, _) | maybe False ((sessSeq /=) . fst) handlerInfo_ -> pure Nothing -- ignore cancel from a ghost session handler Just (_, rhs) -> do TM.delete rhKey sessions modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH @@ -268,7 +268,7 @@ cancelRemoteHostSession handlerInfo_ rhKey = do forM_ deregistered $ \session -> do liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) forM_ (snd <$> handlerInfo_) $ \rhStopReason -> - toView $ CRRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} + toView CRRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} where handlingError = isJust handlerInfo_ remoteHostId_ = case rhKey of diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 5f8577ffb..91021713b 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -12,13 +12,14 @@ module Simplex.Chat.Store ) where +import Data.ByteArray (ScrubbedBytes) import Simplex.Chat.Store.Migrations import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, MigrationError, SQLiteStore (..), createSQLiteStore, withTransaction) -createChatStore :: FilePath -> String -> MigrationConfirmation -> IO (Either MigrationError SQLiteStore) -createChatStore dbPath dbKey = createSQLiteStore dbPath dbKey migrations +createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError SQLiteStore) +createChatStore dbPath key keepKey = createSQLiteStore dbPath key keepKey migrations chatStoreFile :: FilePath -> FilePath chatStoreFile = (<> "_chat.db") diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7eeddefbc..131b04773 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -279,6 +279,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] + CRNtfMessage {} -> [] CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 824e6be0a..6c2e8c080 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -15,6 +15,7 @@ import Control.Concurrent.STM import Control.Exception (bracket, bracket_) import Control.Monad import Control.Monad.Except +import Data.ByteArray (ScrubbedBytes) import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (fromJust, isNothing) @@ -86,7 +87,7 @@ testOpts = maintenance = False } -getTestOpts :: Bool -> String -> ChatOpts +getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts getTestOpts maintenance dbKey = testOpts {maintenance, coreOptions = (coreOptions testOpts) {dbKey}} termSettings :: VirtualTerminalSettings @@ -160,13 +161,13 @@ groupLinkViaContactVRange = mkVersionRange 1 2 createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do - Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey MCError + Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user startTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> IO TestCC startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix = do - Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey MCError + Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError Just user <- find activeUser <$> withTransaction chatStore getUsers startTestChat_ db cfg opts user diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index d8e98513c..64fb7c98b 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -209,7 +209,7 @@ testChatApi :: FilePath -> IO () testChatApi tmp = do let dbPrefix = tmp "1" f = chatStoreFile dbPrefix - Right st <- createChatStore f "myKey" MCYesUp + Right st <- createChatStore f "myKey" False MCYesUp Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index f517d13df..d84572aba 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -36,14 +36,14 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore testDB "" MCError + void $ createChatStore testDB "" False MCError getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createSQLiteStore testDB "" noDownMigrations MCError + Right st <- createSQLiteStore testDB "" False noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeSQLiteStore st removeFile testDB