diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 9e6073c10e..bb1de94359 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 5ee52407bf..a39155efe8 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 13fe0737e2..e7932f2d92 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/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 90070e74d3..41d741e7e6 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -195,18 +195,18 @@ func moveTempFileFromURL(_ url: URL) -> CryptoFile? { } } -func generateNewFileName(_ prefix: String, _ ext: String) -> String { - uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") +func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String { + uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath) } -private func uniqueCombine(_ fileName: String) -> String { +private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String { func tryCombine(_ fileName: String, _ n: Int) -> String { let ns = fileName as NSString let name = ns.deletingPathExtension let ext = ns.pathExtension let suffix = (n == 0) ? "" : "_\(n)" let f = "\(name)\(suffix).\(ext)" - return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f + return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f } return tryCombine(fileName, 0) } diff --git a/apps/ios/Shared/Model/NSESubscriber.swift b/apps/ios/Shared/Model/NSESubscriber.swift new file mode 100644 index 0000000000..f52e72beab --- /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 19030a2842..e67dab18fa 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 1c8c32f8b9..9b03f38f3c 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 448ed8b5c4..057188c37c 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 9ca894ea89..fcd3a85584 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/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 0572821770..4001edffb3 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -384,10 +384,10 @@ struct ComposeView: View { } } .sheet(isPresented: $showMediaPicker) { - LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in - showMediaPicker = false - if itemsSelected { - DispatchQueue.main.async { + LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10) { itemsSelected in + await MainActor.run { + showMediaPicker = false + if itemsSelected { composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: [])) } } @@ -488,6 +488,21 @@ struct ComposeView: View { } } + private func addMediaContent(_ content: UploadContent) async { + if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { + var newMedia: [(String, UploadContent?)] = [] + if case var .mediaPreviews(media) = composeState.preview { + media.append((img, content)) + newMedia = media + } else { + newMedia = [(img, content)] + } + await MainActor.run { + composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia)) + } + } + } + private var maxFileSize: Int64 { getMaxFileSize(.xftp) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 94a018749e..09ead880ad 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -16,7 +16,6 @@ struct GroupChatInfoView: View { @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo - @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 4a187cecb9..7e336c3328 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -188,17 +188,19 @@ struct GroupMemberInfoView: View { // this condition prevents re-setting picker if !justOpened { return } } - newRole = member.memberRole - do { - let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) - let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) - _ = chatModel.upsertGroupMember(groupInfo, mem) - connectionStats = stats - connectionCode = code - } catch let error { - logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") - } justOpened = false + DispatchQueue.main.async { + newRole = member.memberRole + do { + let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) + let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) + _ = chatModel.upsertGroupMember(groupInfo, mem) + connectionStats = stats + connectionCode = code + } catch let error { + logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") + } + } } .onChange(of: newRole) { newRole in if newRole != member.memberRole { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 7e123c389f..18cc3f4d80 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -103,8 +103,10 @@ struct GroupProfileView: View { } } .sheet(isPresented: $showImagePicker) { - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { + showImagePicker = false + } } } .onChange(of: chosenImage) { image in diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift index 09861fa50b..f364b4ed0b 100644 --- a/apps/ios/Shared/Views/Chat/ScanCodeView.swift +++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift @@ -17,7 +17,7 @@ struct ScanCodeView: View { var body: some View { VStack(alignment: .leading) { - CodeScannerView(codeTypes: [.qr], completion: processQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) Text("Scan security code from your contact's app.") diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 65ec9ef944..72515a1fac 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/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 1b44c23135..0e3f8082b3 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -13,112 +13,122 @@ import SimpleXChat struct LibraryImagePicker: View { @Binding var image: UIImage? - var didFinishPicking: (_ didSelectItems: Bool) -> Void - @State var images: [UploadContent] = [] + var didFinishPicking: (_ didSelectImage: Bool) async -> Void + @State var mediaAdded = false var body: some View { - LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking) - .onChange(of: images) { _ in - if let img = images.first { - image = img.uiImage - } - } + LibraryMediaListPicker(addMedia: addMedia, selectionLimit: 1, didFinishPicking: didFinishPicking) + } + + private func addMedia(_ content: UploadContent) async { + if mediaAdded { return } + await MainActor.run { + mediaAdded = true + image = content.uiImage + } } } struct LibraryMediaListPicker: UIViewControllerRepresentable { typealias UIViewControllerType = PHPickerViewController - @Binding var media: [UploadContent] + var addMedia: (_ content: UploadContent) async -> Void var selectionLimit: Int - var didFinishPicking: (_ didSelectItems: Bool) -> Void + var didFinishPicking: (_ didSelectItems: Bool) async -> Void class Coordinator: PHPickerViewControllerDelegate { let parent: LibraryMediaListPicker let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker") - var media: [UploadContent] = [] - var mediaCount: Int = 0 init(_ parent: LibraryMediaListPicker) { self.parent = parent } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - parent.didFinishPicking(!results.isEmpty) - guard !results.isEmpty else { - return + Task { + await parent.didFinishPicking(!results.isEmpty) + if results.isEmpty { return } + for r in results { + await loadItem(r.itemProvider) + } } + } - parent.media = [] - media = [] - mediaCount = results.count - for result in results { - logger.log("LibraryMediaListPicker result") - let p = result.itemProvider - if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { - p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in - if let url = url { - let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension)) - if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) { - ChatModel.shared.filesToDelete.insert(tempUrl) - self.loadVideo(url: tempUrl, error: error) + private func loadItem(_ p: NSItemProvider) async { + logger.debug("LibraryMediaListPicker result") + if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + if let video = await loadVideo(p) { + await self.parent.addMedia(video) + logger.debug("LibraryMediaListPicker: added video") + } + } else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { + if let img = await loadImageData(p) { + await self.parent.addMedia(img) + logger.debug("LibraryMediaListPicker: added image") + } + } else if p.canLoadObject(ofClass: UIImage.self) { + if let img = await loadImage(p) { + await self.parent.addMedia(.simpleImage(image: img)) + logger.debug("LibraryMediaListPicker: added image") + } + } + } + + private func loadImageData(_ p: NSItemProvider) async -> UploadContent? { + await withCheckedContinuation { cont in + loadFileURL(p, type: UTType.data) { url in + if let url = url { + let img = UploadContent.loadFromURL(url: url) + cont.resume(returning: img) + } else { + cont.resume(returning: nil) + } + } + } + } + + private func loadImage(_ p: NSItemProvider) async -> UIImage? { + await withCheckedContinuation { cont in + p.loadObject(ofClass: UIImage.self) { obj, err in + if let err = err { + logger.error("LibraryMediaListPicker result image error: \(err.localizedDescription)") + cont.resume(returning: nil) + } else { + cont.resume(returning: obj as? UIImage) + } + } + } + } + + private func loadVideo(_ p: NSItemProvider) async -> UploadContent? { + await withCheckedContinuation { cont in + loadFileURL(p, type: UTType.movie) { url in + if let url = url { + let tempUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "video", url.pathExtension, fullPath: true)) + do { +// logger.debug("LibraryMediaListPicker copyItem \(url) to \(tempUrl)") + try FileManager.default.copyItem(at: url, to: tempUrl) + DispatchQueue.main.async { + _ = ChatModel.shared.filesToDelete.insert(tempUrl) } + let video = UploadContent.loadVideoFromURL(url: tempUrl) + cont.resume(returning: video) + return + } catch let err { + logger.error("LibraryMediaListPicker copyItem error: \(err.localizedDescription)") } } - } else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { - p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in - self.loadImage(object: url, error: error) - } - } else if p.canLoadObject(ofClass: UIImage.self) { - p.loadObject(ofClass: UIImage.self) { image, error in - DispatchQueue.main.async { - self.loadImage(object: image, error: error) - } - } + cont.resume(returning: nil) + } + } + } + + private func loadFileURL(_ p: NSItemProvider, type: UTType, completion: @escaping (URL?) -> Void) { + p.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, err in + if let err = err { + logger.error("LibraryMediaListPicker loadFileURL error: \(err.localizedDescription)") + completion(nil) } else { - dispatchQueue.sync { self.mediaCount -= 1} - } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - self.dispatchQueue.sync { - if self.parent.media.count == 0 { - logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)") - self.parent.media = self.media - } - } - } - } - - func loadImage(object: Any?, error: Error? = nil) { - if let error = error { - logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)") - } else if let image = object as? UIImage { - media.append(.simpleImage(image: image)) - logger.log("LibraryMediaListPicker: added image") - } else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) { - media.append(image) - } - dispatchQueue.sync { - self.mediaCount -= 1 - if self.mediaCount == 0 && self.parent.media.count == 0 { - logger.log("LibraryMediaListPicker: added all media") - self.parent.media = self.media - self.media = [] - } - } - } - - func loadVideo(url: URL?, error: Error? = nil) { - if let error = error { - logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)") - } else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) { - media.append(video) - } - dispatchQueue.sync { - self.mediaCount -= 1 - if self.mediaCount == 0 && self.parent.media.count == 0 { - logger.log("LibraryMediaListPicker: added all media") - self.parent.media = self.media - self.media = [] + completion(url) } } } diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 59b13e45b3..bdb5b03e8c 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/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 2d7f31c58e..6c7919669b 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -130,8 +130,10 @@ struct AddGroupView: View { } } .sheet(isPresented: $showImagePicker) { - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { + showImagePicker = false + } } } .alert(isPresented: $showInvalidNameAlert) { diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index 82c4629c0c..3ddb85079c 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -74,6 +74,7 @@ struct QRCode: View { .onAppear { image = image ?? generateImage(uri, tintColor: tintColor) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 9a11eee92b..7f3f5e02f8 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -25,7 +25,7 @@ struct ScanToConnectView: View { .fixedSize(horizontal: false, vertical: true) .padding(.vertical) - CodeScannerView(codeTypes: [.qr], completion: processQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index e934bbc89a..6809dc1385 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -332,7 +332,7 @@ struct ConnectDesktopView: View { private func scanDesctopAddressView() -> some View { Section("Scan QR code from desktop") { - CodeScannerView(codeTypes: [.qr], completion: processDesktopQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processDesktopQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) .listRowBackground(Color.clear) diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift index 8e8885b518..9da3bac00b 100644 --- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift @@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View { } .disabled(currentNetCfg == NetCfg.proxyDefaults) - timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) - timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) - timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel) + timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) + timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) + timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 45_000, 60_000, 90_000, 120_000], label: secondsLabel) timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel) intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "") Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive) diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index 5befe405c6..04c02f0dd2 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/Shared/Views/UserSettings/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift index ffdbd1b07e..33825fee62 100644 --- a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift @@ -21,7 +21,7 @@ struct ScanProtocolServer: View { .font(.largeTitle) .bold() .padding(.vertical) - CodeScannerView(codeTypes: [.qr], completion: processQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) .padding(.top) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index b64ec21de6..e5ec23178d 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -120,8 +120,10 @@ struct UserProfile: View { } } .sheet(isPresented: $showImagePicker) { - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { + showImagePicker = false + } } } .onChange(of: chosenImage) { image in diff --git a/apps/ios/SimpleX NSE/ConcurrentQueue.swift b/apps/ios/SimpleX NSE/ConcurrentQueue.swift new file mode 100644 index 0000000000..274a683c00 --- /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 ea52f4be89..eaa1131eb8 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 a3b7580a1a..60a9e2ff00 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -116,6 +116,11 @@ 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; + 5CCD1A602B27927E001A4199 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A5B2B27927E001A4199 /* libgmpxx.a */; }; + 5CCD1A612B27927E001A4199 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A5C2B27927E001A4199 /* libgmp.a */; }; + 5CCD1A622B27927E001A4199 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A5D2B27927E001A4199 /* libffi.a */; }; + 5CCD1A632B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A5E2B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */; }; + 5CCD1A642B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A5F2B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -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 */; }; @@ -399,6 +402,11 @@ 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; + 5CCD1A5B2B27927E001A4199 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CCD1A5C2B27927E001A4199 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CCD1A5D2B27927E001A4199 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CCD1A5E2B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a"; sourceTree = ""; }; + 5CCD1A5F2B27927E001A4199 /* 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 = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = ""; }; @@ -429,11 +437,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; }; @@ -511,13 +517,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */, - 5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */, + 5CCD1A602B27927E001A4199 /* 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 */, + 5CCD1A612B27927E001A4199 /* libgmp.a in Frameworks */, + 5CCD1A622B27927E001A4199 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5CCD1A642B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a in Frameworks */, + 5CCD1A632B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -579,11 +585,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 */, + 5CCD1A5D2B27927E001A4199 /* libffi.a */, + 5CCD1A5C2B27927E001A4199 /* libgmp.a */, + 5CCD1A5B2B27927E001A4199 /* libgmpxx.a */, + 5CCD1A5F2B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */, + 5CCD1A5E2B27927E001A4199 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */, ); path = Libraries; sourceTree = ""; @@ -608,6 +614,7 @@ 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, @@ -788,6 +795,7 @@ isa = PBXGroup; children = ( 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, @@ -808,6 +816,7 @@ 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, @@ -1180,6 +1189,7 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, + 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */, @@ -1259,6 +1269,7 @@ files = ( 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1266,6 +1277,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */, 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index c7e94a2dc0..dfa4caf099 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 3d2c21392e..a199966bab 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)" @@ -1204,9 +1207,9 @@ public struct NetCfg: Codable, Equatable { public static let defaults: NetCfg = NetCfg( socksProxy: nil, sessionMode: TransportSessionMode.user, - tcpConnectTimeout: 15_000_000, - tcpTimeout: 10_000_000, - tcpTimeoutPerKb: 30_000, + tcpConnectTimeout: 20_000_000, + tcpTimeout: 15_000_000, + tcpTimeoutPerKb: 45_000, tcpKeepAlive: KeepAliveOpts.defaults, smpPingInterval: 1200_000_000, smpPingCount: 3, @@ -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 cc61fae53f..10625e2edf 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 dc4cdda462..a545d3508c 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 d613ff20ae..bc959cb34b 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 0000000000..f496e6999e --- /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 2872922a9b..6e37a51779 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/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 6c01aff5d3..ad897c60fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2800,9 +2800,9 @@ data class NetCfg( hostMode = HostMode.OnionViaSocks, requiredHostMode = false, sessionMode = TransportSessionMode.User, - tcpConnectTimeout = 15_000_000, - tcpTimeout = 10_000_000, - tcpTimeoutPerKb = 30_000, + tcpConnectTimeout = 20_000_000, + tcpTimeout = 15_000_000, + tcpTimeoutPerKb = 45_000, tcpKeepAlive = KeepAliveOpts.defaults, smpPingInterval = 1200_000_000, smpPingCount = 3 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index 5849178202..5fb8bfb03e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -154,20 +154,20 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, - listOf(5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000_000, 45_000_000), secondsLabel + listOf(7_500000, 10_000000, 15_000000, 20_000000, 30_000_000, 45_000_000), secondsLabel ) } SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, - listOf(3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel + listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel ) } SectionItemView { // can't be higher than 130ms to avoid overflow on 32bit systems TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(15_000, 30_000, 60_000, 90_000, 120_000), secondsLabel + listOf(15_000, 30_000, 45_000, 60_000, 90_000, 120_000), secondsLabel ) } SectionItemView { diff --git a/cabal.project b/cabal.project index 3de5197d6b..873035d7ab 100644 --- a/cabal.project +++ b/cabal.project @@ -4,25 +4,23 @@ packages: . with-compiler: ghc-9.6.3 -index-state: 2023-10-20T00:00:00Z +index-state: 2023-12-12T00:00:00Z + +package cryptostore + flags: +use_crypton constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a860936072172e261480fa6bdd95203976e366b2 + tag: 18be2709f59a4cb20fe9758b899622092dba062e source-repository-package type: git location: https://github.com/simplex-chat/hs-socks.git tag: a30cc7a79a08d8108316094f8f2f82a0c5e1ac51 -source-repository-package - type: git - location: https://github.com/kazu-yamamoto/http2.git - tag: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb - source-repository-package type: git location: https://github.com/simplex-chat/direct-sqlcipher.git diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index a43a694097..5362e4f2c3 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -7,7 +7,7 @@ revision: 25.11.2023 | Updated 25.11.2023 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.4.0. +The latest stable version is v5.4.1. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-windows-x86_64.msi). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-windows-x86_64.msi). ## Mobile apps **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-windows-x86-64). diff --git a/flake.lock b/flake.lock index c482f7ca9b..e5f8d531cc 100644 --- a/flake.lock +++ b/flake.lock @@ -190,11 +190,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1699834964, - "narHash": "sha256-733KT+G0c1euCeb60/u1qbX22Kvu9lNnIDfAmk6Jxq0=", + "lastModified": 1702340598, + "narHash": "sha256-CC0HI+6iKPtH+8r/ZfcpW5v/OYvL7zMwpr0xfkXV1zU=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "2e891e530400187ea1083ffef15adf259061be41", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index c371fa97e8..999f097c24 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { compiler-nix-name = "ghc963"; - index-state = "2023-10-20T00:00:00Z"; + index-state = "2023-12-12T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. projectFileName = "cabal.project"; diff --git a/package.yaml b/package.yaml index a4cc60d83f..af58ce6729 100644 --- a/package.yaml +++ b/package.yaml @@ -22,7 +22,7 @@ dependencies: - composition == 1.0.* - constraints >= 0.12 && < 0.14 - containers == 0.6.* - - cryptonite == 0.30.* + - crypton == 0.34.* - data-default >= 0.7 && < 0.8 - directory == 1.3.* - direct-sqlcipher == 2.3.* @@ -46,7 +46,7 @@ dependencies: - stm == 2.5.* - terminal == 0.2.* - time == 1.9.* - - tls >= 1.6.0 && < 1.7 + - tls >= 1.7.0 && < 1.8 - unliftio == 0.2.* - unliftio-core == 0.2.* - zip == 2.0.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fc57b60045..3733163f49 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,6 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a860936072172e261480fa6bdd95203976e366b2" = "16rwnh5zzphmw8d8ypvps6xjvzbmf5ljr6zzy15gz2g0jyh7hd91"; + "https://github.com/simplex-chat/simplexmq.git"."18be2709f59a4cb20fe9758b899622092dba062e" = "08dr4vyg1wz2z768iikg8fks5zqf4dw5myr87hbpv964idda3pmj"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index a29c629fd2..5cccb04599 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -176,7 +176,7 @@ library , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -200,7 +200,7 @@ library , stm ==2.5.* , terminal ==0.2.* , time ==1.9.* - , tls >=1.6.0 && <1.7 + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -235,7 +235,7 @@ executable simplex-bot , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -260,7 +260,7 @@ executable simplex-bot , stm ==2.5.* , terminal ==0.2.* , time ==1.9.* - , tls >=1.6.0 && <1.7 + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -295,7 +295,7 @@ executable simplex-bot-advanced , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -320,7 +320,7 @@ executable simplex-bot-advanced , stm ==2.5.* , terminal ==0.2.* , time ==1.9.* - , tls >=1.6.0 && <1.7 + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -357,7 +357,7 @@ executable simplex-broadcast-bot , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -382,7 +382,7 @@ executable simplex-broadcast-bot , stm ==2.5.* , terminal ==0.2.* , time ==1.9.* - , tls >=1.6.0 && <1.7 + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -418,7 +418,7 @@ executable simplex-chat , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -443,7 +443,7 @@ executable simplex-chat , stm ==2.5.* , terminal ==0.2.* , time ==1.9.* - , tls >=1.6.0 && <1.7 + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* @@ -483,7 +483,7 @@ executable simplex-directory-service , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -508,7 +508,7 @@ executable simplex-directory-service , stm ==2.5.* , terminal ==0.2.* , time ==1.9.* - , tls >=1.6.0 && <1.7 + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -572,7 +572,7 @@ test-suite simplex-chat-test , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , deepseq ==1.4.* , direct-sqlcipher ==2.3.* @@ -601,7 +601,7 @@ test-suite simplex-chat-test , stm ==2.5.* , terminal ==0.2.* , time ==1.9.* - , tls >=1.6.0 && <1.7 + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b1a657b694..c48b3feba7 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -9,7 +9,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -28,6 +27,8 @@ import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (bimap, first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -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 (Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive @@ -195,10 +196,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 @@ -542,16 +543,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_ @@ -600,7 +603,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 @@ -1182,16 +1185,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) @@ -1215,8 +1215,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 @@ -1234,7 +1233,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 @@ -1493,9 +1492,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} @@ -3239,23 +3238,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 @@ -6017,6 +6017,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 @@ -6066,7 +6071,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), @@ -6081,9 +6087,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, @@ -6436,7 +6442,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 22e5f1ee2f..d386b48d40 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 d73dba25e9..9341213765 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 0706dda084..c409526a0b 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/Migrations/M20231207_chat_list_pagination.hs b/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs index cf272ae651..9a8944c5c5 100644 --- a/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs +++ b/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs @@ -26,9 +26,6 @@ CREATE INDEX idx_contacts_chat_ts ON contacts(user_id, chat_ts); CREATE INDEX idx_groups_chat_ts ON groups(user_id, chat_ts); CREATE INDEX idx_contact_requests_updated_at ON contact_requests(user_id, updated_at); CREATE INDEX idx_connections_updated_at ON connections(user_id, updated_at); - -CREATE INDEX idx_chat_items_contact_id_item_status ON chat_items(contact_id, item_status); -CREATE INDEX idx_chat_items_group_id_item_status ON chat_items(group_id, item_status); |] down_m20231207_chat_list_pagination :: Query @@ -38,7 +35,4 @@ DROP INDEX idx_contacts_chat_ts; DROP INDEX idx_groups_chat_ts; DROP INDEX idx_contact_requests_updated_at; DROP INDEX idx_connections_updated_at; - -DROP INDEX idx_chat_items_contact_id_item_status; -DROP INDEX idx_chat_items_group_id_item_status; |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 10570c7659..59fb2bae59 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -802,14 +802,6 @@ CREATE INDEX idx_contact_requests_updated_at ON contact_requests( updated_at ); CREATE INDEX idx_connections_updated_at ON connections(user_id, updated_at); -CREATE INDEX idx_chat_items_contact_id_item_status ON chat_items( - contact_id, - item_status -); -CREATE INDEX idx_chat_items_group_id_item_status ON chat_items( - group_id, - item_status -); CREATE INDEX idx_msg_deliveries_message_id ON "msg_deliveries"(message_id); CREATE INDEX idx_msg_deliveries_agent_ack_cmd_id ON "msg_deliveries"( connection_id, diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 69f6887403..a7f032c75b 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 f8cab1e357..85298ae310 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 b9989d8af1..3d98eb7e35 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 5f8577ffbe..91021713b1 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/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 229f7fe31f..7bfc9c6d05 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -468,8 +468,8 @@ getChatPreviews db user withPCC pagination query = do where ts :: AChatPreviewData -> UTCTime ts (ACPD _ cpd) = case cpd of - (DirectChatPD t _ _) -> t - (GroupChatPD t _ _) -> t + (DirectChatPD t _ _ _) -> t + (GroupChatPD t _ _ _) -> t (ContactRequestPD t _) -> t (ContactConnectionPD t _) -> t sortTake = case pagination of @@ -484,8 +484,8 @@ getChatPreviews db user withPCC pagination query = do SCTContactConnection -> let (ContactConnectionPD _ chat) = cpd in pure chat data ChatPreviewData (c :: ChatType) where - DirectChatPD :: UTCTime -> ContactId -> Maybe ChatStats -> ChatPreviewData 'CTDirect - GroupChatPD :: UTCTime -> GroupId -> Maybe ChatStats -> ChatPreviewData 'CTGroup + DirectChatPD :: UTCTime -> ContactId -> Maybe ChatItemId -> ChatStats -> ChatPreviewData 'CTDirect + GroupChatPD :: UTCTime -> GroupId -> Maybe ChatItemId -> ChatStats -> ChatPreviewData 'CTGroup ContactRequestPD :: UTCTime -> AChat -> ChatPreviewData 'CTContactRequest ContactConnectionPD :: UTCTime -> AChat -> ChatPreviewData 'CTContactConnection @@ -497,283 +497,200 @@ paginationByTimeFilter = \case PTAfter ts count -> ("\nAND ts > :ts ORDER BY ts ASC LIMIT :count", [":ts" := ts, ":count" := count]) PTBefore ts count -> ("\nAND ts < :ts ORDER BY ts DESC LIMIT :count", [":ts" := ts, ":count" := count]) -type MaybeChatStatsRow = (Maybe Int, Maybe ChatItemId, Maybe Bool) +type ChatStatsRow = (Int, ChatItemId, Bool) -toMaybeChatStats :: MaybeChatStatsRow -> Maybe ChatStats -toMaybeChatStats (Just unreadCount, Just minUnreadItemId, Just unreadChat) = Just ChatStats {unreadCount, minUnreadItemId, unreadChat} -toMaybeChatStats _ = Nothing +toChatStats :: ChatStatsRow -> ChatStats +toChatStats (unreadCount, minUnreadItemId, unreadChat) = ChatStats {unreadCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = map toPreview <$> getPreviews where - toPreview :: (ContactId, UTCTime) :. MaybeChatStatsRow -> AChatPreviewData - toPreview ((contactId, ts) :. statsRow_) = - ACPD SCTDirect $ DirectChatPD ts contactId (toMaybeChatStats statsRow_) + toPreview :: (ContactId, UTCTime, Maybe ChatItemId) :. ChatStatsRow -> AChatPreviewData + toPreview ((contactId, ts, lastItemId_) :. statsRow) = + ACPD SCTDirect $ DirectChatPD ts contactId lastItemId_ (toChatStats statsRow) + baseQuery = + [sql| + SELECT ct.contact_id, ct.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, chat_item_id, MAX(created_at) + FROM chat_items + GROUP BY contact_id + ) LastItems ON LastItems.contact_id = ct.contact_id + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE item_status = :rcv_new + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + |] (pagQuery, pagParams) = paginationByTimeFilter pagination getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> DB.queryNamed db - ( [sql| - SELECT ct.contact_id, ct.chat_ts as ts, NULL, NULL, NULL - FROM contacts ct - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - |] + ( baseQuery + <> [sql| + WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + |] <> pagQuery ) - ([":user_id" := userId] <> pagParams) + ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQFilters {favorite = True, unread = False} -> DB.queryNamed db - ( [sql| - SELECT ct.contact_id, ct.chat_ts as ts, NULL, NULL, NULL - FROM contacts ct - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND ct.favorite = 1 - |] + ( baseQuery + <> [sql| + WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND ct.favorite = 1 + |] <> pagQuery ) - ([":user_id" := userId] <> pagParams) + ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQFilters {favorite = False, unread = True} -> DB.queryNamed db - ( [sql| - SELECT ct.contact_id, ct.chat_ts as ts, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat - FROM contacts ct - LEFT JOIN ( - SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE item_status = :rcv_new - GROUP BY contact_id - ) ChatStats ON ChatStats.contact_id = ct.contact_id - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + ( baseQuery + <> [sql| + WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] <> pagQuery ) ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQFilters {favorite = True, unread = True} -> DB.queryNamed db - ( [sql| - SELECT ct.contact_id, ct.chat_ts as ts, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat - FROM contacts ct - LEFT JOIN ( - SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE item_status = :rcv_new - GROUP BY contact_id - ) ChatStats ON ChatStats.contact_id = ct.contact_id - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND (ct.favorite = 1 - OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + ( baseQuery + <> [sql| + WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND (ct.favorite = 1 + OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] <> pagQuery ) ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQSearch {search} -> DB.queryNamed db - ( [sql| - SELECT ct.contact_id, ct.chat_ts as ts, NULL, NULL, NULL - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND ( - ct.local_display_name LIKE '%' || :search || '%' - OR cp.display_name LIKE '%' || :search || '%' - OR cp.full_name LIKE '%' || :search || '%' - OR cp.local_alias LIKE '%' || :search || '%' - ) - |] + ( baseQuery + <> [sql| + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND ( + ct.local_display_name LIKE '%' || :search || '%' + OR cp.display_name LIKE '%' || :search || '%' + OR cp.full_name LIKE '%' || :search || '%' + OR cp.local_alias LIKE '%' || :search || '%' + ) + |] <> pagQuery ) - ([":user_id" := userId, ":search" := search] <> pagParams) + ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) getDirectChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat -getDirectChatPreview_ db user (DirectChatPD _ contactId stats_) = do +getDirectChatPreview_ db user (DirectChatPD _ contactId lastItemId_ stats) = do contact <- getContact db user contactId - lastItem <- getLastItem - stats <- maybe getChatStats pure stats_ + lastItem <- case lastItemId_ of + Just lastItemId -> (: []) <$> getDirectChatItem db user contactId lastItemId + Nothing -> pure [] pure $ AChat SCTDirect (Chat (DirectChat contact) lastItem stats) - where - getLastItem :: ExceptT StoreError IO [CChatItem 'CTDirect] - getLastItem = - liftIO getLastItemId >>= \case - Nothing -> pure [] - Just lastItemId -> (: []) <$> getDirectChatItem db user contactId lastItemId - getLastItemId :: IO (Maybe ChatItemId) - getLastItemId = - maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT chat_item_id FROM ( - SELECT contact_id, chat_item_id, MAX(created_at) - FROM chat_items - WHERE contact_id = ? - GROUP BY contact_id - ) - |] - (Only contactId) - getChatStats :: ExceptT StoreError IO ChatStats - getChatStats = do - r_ <- liftIO getUnreadStats - let (unreadCount, minUnreadItemId) = maybe (0, 0) (\(_, unreadCnt, minId) -> (unreadCnt, minId)) r_ - -- unread_chat could be read into contact to not search twice - unreadChat <- - ExceptT . firstRow fromOnly (SEInternalError $ "unread_chat not found for contact " <> show contactId) $ - DB.query db "SELECT unread_chat FROM contacts WHERE contact_id = ?" (Only contactId) - pure ChatStats {unreadCount, minUnreadItemId, unreadChat} - getUnreadStats :: IO (Maybe (ContactId, Int, ChatItemId)) - getUnreadStats = - maybeFirstRow id $ - DB.query - db - [sql| - SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE contact_id = ? AND item_status = ? - GROUP BY contact_id - |] - (contactId, CISRcvNew) findGroupChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findGroupChatPreviews_ db User {userId} pagination clq = map toPreview <$> getPreviews where - toPreview :: (GroupId, UTCTime) :. MaybeChatStatsRow -> AChatPreviewData - toPreview ((groupId, ts) :. statsRow_) = - ACPD SCTGroup $ GroupChatPD ts groupId (toMaybeChatStats statsRow_) + toPreview :: (GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow -> AChatPreviewData + toPreview ((groupId, ts, lastItemId_) :. statsRow) = + ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toChatStats statsRow) + baseQuery = + [sql| + SELECT g.group_id, g.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, chat_item_id, MAX(item_ts) + FROM chat_items + GROUP BY group_id + ) LastItems ON LastItems.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE item_status = :rcv_new + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + |] (pagQuery, pagParams) = paginationByTimeFilter pagination getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> DB.queryNamed db - ( [sql| - SELECT g.group_id, g.chat_ts as ts, NULL, NULL, NULL - FROM groups g - WHERE g.user_id = :user_id - |] + ( baseQuery + <> [sql| + WHERE g.user_id = :user_id + |] <> pagQuery ) - ([":user_id" := userId] <> pagParams) + ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQFilters {favorite = True, unread = False} -> DB.queryNamed db - ( [sql| - SELECT g.group_id, g.chat_ts as ts, NULL, NULL, NULL - FROM groups g - WHERE g.user_id = :user_id - AND g.favorite = 1 - |] + ( baseQuery + <> [sql| + WHERE g.user_id = :user_id + AND g.favorite = 1 + |] <> pagQuery ) - ([":user_id" := userId] <> pagParams) + ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQFilters {favorite = False, unread = True} -> DB.queryNamed db - ( [sql| - SELECT g.group_id, g.chat_ts as ts, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat - FROM groups g - LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE item_status = :rcv_new - GROUP BY group_id - ) ChatStats ON ChatStats.group_id = g.group_id - WHERE g.user_id = :user_id - AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + ( baseQuery + <> [sql| + WHERE g.user_id = :user_id + AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] <> pagQuery ) ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQFilters {favorite = True, unread = True} -> DB.queryNamed db - ( [sql| - SELECT g.group_id, g.chat_ts as ts, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat - FROM groups g - LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE item_status = :rcv_new - GROUP BY group_id - ) ChatStats ON ChatStats.group_id = g.group_id - WHERE g.user_id = :user_id - AND (g.favorite = 1 - OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + ( baseQuery + <> [sql| + WHERE g.user_id = :user_id + AND (g.favorite = 1 + OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + |] <> pagQuery ) ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) CLQSearch {search} -> DB.queryNamed db - ( [sql| - SELECT g.group_id, g.chat_ts as ts, NULL, NULL, NULL - FROM groups g - JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE g.user_id = :user_id - AND ( - g.local_display_name LIKE '%' || :search || '%' - OR gp.display_name LIKE '%' || :search || '%' - OR gp.full_name LIKE '%' || :search || '%' - OR gp.description LIKE '%' || :search || '%' - ) - |] + ( baseQuery + <> [sql| + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = :user_id + AND ( + g.local_display_name LIKE '%' || :search || '%' + OR gp.display_name LIKE '%' || :search || '%' + OR gp.full_name LIKE '%' || :search || '%' + OR gp.description LIKE '%' || :search || '%' + ) + |] <> pagQuery ) - ([":user_id" := userId, ":search" := search] <> pagParams) + ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) getGroupChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat -getGroupChatPreview_ db user (GroupChatPD _ groupId stats_) = do +getGroupChatPreview_ db user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db user groupId - lastItem <- getLastItem - stats <- maybe getChatStats pure stats_ + lastItem <- case lastItemId_ of + Just lastItemId -> (: []) <$> getGroupChatItem db user groupId lastItemId + Nothing -> pure [] pure $ AChat SCTGroup (Chat (GroupChat groupInfo) lastItem stats) - where - getLastItem :: ExceptT StoreError IO [CChatItem 'CTGroup] - getLastItem = - liftIO getLastItemId >>= \case - Nothing -> pure [] - Just lastItemId -> (: []) <$> getGroupChatItem db user groupId lastItemId - getLastItemId :: IO (Maybe ChatItemId) - getLastItemId = - maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT chat_item_id FROM ( - SELECT group_id, chat_item_id, MAX(item_ts) - FROM chat_items - WHERE group_id = ? - GROUP BY group_id - ) - |] - (Only groupId) - getChatStats :: ExceptT StoreError IO ChatStats - getChatStats = do - r_ <- liftIO getUnreadStats - let (unreadCount, minUnreadItemId) = maybe (0, 0) (\(_, unreadCnt, minId) -> (unreadCnt, minId)) r_ - -- unread_chat could be read into group to not search twice - unreadChat <- - ExceptT . firstRow fromOnly (SEInternalError $ "unread_chat not found for group " <> show groupId) $ - DB.query db "SELECT unread_chat FROM groups WHERE group_id = ?" (Only groupId) - pure ChatStats {unreadCount, minUnreadItemId, unreadChat} - getUnreadStats :: IO (Maybe (GroupId, Int, ChatItemId)) - getUnreadStats = - maybeFirstRow id $ - DB.query - db - [sql| - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE group_id = ? AND item_status = ? - GROUP BY group_id - |] - (groupId, CISRcvNew) getContactRequestChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6cb5de47cc..35414c76ea 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -279,6 +279,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] + CRNtfMessage {} -> [] CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 665ef33f97..74e29cb0b1 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 d8e98513c7..64fb7c98b8 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 f254927154..4d9a2a286a 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