diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 9e6073c10..bb1de9435 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -42,6 +42,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { let m = ChatModel.shared let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token) m.deviceToken = deviceToken + // savedToken is set in startChat, when it is started before this method is called if m.savedToken != nil { registerToken(token: deviceToken) } @@ -80,7 +81,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..a39155efe 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -15,7 +15,8 @@ private let receiveTaskId = "chat.simplex.app.receive" // TCP timeout + 2 sec private let waitForMessages: TimeInterval = 6 -private let bgRefreshInterval: TimeInterval = 450 +// This is the smallest interval between refreshes, and also target interval in "off" mode +private let bgRefreshInterval: TimeInterval = 600 private let maxTimerCount = 9 @@ -55,7 +56,7 @@ class BGManager { } logger.debug("BGManager.handleRefresh") schedule() - if appStateGroupDefault.get().inactive { + if allowBackgroundRefresh() { let completeRefresh = completionHandler { task.setTaskCompleted(success: true) } @@ -92,18 +93,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..e7932f2d9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -104,12 +104,10 @@ final class ChatModel: ObservableObject { static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } - var ntfEnableLocal: Bool { - notificationMode == .off || ntfEnableLocalGroupDefault.get() - } + let ntfEnableLocal = true var ntfEnablePeriodic: Bool { - notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() + notificationMode != .off } var activeRemoteCtrl: Bool { 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/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 19030a284..e67dab18f 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))") } @@ -1234,6 +1235,9 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try startChat(refreshInvitations: refreshInvitations) } else { m.chatRunning = false + try getUserChatData() + NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) + m.onboardingStage = onboardingStageDefault.get() } } @@ -1250,6 +1254,8 @@ func startChat(refreshInvitations: Bool = true) throws { try refreshCallInvitations() } (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() + // deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called, + // when it is called before startChat if let token = m.deviceToken { registerToken(token: token) } diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 1c8c32f8b..9b03f38f3 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -9,27 +9,28 @@ import Foundation import UIKit import SimpleXChat +import SwiftUI 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: 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) } } @@ -41,18 +42,16 @@ func suspendChat() { func suspendBgRefresh() { suspendLockQueue.sync { - if case .bgRefresh = appStateGroupDefault.get() { + if case .bgRefresh = AppChatState.shared.value { _suspendChat(timeout: bgSuspendTimeout) } } } -private var terminating = false - func terminateChat() { logger.debug("terminateChat") suspendLockQueue.sync { - switch appStateGroupDefault.get() { + switch AppChatState.shared.value { case .suspending: // suspend instantly if already suspending _chatSuspended() @@ -64,7 +63,6 @@ func terminateChat() { case .stopped: chatCloseStore() default: - terminating = true // the store will be closed in _chatSuspended when event is received _suspendChat(timeout: terminationTimeout) } @@ -73,7 +71,7 @@ func terminateChat() { func chatSuspended() { suspendLockQueue.sync { - if case .suspending = appStateGroupDefault.get() { + if case .suspending = AppChatState.shared.value { _chatSuspended() } } @@ -81,48 +79,108 @@ func chatSuspended() { private func _chatSuspended() { logger.debug("_chatSuspended") - appStateGroupDefault.set(.suspended) + AppChatState.shared.set(.suspended) if ChatModel.shared.chatRunning == true { ChatReceiver.shared.stop() } - if terminating { - chatCloseStore() + chatCloseStore() +} + +func setAppState(_ appState: AppState) { + suspendLockQueue.sync { + AppChatState.shared.set(appState) } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") - terminating = false suspendLockQueue.sync { - appStateGroupDefault.set(appState) + AppChatState.shared.set(appState) if ChatModel.ok { apiActivateChat() } logger.debug("DEBUGGING: activateChat: after apiActivateChat") } } func initChatAndMigrate(refreshInvitations: Bool = true) { - terminating = false let m = ChatModel.shared if (!m.chatInitialized) { + m.v3DBMigration = v3DBMigrationDefault.get() + if AppChatState.shared.value == .stopped { + AlertManager.shared.showAlert(Alert( + title: Text("Start chat?"), + message: Text("Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat."), + primaryButton: .default(Text("Ok")) { + AppChatState.shared.set(.active) + initialize(start: true) + }, + secondaryButton: .cancel { + initialize(start: false) + } + )) + } else { + initialize(start: true) + } + } + + func initialize(start: Bool) { do { - m.v3DBMigration = v3DBMigrationDefault.get() - try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations) + try initializeChat(start: m.v3DBMigration.startChat && start, refreshInvitations: refreshInvitations) } catch let error { - fatalError("Failed to start or load chats: \(responseError(error))") + AlertManager.shared.showAlertMsg( + title: start ? "Error starting chat" : "Error opening chat", + message: "Please contact developers.\nError: \(responseError(error))" + ) } } } -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 == AppChatState.shared.value { + completion() + } else if nseStateGroupDefault.get().inactive { + activate() + } else { + // 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() + } + } + } + + func activate() { logger.debug("DEBUGGING: startChatAndActivate: before activateChat") activateChat() + completion() 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 448ed8b5c..057188c37 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -54,7 +54,7 @@ struct SimpleXApp: App { } .onAppear() { showInitializationView = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } } @@ -76,16 +76,19 @@ struct SimpleXApp: App { NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) 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() + let appState = AppChatState.shared.value + if appState != .stopped { + 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/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/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index 5befe405c..04c02f0dd 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -14,9 +14,6 @@ struct NotificationsView: View { @State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode @State private var showAlert: NotificationAlert? @State private var legacyDatabase = dbContainerGroupDefault.get() == .documents -// @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false -// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_LOCAL, store: groupDefaults) private var ntfEnableLocal = false -// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_PERIODIC, store: groupDefaults) private var ntfEnablePeriodic = false var body: some View { List { @@ -88,13 +85,6 @@ struct NotificationsView: View { .padding(.top, 1) } } - -// if developerTools { -// Section(String("Experimental")) { -// Toggle(String("Always enable local"), isOn: $ntfEnableLocal) -// Toggle(String("Always enable periodic"), isOn: $ntfEnablePeriodic) -// } -// } } .disabled(legacyDatabase) } @@ -119,7 +109,7 @@ struct NotificationsView: View { private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey { switch mode { - case .off: return "Turn off notifications?" + case .off: return "Use only local notifications?" case .periodic: return "Enable periodic notifications?" case .instant: return "Enable instant notifications?" } 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..eaa1131eb 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -14,91 +14,225 @@ 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 read cancellation, to ensure that notifications are not lost in case 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 allThreads: Set = [] + private var activeThreads: Set = [] + + func newThread() -> UUID { + NSEThreads.queue.sync { + 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 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 + } + } +} + +// 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 of notification service extension exists at a time. +// 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 + // 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? = NSEThreads.shared.newThread() + var receiveEntityId: String? + var cancelRead: (() -> Void)? + var appSubscriber: AppSubscriber? + var returnedSuspension = false override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") - if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent { - setBestAttemptNtf(ntf) - } + let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } + setBestAttemptNtf(ntf) self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() + logger.debug("NotificationService: app is \(appState.rawValue, privacy: .public)") switch appState { + case .stopped: + setBadgeCount() + setBestAttemptNtf(createAppStoppedNtf()) + deliverBestAttemptNtf() case .suspended: - logger.debug("NotificationService: app is suspended") setBadgeCount() receiveNtfMessages(request, contentHandler) case .suspending: - logger.debug("NotificationService: app is suspending") setBadgeCount() Task { - var state = appState - for _ in 1...5 { - _ = 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)") + logger.debug("NotificationService: app state is now \(state.rawValue, privacy: .public)") if state.inactive { receiveNtfMessages(request, contentHandler) } else { @@ -106,7 +240,6 @@ class NotificationService: UNNotificationServiceExtension { } } default: - logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)") deliverBestAttemptNtf() } } @@ -121,27 +254,35 @@ 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 + if let t = threadId { NSEThreads.shared.startThread(t) } + 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 { + } else if let dbStatus = dbStatus { setBestAttemptNtf(createErrorNtf(dbStatus)) } } @@ -159,14 +300,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 +315,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,66 +349,200 @@ 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() +// nseStateGroupDefault must not be used in NSE directly, only via this singleton +class NSEChatState { + static let shared = NSEChatState() + private var value_ = NSEState.created + var value: NSEState { + value_ + } + + 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) +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 .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() + } +} + +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 } + let state = NSEChatState.shared.value + NSEChatState.shared.set(.starting) 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) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) - let justStarted = try apiStartChat() - chatStarted = true - if justStarted { - chatLastStartGroupDefault.set(Date.now) - Task { 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("no active user") + logger.debug("NotificationService: no active user") } + if NSEChatState.shared.value == .starting { NSEChatState.shared.set(state) } 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 { + // 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 .starting: await delayWhenInactive() + case .active: 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 +556,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 +573,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 +591,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 +638,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 +683,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 +724,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 fc3df3a46..01696ad9e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -63,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 */; }; @@ -145,11 +150,9 @@ 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 */; }; + 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 */; }; @@ -335,6 +338,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 = ""; }; @@ -434,11 +442,9 @@ 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 = ""; }; + 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; }; @@ -521,12 +527,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */, - 5CF9371B2B22552700E1D781 /* libgmpxx.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 */, - 5CF9371A2B22552700E1D781 /* libgmp.a in Frameworks */, - 5CF937182B22552700E1D781 /* libffi.a in Frameworks */, - 5CF937192B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.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; @@ -589,11 +595,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 */, + 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 = ""; @@ -618,6 +624,7 @@ 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, @@ -798,6 +805,7 @@ isa = PBXGroup; children = ( 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, @@ -818,6 +826,7 @@ 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, @@ -1190,6 +1199,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 */, @@ -1269,6 +1279,7 @@ files = ( 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1276,6 +1287,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/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..4d1446965 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)" @@ -1495,6 +1498,8 @@ public enum PushProvider: String, Decodable { } } +// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable, +// and .local for periodic background checks public enum NotificationsMode: String, Decodable, SelectableItem { case off = "OFF" case periodic = "PERIODIC" @@ -1502,9 +1507,9 @@ public enum NotificationsMode: String, Decodable, SelectableItem { public var label: LocalizedStringKey { switch self { - case .off: return "Off (Local)" - case .periodic: return "Periodically" - case .instant: return "Instantly" + case .off: "Local" + case .periodic: "Periodically" + case .instant: "Instantly" } } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index cc61fae53..10625e2ed 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -9,12 +9,15 @@ 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" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" -public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" -public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" +public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used +public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles" @@ -66,13 +69,23 @@ public func registerGroupDefaults() { ]) } -public enum AppState: String { +public enum AppState: String, Codable { 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,23 +97,57 @@ 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, Codable { + case created + case starting + 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 } +// 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: .suspended // so that NSE that was never launched does not delay the app from resuming +) + +// inactive app states do not include "stopped" state +public func allowBackgroundRefresh() -> Bool { + appStateGroupDefault.get().inactive && nseStateGroupDefault.get().inactive +} + public let dbContainerGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_DB_CONTAINER, @@ -117,10 +164,6 @@ public let ntfPreviewModeGroupDefault = EnumDefault( public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO) -public let ntfEnableLocalGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_ENABLE_LOCAL) - -public let ntfEnablePeriodicGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_ENABLE_PERIODIC) - public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) 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/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index d613ff20a..bc959cb34 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -146,6 +146,13 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificati ) } +public func createAppStoppedNtf() -> UNMutableNotificationContent { + return createNotification( + categoryIdentifier: ntfCategoryConnectionEvent, + title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification") + ) +} + private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember, hideContent: Bool) -> String { hideContent ? NSLocalizedString("Group message:", comment: "notification") 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)) +} 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 0667b8304..b0dbaab3e 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: 560dc553127851fa1fb201d0a9c80dcf1ad6e5dc source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index e34847138..f4953a57a 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"."560dc553127851fa1fb201d0a9c80dcf1ad6e5dc" = "1xz3lw5dsh7gm136jzwmsbqjigsqsnjlbhg38mpc6lm586lg8f9x"; "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" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7acd9c907..807fbe38f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -8,7 +8,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -27,6 +26,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 @@ -49,7 +50,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 @@ -190,10 +191,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 @@ -537,16 +538,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_ @@ -595,7 +598,7 @@ processChatCommand = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) - APIGetChats {userId, pendingConnections, pagination, query} -> withUserId userId $ \user -> do + APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do (errs, previews) <- partitionEithers <$> withStore' (\db -> getChatPreviews db user pendingConnections pagination query) toView $ CRChatErrors (Just user) (map ChatErrorStore errs) pure $ CRApiChats user previews @@ -1177,16 +1180,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) @@ -1210,8 +1210,7 @@ processChatCommand = \case CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv - APISetChatItemTTL userId newTTL_ -> withUser $ \user -> do - checkSameUser userId user + APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do case newTTL_ of @@ -1229,7 +1228,7 @@ processChatCommand = \case ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ - APIGetChatItemTTL userId -> withUserId userId $ \user -> do + APIGetChatItemTTL userId -> withUserId' userId $ \user -> do ttl <- withStoreCtx' (Just "APIGetChatItemTTL, getChatItemTTL") (`getChatItemTTL` user) pure $ CRChatItemTTL user ttl GetChatItemTTL -> withUser' $ \User {userId} -> do @@ -1489,9 +1488,9 @@ processChatCommand = \case pure $ CRUserContactLinkDeleted user' DeleteMyAddress -> withUser $ \User {userId} -> processChatCommand $ APIDeleteMyAddress userId - APIShowMyAddress userId -> withUserId userId $ \user -> + APIShowMyAddress userId -> withUserId' userId $ \user -> CRUserContactLink user <$> withStoreCtx (Just "APIShowMyAddress, getUserAddress") (`getUserAddress` user) - ShowMyAddress -> withUser $ \User {userId} -> + ShowMyAddress -> withUser' $ \User {userId} -> processChatCommand $ APIShowMyAddress userId APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} @@ -3236,23 +3235,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 @@ -5919,6 +5919,11 @@ withUser action = withUser' $ \user -> withUser_ :: ChatMonad m => m ChatResponse -> m ChatResponse withUser_ = withUser . const +withUserId' :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse +withUserId' userId action = withUser' $ \user -> do + checkSameUser userId user + action user + withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse withUserId userId action = withUser $ \user -> do checkSameUser userId user @@ -5968,7 +5973,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), @@ -5983,9 +5989,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, @@ -6338,7 +6344,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 04c47a646..8446c15a8 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 @@ -455,7 +459,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 @@ -656,7 +660,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} @@ -848,17 +853,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" @@ -923,9 +928,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 7cf1920a2..3d73043e7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -278,6 +278,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 665ef33f9..74e29cb0b 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