From 2f7632a70f0d7904cb65f710bc6bc71edd44416e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Dec 2023 21:01:14 +0000 Subject: [PATCH 01/69] 5.4.1: ios 185, android 164, desktop 19 --- apps/ios/Shared/SimpleXApp.swift | 4 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 64 +++++++++++----------- apps/multiplatform/gradle.properties | 8 +-- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index fd1ec9511b..448ed8b5c4 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -26,10 +26,10 @@ struct SimpleXApp: App { @State private var showInitializationView = false init() { - DispatchQueue.global(qos: .background).sync { +// DispatchQueue.global(qos: .background).sync { haskell_init() // hs_init(0, nil) - } +// } UserDefaults.standard.register(defaults: appDefaults) setGroupDefaults() registerGroupDefaults() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index f8e86f4c8f..a3b7580a1a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,11 +43,6 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; - 5C4BB4B82B1E7D75007981AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B32B1E7D75007981AA /* libgmp.a */; }; - 5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */; }; - 5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */; }; - 5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B62B1E7D75007981AA /* libffi.a */; }; - 5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -150,6 +145,11 @@ 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 */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -290,11 +290,6 @@ 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; - 5C4BB4B32B1E7D75007981AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a"; sourceTree = ""; }; - 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a"; sourceTree = ""; }; - 5C4BB4B62B1E7D75007981AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; @@ -434,6 +429,11 @@ 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 = ""; }; 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 +511,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */, + 5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */, - 5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */, - 5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */, - 5C4BB4B82B1E7D75007981AA /* libgmp.a 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 */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -579,11 +579,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C4BB4B62B1E7D75007981AA /* libffi.a */, - 5C4BB4B32B1E7D75007981AA /* libgmp.a */, - 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */, - 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */, - 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */, + 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 */, ); path = Libraries; sourceTree = ""; @@ -1502,7 +1502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1524,7 +1524,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1545,7 +1545,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1567,7 +1567,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1626,7 +1626,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1639,7 +1639,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1658,7 +1658,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1671,7 +1671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1690,7 +1690,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1714,7 +1714,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1736,7 +1736,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1760,7 +1760,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 570e982e22..bce98015ce 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4 -android.version_code=162 +android.version_name=5.4.1 +android.version_code=164 -desktop.version_name=5.4 -desktop.version_code=18 +desktop.version_name=5.4.1 +desktop.version_code=19 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From d3059afc9987cbdf46de8534147d49888feacfff Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 9 Dec 2023 21:59:40 +0000 Subject: [PATCH 02/69] ios, core: better notifications processing to avoid contention for database (#3485) * core: forward notifications about message processing (for iOS notifications) * simplexmq * the option to keep database key, to allow re-opening the database * export new init with keepKey and reopen DB api * stop remote ctrl when suspending chat * ios: close/re-open db on suspend/activate * allow activating chat without restoring (for NSE) * update NSE to suspend/activate (does not work) * simplexmq * suspend chat and close database when last notification in the process is processed * stop reading notifications on message markers * replace async stream with cancellable concurrent queue * better synchronization of app and NSE * remove outside of task * remove unused var * whitespace * more debug logging, handle cancelled read after dequeue * comments * more comments --- apps/ios/Shared/AppDelegate.swift | 2 +- apps/ios/Shared/Model/BGManager.swift | 7 +- apps/ios/Shared/Model/ChatModel.swift | 6 +- apps/ios/Shared/Model/SimpleXAPI.swift | 3 +- apps/ios/Shared/Model/SuspendChat.swift | 37 +- apps/ios/Shared/SimpleXApp.swift | 15 +- .../Shared/Views/Call/CallController.swift | 43 +- apps/ios/SimpleX NSE/ConcurrentQueue.swift | 64 +++ .../ios/SimpleX NSE/NotificationService.swift | 393 +++++++++++++++--- apps/ios/SimpleX.xcodeproj/project.pbxproj | 44 +- apps/ios/SimpleXChat/API.swift | 9 +- apps/ios/SimpleXChat/APITypes.swift | 9 +- apps/ios/SimpleXChat/AppGroup.swift | 41 ++ apps/ios/SimpleXChat/ChatTypes.swift | 3 +- apps/ios/SimpleXChat/SimpleX.h | 4 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 79 ++-- src/Simplex/Chat/Archive.hs | 22 +- src/Simplex/Chat/Controller.hs | 26 +- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/Mobile.hs | 44 +- src/Simplex/Chat/Options.hs | 3 +- src/Simplex/Chat/Remote.hs | 6 +- src/Simplex/Chat/Store.hs | 5 +- src/Simplex/Chat/View.hs | 1 + tests/ChatClient.hs | 7 +- tests/MobileTests.hs | 2 +- tests/SchemaDump.hs | 4 +- 29 files changed, 661 insertions(+), 224 deletions(-) create mode 100644 apps/ios/SimpleX NSE/ConcurrentQueue.swift diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 9e6073c10e..b083361a05 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -80,7 +80,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") - if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic { + if m.ntfEnablePeriodic && allowBackgroundRefresh() { receiveMessages(completionHandler) } else { completionHandler(.noData) diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 5ee52407bf..aae1e15fa0 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -15,7 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive" // TCP timeout + 2 sec private let waitForMessages: TimeInterval = 6 -private let bgRefreshInterval: TimeInterval = 450 +private let bgRefreshInterval: TimeInterval = 600 private let maxTimerCount = 9 @@ -55,7 +55,7 @@ class BGManager { } logger.debug("BGManager.handleRefresh") schedule() - if appStateGroupDefault.get().inactive { + if allowBackgroundRefresh() { let completeRefresh = completionHandler { task.setTaskCompleted(success: true) } @@ -92,18 +92,19 @@ class BGManager { DispatchQueue.main.async { let m = ChatModel.shared if (!m.chatInitialized) { + setAppState(.bgRefresh) do { try initializeChat(start: true) } catch let error { fatalError("Failed to start or load chats: \(responseError(error))") } } + activateChat(appState: .bgRefresh) if m.currentUser == nil { completeReceiving("no current user") return } logger.debug("BGManager.receiveMessages: starting chat") - activateChat(appState: .bgRefresh) let cr = ChatReceiver() self.chatReceiver = cr cr.start() diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 13fe0737e2..a7f4bcdbee 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -105,11 +105,13 @@ final class ChatModel: ObservableObject { static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } var ntfEnableLocal: Bool { - notificationMode == .off || ntfEnableLocalGroupDefault.get() + true +// notificationMode == .off || ntfEnableLocalGroupDefault.get() } var ntfEnablePeriodic: Bool { - notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() + notificationMode != .off +// notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() } var activeRemoteCtrl: Bool { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 19030a2842..e2161cbf96 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -228,7 +228,8 @@ func apiStopChat() async throws { } func apiActivateChat() { - let r = chatSendCmdSync(.apiActivateChat) + chatReopenStore() + let r = chatSendCmdSync(.apiActivateChat(restoreChat: true)) if case .cmdOk = r { return } logger.error("apiActivateChat error: \(String(describing: r))") } diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 1c8c32f8b9..3776f9cd42 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -18,6 +18,8 @@ let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds +let activationDelay: Double = 1.5 // seconds + private func _suspendChat(timeout: Int) { // this is a redundant check to prevent logical errors, like the one fixed in this PR let state = appStateGroupDefault.get() @@ -47,8 +49,6 @@ func suspendBgRefresh() { } } -private var terminating = false - func terminateChat() { logger.debug("terminateChat") suspendLockQueue.sync { @@ -64,7 +64,6 @@ func terminateChat() { case .stopped: chatCloseStore() default: - terminating = true // the store will be closed in _chatSuspended when event is received _suspendChat(timeout: terminationTimeout) } @@ -85,14 +84,17 @@ private func _chatSuspended() { if ChatModel.shared.chatRunning == true { ChatReceiver.shared.stop() } - if terminating { - chatCloseStore() + chatCloseStore() +} + +func setAppState(_ appState: AppState) { + suspendLockQueue.sync { + appStateGroupDefault.set(appState) } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") - terminating = false suspendLockQueue.sync { appStateGroupDefault.set(appState) if ChatModel.ok { apiActivateChat() } @@ -101,7 +103,6 @@ func activateChat(appState: AppState = .active) { } func initChatAndMigrate(refreshInvitations: Bool = true) { - terminating = false let m = ChatModel.shared if (!m.chatInitialized) { do { @@ -113,16 +114,32 @@ func initChatAndMigrate(refreshInvitations: Bool = true) { } } -func startChatAndActivate() { - terminating = false +func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ completion: @escaping () -> Void) { logger.debug("DEBUGGING: startChatAndActivate") if ChatModel.shared.chatRunning == true { ChatReceiver.shared.start() logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") } - if .active != appStateGroupDefault.get() { + if .active == appStateGroupDefault.get() { + completion() + } else if nseStateGroupDefault.get().inactive { + activate() + } else { + suspendLockQueue.sync { + appStateGroupDefault.set(.activating) + } + // TODO can be replaced with Mach messenger to notify the NSE to terminate and continue after reply, with timeout + dispatchQueue.asyncAfter(deadline: .now() + activationDelay) { + if appStateGroupDefault.get() == .activating { + activate() + } + } + } + + func activate() { logger.debug("DEBUGGING: startChatAndActivate: before activateChat") activateChat() + completion() logger.debug("DEBUGGING: startChatAndActivate: after activateChat") } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 448ed8b5c4..991cb1a29e 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -77,15 +77,16 @@ struct SimpleXApp: App { case .active: CallController.shared.shouldSuspendChat = false let appState = appStateGroupDefault.get() - startChatAndActivate() - if appState.inactive && chatModel.chatRunning == true { - updateChats() - if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { - updateCallInvitations() + startChatAndActivate { + if appState.inactive && chatModel.chatRunning == true { + updateChats() + if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { + updateCallInvitations() + } } + doAuthenticate = authenticationExpired() + canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() } - doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() default: break } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 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/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..6732bb7665 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -14,68 +14,167 @@ import SimpleXChat let logger = Logger() -let suspendingDelay: UInt64 = 2_000_000_000 +let suspendingDelay: UInt64 = 2_500_000_000 -typealias NtfStream = AsyncStream +let nseSuspendTimeout: Int = 10 +typealias NtfStream = ConcurrentQueue + +// Notifications are delivered via concurrent queues, as they are all received from chat controller in a single loop that +// writes to ConcurrentQueue and when notification is processed, the instance of Notification service extension reads from the queue. +// One queue per connection (entity) is used. +// The concurrent queues allow for read cancellation, to ensure that notifications are not lost in case the next the current thread completes +// before expected notification is read (multiple notifications can be expected, because one notification can be delivered for several messages. actor PendingNtfs { static let shared = PendingNtfs() private var ntfStreams: [String: NtfStream] = [:] - private var ntfConts: [String: NtfStream.Continuation] = [:] - func createStream(_ id: String) { - logger.debug("PendingNtfs.createStream: \(id, privacy: .public)") - if ntfStreams.index(forKey: id) == nil { - ntfStreams[id] = AsyncStream { cont in - ntfConts[id] = cont - logger.debug("PendingNtfs.createStream: store continuation") - } + func createStream(_ id: String) async { + logger.debug("NotificationService PendingNtfs.createStream: \(id, privacy: .public)") + if ntfStreams[id] == nil { + ntfStreams[id] = ConcurrentQueue() + logger.debug("NotificationService PendingNtfs.createStream: created ConcurrentQueue") } } - func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1, showNotifications: Bool) async { - logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)") + func readStream(_ id: String, for nse: NotificationService, ntfInfo: NtfMessages) async { + logger.debug("NotificationService PendingNtfs.readStream: \(id, privacy: .public) \(ntfInfo.ntfMessages.count, privacy: .public)") + if !ntfInfo.user.showNotifications { + nse.setBestAttemptNtf(.empty) + } if let s = ntfStreams[id] { - logger.debug("PendingNtfs.readStream: has stream") - var rcvCount = max(1, msgCount) - for await ntf in s { - nse.setBestAttemptNtf(showNotifications ? ntf : .empty) - rcvCount -= 1 - if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break } + logger.debug("NotificationService PendingNtfs.readStream: has stream") + var expected = Set(ntfInfo.ntfMessages.map { $0.msgId }) + logger.debug("NotificationService PendingNtfs.readStream: expecting: \(expected, privacy: .public)") + var readCancelled = false + var dequeued: DequeueElement? + nse.cancelRead = { + readCancelled = true + if let elementId = dequeued?.elementId { + s.cancelDequeue(elementId) + } } - logger.debug("PendingNtfs.readStream: exiting") + while !readCancelled { + dequeued = s.dequeue() + if let ntf = await dequeued?.task.value { + if readCancelled { + logger.debug("NotificationService PendingNtfs.readStream: read cancelled, put ntf to queue front") + s.frontEnqueue(ntf) + break + } else if case let .msgInfo(info) = ntf { + let found = expected.remove(info.msgId) + if found != nil { + logger.debug("NotificationService PendingNtfs.readStream: msgInfo, last: \(expected.isEmpty, privacy: .public)") + if expected.isEmpty { break } + } else if let msgTs = ntfInfo.msgTs, info.msgTs > msgTs { + logger.debug("NotificationService PendingNtfs.readStream: unexpected msgInfo") + s.frontEnqueue(ntf) + break + } + } else if ntfInfo.user.showNotifications { + logger.debug("NotificationService PendingNtfs.readStream: setting best attempt") + nse.setBestAttemptNtf(ntf) + if ntf.isCallInvitation { break } + } + } else { + break + } + } + nse.cancelRead = nil + logger.debug("NotificationService PendingNtfs.readStream: exiting") } } - func writeStream(_ id: String, _ ntf: NSENotification) { - logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)") - if let cont = ntfConts[id] { - logger.debug("PendingNtfs.writeStream: writing ntf") - cont.yield(ntf) + func writeStream(_ id: String, _ ntf: NSENotification) async { + logger.debug("NotificationService PendingNtfs.writeStream: \(id, privacy: .public)") + if let s = ntfStreams[id] { + logger.debug("NotificationService PendingNtfs.writeStream: writing ntf") + s.enqueue(ntf) + } + } +} + +// The current implementation assumes concurrent notification delivery and uses semaphores +// to process only one notification per connection (entity) at a time. +class NtfStreamSemaphores { + static let shared = NtfStreamSemaphores() + private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-semaphores.lock") + private var semaphores: [String: DispatchSemaphore] = [:] + + func waitForStream(_ id: String) { + streamSemaphore(id, value: 0)?.wait() + } + + func signalStreamReady(_ id: String) { + streamSemaphore(id, value: 1)?.signal() + } + + // this function returns nil if semaphore is just created, so passed value shoud be coordinated with the desired end value of the semaphore + private func streamSemaphore(_ id: String, value: Int) -> DispatchSemaphore? { + NtfStreamSemaphores.queue.sync { + if let s = semaphores[id] { + return s + } else { + semaphores[id] = DispatchSemaphore(value: value) + return nil + } } } } enum NSENotification { - case nse(notification: UNMutableNotificationContent) - case callkit(invitation: RcvCallInvitation) + case nse(UNMutableNotificationContent) + case callkit(RcvCallInvitation) case empty + case msgInfo(NtfMsgInfo) - var categoryIdentifier: String? { + var isCallInvitation: Bool { switch self { - case let .nse(ntf): return ntf.categoryIdentifier - case .callkit: return ntfCategoryCallInvitation - case .empty: return nil + case let .nse(ntf): ntf.categoryIdentifier == ntfCategoryCallInvitation + case .callkit: true + case .empty: false + case .msgInfo: false } } } +// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid +// background crashes and contention for database with the application (both UI and background fetch triggered either on schedule +// or when background notification is received. +class NSEThreads { + static let shared = NSEThreads() + private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") + private var threads: Set = [] + + func startThread() -> UUID { + NSEThreads.queue.sync { + let (_, t) = threads.insert(UUID()) + return t + } + } + + func endThread(_ t: UUID) -> Bool { + NSEThreads.queue.sync { + let t_ = threads.remove(t) + return t_ != nil && threads.isEmpty + } + } +} + +// Notification service extension creates a new instance of the class and calls didReceive for each notification. +// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never +// more than one process for notification service extension. +// Soon after notification service delivers the last notification it is either suspended or terminated. class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptNtf: NSENotification? var badgeCount: Int = 0 + var threadId: UUID? + var receiveEntityId: String? + var cancelRead: (() -> Void)? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + threadId = NSEThreads.shared.startThread() logger.debug("DEBUGGING: NotificationService.didReceive") if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent { setBestAttemptNtf(ntf) @@ -93,7 +192,7 @@ class NotificationService: UNNotificationServiceExtension { setBadgeCount() Task { var state = appState - for _ in 1...5 { + for _ in 1...6 { _ = try await Task.sleep(nanoseconds: suspendingDelay) state = appStateGroupDefault.get() if state == .suspended || state != .suspending { break } @@ -123,24 +222,28 @@ class NotificationService: UNNotificationServiceExtension { let encNtfInfo = ntfData["message"] as? String, let dbStatus = startChat() { if case .ok = dbStatus, - let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { - logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") - if let connEntity = ntfMsgInfo.connEntity { + let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo), privacy: .public)") + if let connEntity = ntfInfo.connEntity_ { setBestAttemptNtf( - ntfMsgInfo.ntfsEnabled - ? .nse(notification: createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + ntfInfo.ntfsEnabled + ? .nse(createConnectionEventNtf(ntfInfo.user, connEntity)) : .empty ) if let id = connEntity.id { - Task { - logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") - await PendingNtfs.shared.createStream(id) - await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count, showNotifications: ntfMsgInfo.user.showNotifications) - deliverBestAttemptNtf() + receiveEntityId = id + NtfStreamSemaphores.shared.waitForStream(id) + if receiveEntityId != nil { + Task { + logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") + await PendingNtfs.shared.createStream(id) + await PendingNtfs.shared.readStream(id, for: self, ntfInfo: ntfInfo) + deliverBestAttemptNtf() + } } + return } } - return } else { setBestAttemptNtf(createErrorNtf(dbStatus)) } @@ -159,14 +262,14 @@ class NotificationService: UNNotificationServiceExtension { } func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) { - setBestAttemptNtf(.nse(notification: ntf)) + setBestAttemptNtf(.nse(ntf)) } func setBestAttemptNtf(_ ntf: NSENotification) { logger.debug("NotificationService.setBestAttemptNtf") if case let .nse(notification) = ntf { notification.badge = badgeCount as NSNumber - bestAttemptNtf = .nse(notification: notification) + bestAttemptNtf = .nse(notification) } else { bestAttemptNtf = ntf } @@ -174,9 +277,33 @@ class NotificationService: UNNotificationServiceExtension { private func deliverBestAttemptNtf() { logger.debug("NotificationService.deliverBestAttemptNtf") + if let cancel = cancelRead { + cancelRead = nil + cancel() + } + if let id = receiveEntityId { + receiveEntityId = nil + NtfStreamSemaphores.shared.signalStreamReady(id) + } + if let t = threadId { + threadId = nil + if NSEThreads.shared.endThread(t) { + suspendChat(nseSuspendTimeout) + } + } if let handler = contentHandler, let ntf = bestAttemptNtf { + contentHandler = nil + bestAttemptNtf = nil + let deliver: (UNMutableNotificationContent?) -> Void = { ntf in + let useNtf = if let ntf = ntf { + appStateGroupDefault.get().running ? UNMutableNotificationContent() : ntf + } else { + UNMutableNotificationContent() + } + handler(useNtf) + } switch ntf { - case let .nse(content): handler(content) + case let .nse(content): deliver(content) case let .callkit(invitation): CXProvider.reportNewIncomingVoIPPushPayload([ "displayName": invitation.contact.displayName, @@ -184,33 +311,71 @@ class NotificationService: UNNotificationServiceExtension { "media": invitation.callType.media.rawValue ]) { error in if error == nil { - handler(UNMutableNotificationContent()) + deliver(nil) } else { - logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") - handler(createCallInvitationNtf(invitation)) + logger.debug("NotificationService reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") + deliver(createCallInvitationNtf(invitation)) } } - case .empty: handler(UNMutableNotificationContent()) + case .empty: deliver(nil) // used to mute notifications that did not unsubscribe yet + case .msgInfo: deliver(nil) // unreachable, the best attempt is never set to msgInfo } - bestAttemptNtf = nil } } } -var chatStarted = false -var networkConfig: NetCfg = getNetCfg() -var xftpConfig: XFTPFileConfig? = getXFTPCfg() +class NSEChatState { + static let shared = NSEChatState() + private var value_ = NSEState.created + var value: NSEState { + value_ + } + + func set(_ state: NSEState) { + nseStateGroupDefault.set(state) + value_ = state + } + + init() { + set(.created) + } +} + +var receiverStarted = false +let startLock = DispatchSemaphore(value: 1) +let suspendLock = DispatchSemaphore(value: 1) +var networkConfig: NetCfg = getNetCfg() +let xftpConfig: XFTPFileConfig? = getXFTPCfg() + +// startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller +// Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active func startChat() -> DBMigrationResult? { + logger.debug("NotificationService: startChat") + if case .active = NSEChatState.shared.value { return .ok } + + startLock.wait() + defer { startLock.signal() } + + return switch NSEChatState.shared.value { + case .created: doStartChat() + case .active: .ok + case .suspending: activateChat() + case .suspended: activateChat() + } +} + +func doStartChat() -> DBMigrationResult? { + logger.debug("NotificationService: doStartChat") hs_init(0, nil) - if chatStarted { return .ok } let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation()) if dbStatus != .ok { resetChatCtrl() + NSEChatState.shared.set(.created) return dbStatus } if let user = apiGetActiveUser() { - logger.debug("active user \(String(describing: user))") + logger.debug("NotificationService active user \(String(describing: user))") do { try setNetworkConfig(networkConfig) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) @@ -218,32 +383,102 @@ func startChat() -> DBMigrationResult? { try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) let justStarted = try apiStartChat() - chatStarted = true + NSEChatState.shared.set(.active) if justStarted { chatLastStartGroupDefault.set(Date.now) - Task { await receiveMessages() } + Task { + if !receiverStarted { + receiverStarted = true + await receiveMessages() + } + } } return .ok } catch { logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") } } else { - logger.debug("no active user") + logger.debug("NotificationService: no active user") } return nil } +func activateChat() -> DBMigrationResult? { + logger.debug("NotificationService: activateChat") + let state = NSEChatState.shared.value + NSEChatState.shared.set(.active) + if apiActivateChat() { + logger.debug("NotificationService: activateChat: after apiActivateChat") + return .ok + } else { + NSEChatState.shared.set(state) + return nil + } +} + +// suspendChat uses semaphore suspendLock to ensure that only one suspension can happen. +func suspendChat(_ timeout: Int) { + logger.debug("NotificationService: suspendChat") + let state = NSEChatState.shared.value + if !state.canSuspend { + logger.error("NotificationService suspendChat called, current state: \(state.rawValue, privacy: .public)") + } else { + suspendLock.wait() + defer { suspendLock.signal() } + + NSEChatState.shared.set(.suspending) + if apiSuspendChat(timeoutMicroseconds: timeout * 1000000) { + logger.debug("NotificationService: activateChat: after apiActivateChat") + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: chatSuspended) + } else { + NSEChatState.shared.set(state) + } + } +} + +func chatSuspended() { + logger.debug("NotificationService chatSuspended") + if case .suspending = NSEChatState.shared.value { + NSEChatState.shared.set(.suspended) + chatCloseStore() + } +} + +// A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state +// If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will no be received. func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { - updateNetCfg() + switch NSEChatState.shared.value { + case .created: await delayWhenInactive() + case .active: + if appStateGroupDefault.get().running { + suspendChat(nseSuspendTimeout) + await delayWhenInactive() + } else { + updateNetCfg() + await receiveMsg() + } + case .suspending: await receiveMsg() + case .suspended: await delayWhenInactive() + } + } + + func receiveMsg() async { if let msg = await chatRecvMsg() { + logger.debug("NotificationService receiveMsg: message") if let (id, ntf) = await receivedMsgNtf(msg) { + logger.debug("NotificationService receiveMsg: notification") await PendingNtfs.shared.createStream(id) await PendingNtfs.shared.writeStream(id, ntf) } } } + + func delayWhenInactive() async { + logger.debug("NotificationService delayWhenInactive") + _ = try? await Task.sleep(nanoseconds: 1000_000000) + } } func chatRecvMsg() async -> ChatResponse? { @@ -257,14 +492,14 @@ private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { - logger.debug("NotificationService processReceivedMsg: \(res.responseType)") + logger.debug("NotificationService receivedMsgNtf: \(res.responseType, privacy: .public)") switch res { case let .contactConnected(user, contact, _): - return (contact.id, .nse(notification: createContactConnectedNtf(user, contact))) + return (contact.id, .nse(createContactConnectedNtf(user, contact))) // case let .contactConnecting(contact): // TODO profile update case let .receivedContactRequest(user, contactRequest): - return (UserContact(contactRequest: contactRequest).id, .nse(notification: createContactRequestNtf(user, contactRequest))) + return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest))) case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem @@ -274,7 +509,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { if let file = cItem.autoReceiveFile() { cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem } - let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty + let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty return cItem.showNotification ? (aChatItem.chatId, ntf) : nil case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) @@ -292,10 +527,15 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit return ( invitation.contact.id, - useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation)) + useCallKit() ? .callkit(invitation) : .nse(createCallInvitationNtf(invitation)) ) + case let .ntfMessage(_, connEntity, ntfMessage): + return if let id = connEntity.id { (id, .msgInfo(ntfMessage)) } else { nil } + case .chatSuspended: + chatSuspended() + return nil default: - logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") + logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)") return nil } } @@ -334,6 +574,21 @@ func apiStartChat() throws -> Bool { } } +func apiActivateChat() -> Bool { + chatReopenStore() + let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) + if case .cmdOk = r { return true } + logger.error("NotificationService apiActivateChat error: \(String(describing: r))") + return false +} + +func apiSuspendChat(timeoutMicroseconds: Int) -> Bool { + let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + if case .cmdOk = r { return true } + logger.error("NotificationService apiSuspendChat error: \(String(describing: r))") + return false +} + func apiSetTempFolder(tempFolder: String) throws { let r = sendSimpleXCmd(.setTempFolder(tempFolder: tempFolder)) if case .cmdOk = r { return } @@ -364,8 +619,8 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { return nil } let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r, let user = user { - return NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + if case let .ntfMessages(user, connEntity_, msgTs, ntfMessages) = r, let user = user { + return NtfMessages(user: user, connEntity_: connEntity_, msgTs: msgTs, ntfMessages: ntfMessages) } else if case let .chatCmdError(_, error) = r { logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") } else { @@ -405,11 +660,11 @@ func setNetworkConfig(_ cfg: NetCfg) throws { struct NtfMessages { var user: User - var connEntity: ConnectionEntity? + var connEntity_: ConnectionEntity? var msgTs: Date? var ntfMessages: [NtfMsgInfo] var ntfsEnabled: Bool { - user.showNotifications && (connEntity?.ntfsEnabled ?? false) + user.showNotifications && (connEntity_?.ntfsEnabled ?? false) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a3b7580a1a..7f6a1a2524 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,6 +43,11 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; + 5C4BB4CC2B20E177007981AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C72B20E176007981AA /* libffi.a */; }; + 5C4BB4CD2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */; }; + 5C4BB4CE2B20E177007981AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C92B20E177007981AA /* libgmpxx.a */; }; + 5C4BB4CF2B20E177007981AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4CA2B20E177007981AA /* libgmp.a */; }; + 5C4BB4D02B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -145,11 +150,7 @@ 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; - 5CF937182B22552700E1D781 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937132B22552700E1D781 /* libffi.a */; }; - 5CF937192B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */; }; - 5CF9371A2B22552700E1D781 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937152B22552700E1D781 /* libgmp.a */; }; - 5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937162B22552700E1D781 /* libgmpxx.a */; }; - 5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */; }; + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -290,6 +291,11 @@ 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; + 5C4BB4C72B20E176007981AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a"; sourceTree = ""; }; + 5C4BB4C92B20E177007981AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C4BB4CA2B20E177007981AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; @@ -429,11 +435,7 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; - 5CF937132B22552700E1D781 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a"; sourceTree = ""; }; - 5CF937152B22552700E1D781 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF937162B22552700E1D781 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a"; sourceTree = ""; }; + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -511,12 +513,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */, - 5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */, + 5C4BB4D02B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CF9371A2B22552700E1D781 /* libgmp.a in Frameworks */, - 5CF937182B22552700E1D781 /* libffi.a in Frameworks */, - 5CF937192B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a in Frameworks */, + 5C4BB4CE2B20E177007981AA /* libgmpxx.a in Frameworks */, + 5C4BB4CC2B20E177007981AA /* libffi.a in Frameworks */, + 5C4BB4CD2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a in Frameworks */, + 5C4BB4CF2B20E177007981AA /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -579,11 +581,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF937132B22552700E1D781 /* libffi.a */, - 5CF937152B22552700E1D781 /* libgmp.a */, - 5CF937162B22552700E1D781 /* libgmpxx.a */, - 5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */, - 5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */, + 5C4BB4C72B20E176007981AA /* libffi.a */, + 5C4BB4CA2B20E177007981AA /* libgmp.a */, + 5C4BB4C92B20E177007981AA /* libgmpxx.a */, + 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */, + 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */, ); path = Libraries; sourceTree = ""; @@ -788,6 +790,7 @@ isa = PBXGroup; children = ( 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, @@ -1259,6 +1262,7 @@ files = ( 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 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..c03951e609 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -27,7 +27,7 @@ public enum ChatCommand { case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) case startChat(subscribe: Bool, expire: Bool, xftp: Bool) case apiStopChat - case apiActivateChat + case apiActivateChat(restoreChat: Bool) case apiSuspendChat(timeoutMicroseconds: Int) case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) @@ -156,7 +156,7 @@ public enum ChatCommand { case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" case let .startChat(subscribe, expire, xftp): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire)) xftp=\(onOff(xftp))" case .apiStopChat: return "/_stop" - case .apiActivateChat: return "/_app activate" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" @@ -604,7 +604,8 @@ public enum ChatResponse: Decodable, Error { case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) - case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) @@ -751,6 +752,7 @@ public enum ChatResponse: Decodable, Error { case .ntfTokenStatus: return "ntfTokenStatus" case .ntfToken: return "ntfToken" case .ntfMessages: return "ntfMessages" + case .ntfMessage: return "ntfMessage" case .contactConnectionDeleted: return "contactConnectionDeleted" case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlFound: return "remoteCtrlFound" @@ -898,6 +900,7 @@ public enum ChatResponse: Decodable, Error { case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index cc61fae53f..eebdefb09d 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI let GROUP_DEFAULT_APP_STATE = "appState" +let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" @@ -68,11 +69,21 @@ public func registerGroupDefaults() { public enum AppState: String { case active + case activating case bgRefresh case suspending case suspended case stopped + public var running: Bool { + switch self { + case .active: return true + case .activating: return true + case .bgRefresh: return true + default: return false + } + } + public var inactive: Bool { switch self { case .suspending: return true @@ -84,12 +95,32 @@ public enum AppState: String { public var canSuspend: Bool { switch self { case .active: return true + case .activating: return true case .bgRefresh: return true default: return false } } } +public enum NSEState: String { + case created + case active + case suspending + case suspended + + public var inactive: Bool { + switch self { + case .created: true + case .suspended: true + default: false + } + } + + public var canSuspend: Bool { + if case .active = self { true } else { false } + } +} + public enum DBContainer: String { case documents case group @@ -101,6 +132,16 @@ public let appStateGroupDefault = EnumDefault( withDefault: .active ) +public let nseStateGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NSE_STATE, + withDefault: .created +) + +public func allowBackgroundRefresh() -> Bool { + appStateGroupDefault.get().inactive && nseStateGroupDefault.get().inactive +} + public let dbContainerGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_DB_CONTAINER, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 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/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/cabal.project b/cabal.project index 3de5197d6b..7e8dee6a0d 100644 --- a/cabal.project +++ b/cabal.project @@ -11,7 +11,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a860936072172e261480fa6bdd95203976e366b2 + tag: 146fb1a6a02a8cadbd3a476089646b57bdd6659c source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fc57b60045..f3bd4d5e0c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a860936072172e261480fa6bdd95203976e366b2" = "16rwnh5zzphmw8d8ypvps6xjvzbmf5ljr6zzy15gz2g0jyh7hd91"; + "https://github.com/simplex-chat/simplexmq.git"."146fb1a6a02a8cadbd3a476089646b57bdd6659c" = "0pbj3k8nygc4dpqhblpvj4rs5c5nh064qmfx3d4zyz11g1n5vpan"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aa489e9a9b..eb322bcd9e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -9,7 +9,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -28,6 +27,8 @@ import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (bimap, first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -50,7 +51,7 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) -import Data.Time.Clock.System (SystemTime, systemToUTCTime) +import Data.Time.Clock.System (systemToUTCTime) import Data.Word (Word16, Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive @@ -191,10 +192,10 @@ smallGroupsRcptsMemLimit = 20 logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: FilePath -> String -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase filePrefix key confirmMigrations = runExceptT $ do - chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key confirmMigrations - agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key confirmMigrations +createChatDatabase :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) +createChatDatabase filePrefix key keepKey confirmMigrations = runExceptT $ do + chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key keepKey confirmMigrations + agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController @@ -538,16 +539,18 @@ processChatCommand = \case APIStopChat -> do ask >>= stopChatController pure CRChatStopped - APIActivateChat -> withUser $ \_ -> do - restoreCalls + APIActivateChat restoreChat -> withUser $ \_ -> do + when restoreChat restoreCalls withAgent foregroundAgent - users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers - void . forkIO $ subscribeUsers True users - void . forkIO $ startFilesToReceive users - setAllExpireCIFlags True + when restoreChat $ do + users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers + void . forkIO $ subscribeUsers True users + void . forkIO $ startFilesToReceive users + setAllExpireCIFlags True ok_ APISuspendChat t -> do setAllExpireCIFlags False + stopRemoteCtrl withAgent (`suspendAgent` t) ok_ ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers False >> ok_ @@ -1172,16 +1175,13 @@ processChatCommand = \case APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ APIGetNtfMessage nonce encNtfInfo -> withUser $ \_ -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo - let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs - getMsgTs :: SMP.NMsgMeta -> SystemTime - getMsgTs SMP.NMsgMeta {msgTs} = msgTs - msgTs' = systemToUTCTime . getMsgTs <$> ntfMsgMeta + let msgTs' = systemToUTCTime . (\SMP.NMsgMeta {msgTs} -> msgTs) <$> ntfMsgMeta agentConnId = AgentConnId ntfConnId user_ <- withStore' (`getUserByAConnId` agentConnId) - connEntity <- + connEntity_ <- pure user_ $>>= \user -> withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) - pure CRNtfMessages {user_, connEntity, msgTs = msgTs', ntfMessages} + pure CRNtfMessages {user_, connEntity_, msgTs = msgTs', ntfMessages = map ntfMsgInfo msgs} APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do ChatConfig {defaultServers} <- asks config servers <- withStore' (`getProtocolServers` user) @@ -3227,23 +3227,24 @@ processAgentMsgRcvFile _corrId aFileId msg = toView $ CRRcvFileError user ci e processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () -processAgentMessageConn user _ agentConnId END = - withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case - RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct - entity -> toView $ CRSubscriptionEnd user entity processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus - case entity of - RcvDirectMsgConnection conn contact_ -> - processDirectMessage agentMessage entity conn contact_ - RcvGroupMsgConnection conn gInfo m -> - processGroupMessage agentMessage entity conn gInfo m - RcvFileConnection conn ft -> - processRcvFileConn agentMessage entity conn ft - SndFileConnection conn ft -> - processSndFileConn agentMessage entity conn ft - UserContactConnection conn uc -> - processUserContactRequest agentMessage entity conn uc + case agentMessage of + END -> case entity of + RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct + _ -> toView $ CRSubscriptionEnd user entity + MSGNTF smpMsgInfo -> toView $ CRNtfMessage user entity $ ntfMsgInfo smpMsgInfo + _ -> case entity of + RcvDirectMsgConnection conn contact_ -> + processDirectMessage agentMessage entity conn contact_ + RcvGroupMsgConnection conn gInfo m -> + processGroupMessage agentMessage entity conn gInfo m + RcvFileConnection conn ft -> + processRcvFileConn agentMessage entity conn ft + SndFileConnection conn ft -> + processSndFileConn agentMessage entity conn ft + UserContactConnection conn uc -> + processUserContactRequest agentMessage entity conn uc where updateConnStatus :: ConnectionEntity -> m ConnectionEntity updateConnStatus acEntity = case agentMsgConnStatus agentMessage of @@ -5959,7 +5960,8 @@ chatCommandP = "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP), "/_start" $> StartChat True True True, "/_stop" $> APIStopChat, - "/_app activate" $> APIActivateChat, + "/_app activate restore=" *> (APIActivateChat <$> onOffP), + "/_app activate" $> APIActivateChat True, "/_app suspend " *> (APISuspendChat <$> A.decimal), "/_resubscribe all" $> ResubscribeAllConnections, "/_temp_folder " *> (SetTempFolder <$> filePath), @@ -5974,9 +5976,9 @@ chatCommandP = "/_db import " *> (APIImportArchive <$> jsonP), "/_db delete" $> APIDeleteStorage, "/_db encryption " *> (APIStorageEncryption <$> jsonP), - "/db encrypt " *> (APIStorageEncryption . DBEncryptionConfig "" <$> dbKeyP), - "/db key " *> (APIStorageEncryption <$> (DBEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), - "/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP), + "/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP), + "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), + "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, @@ -6317,7 +6319,8 @@ chatCommandP = A.decimal ] dbKeyP = nonEmptyKey <$?> strP - nonEmptyKey k@(DBEncryptionKey s) = if null s then Left "empty key" else Right k + nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k + dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} autoAcceptP = ifM onOffP diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 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 fb2ff89a28..580d6d19dd 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -29,6 +29,8 @@ import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) @@ -39,7 +41,9 @@ import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.String import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1) import Data.Time (NominalDiffTime, UTCTime) +import Data.Time.Clock.System (systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) @@ -69,7 +73,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -230,7 +234,7 @@ data ChatCommand | DeleteUser UserName Bool (Maybe UserPwd) | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool} | APIStopChat - | APIActivateChat + | APIActivateChat {restoreChat :: Bool} | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections | SetTempFolder FilePath @@ -453,7 +457,7 @@ allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/For allowRemoteCommand = \case StartChat {} -> False APIStopChat -> False - APIActivateChat -> False + APIActivateChat _ -> False APISuspendChat _ -> False QuitChat -> False SetTempFolder _ -> False @@ -654,7 +658,8 @@ data ChatResponse | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} - | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} @@ -825,17 +830,17 @@ deriving instance Show AUserProtoServers data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show) -data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey} +data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey, keepKey :: Maybe Bool} deriving (Show) -newtype DBEncryptionKey = DBEncryptionKey String +newtype DBEncryptionKey = DBEncryptionKey ScrubbedBytes deriving (Show) instance IsString DBEncryptionKey where fromString = parseString $ parseAll strP instance StrEncoding DBEncryptionKey where - strEncode (DBEncryptionKey s) = B.pack s - strP = DBEncryptionKey . B.unpack <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E) + strEncode (DBEncryptionKey s) = BA.convert s + strP = DBEncryptionKey . BA.convert <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E) instance FromJSON DBEncryptionKey where parseJSON = strParseJSON "DBEncryptionKey" @@ -900,9 +905,12 @@ data XFTPFileConfig = XFTPFileConfig defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} +data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) +ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo +ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs} + crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 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/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/View.hs b/src/Simplex/Chat/View.hs index 7eeddefbcd..131b047739 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 824e6be0a0..6c2e8c080d 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 f517d13df1..d84572abaa 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 From e8016adfdc707a35343e30157f2eef521513d654 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Dec 2023 17:47:44 +0000 Subject: [PATCH 03/69] simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 7e8dee6a0d..b2ec38d28b 100644 --- a/cabal.project +++ b/cabal.project @@ -11,7 +11,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 146fb1a6a02a8cadbd3a476089646b57bdd6659c + tag: 64bc203c7f827b99d846dbc368e43c278e4546d2 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index f3bd4d5e0c..24256824e5 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."146fb1a6a02a8cadbd3a476089646b57bdd6659c" = "0pbj3k8nygc4dpqhblpvj4rs5c5nh064qmfx3d4zyz11g1n5vpan"; + "https://github.com/simplex-chat/simplexmq.git"."64bc203c7f827b99d846dbc368e43c278e4546d2" = "1xz3lw5dsh7gm136jzwmsbqjigsqsnjlbhg38mpc6lm586lg8f9x"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; From f65b8a9e78384421968f608473253ee55a31e62d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Dec 2023 12:26:45 +0000 Subject: [PATCH 04/69] core: mark all user messages read (#3530) --- src/Simplex/Chat.hs | 4 ++++ src/Simplex/Chat/Controller.hs | 2 ++ src/Simplex/Chat/Store/Direct.hs | 9 +++++++++ tests/ChatTests/Direct.hs | 2 ++ 4 files changed, 17 insertions(+) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aa489e9a9b..6e3d299404 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -937,6 +937,8 @@ processChatCommand = \case throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed") when (add && length rs >= maxMsgReactions) $ throwChatError (CECommandError "too many reactions") + APIUserRead userId -> withUserId userId $ \user -> withStore' (`setUserChatsRead` user) >> ok user + UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withStore $ \db -> getUserByContactId db chatId @@ -5989,6 +5991,8 @@ chatCommandP = "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), + "/_read user " *> (APIUserRead <$> A.decimal), + "/read user" $> UserRead, "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), "/_delete " *> (APIDeleteChat <$> chatRefP <*> (A.space *> "notify=" *> onOffP <|> pure True)), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fb2ff89a28..c3bf84b33e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -256,6 +256,8 @@ data ChatCommand | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} + | APIUserRead UserId + | UserRead | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIChatUnread ChatRef Bool | APIDeleteChat ChatRef Bool -- `notify` flag is only applied to direct chats diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 67044d81a7..0046bc990d 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -43,6 +43,7 @@ module Simplex.Chat.Store.Direct deletePCCIncognitoProfile, updateContactUsed, updateContactUnreadChat, + setUserChatsRead, updateContactStatus, updateGroupUnreadChat, setConnectionVerified, @@ -78,6 +79,7 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -392,6 +394,13 @@ updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId) +setUserChatsRead :: DB.Connection -> User -> IO () +setUserChatsRead db User {userId} = do + updatedAt <- getCurrentTime + DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) + DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) + DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ?" (CISRcvRead, updatedAt, userId, CISRcvNew) + updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do currentTs <- getCurrentTime diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index d7c8ff4586..7d299e296d 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -175,6 +175,8 @@ testAddContact = versionTestMatrix2 runTestAddContact bob #$> ("/_read chat @2 from=1 to=100", id, "ok") alice #$> ("/_read chat @2", id, "ok") bob #$> ("/_read chat @2", id, "ok") + alice #$> ("/read user", id, "ok") + alice #$> ("/_read user 1", id, "ok") testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () testDuplicateContactsSeparate = From 79a954336c6c3e6b797ffc78f476f51b51847caa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Dec 2023 12:34:56 +0000 Subject: [PATCH 05/69] ios: communication between NSE and app via files (#3533) * ios: communication between NSE and app via files * clean up * better concurrency --- apps/ios/Shared/Model/NSESubscriber.swift | 83 +++++++++++ apps/ios/Shared/Model/SuspendChat.swift | 57 +++++--- apps/ios/Shared/SimpleXApp.swift | 2 +- .../Shared/Views/Database/DatabaseView.swift | 6 +- .../Views/LocalAuth/LocalAuthView.swift | 2 +- .../ios/SimpleX NSE/NotificationService.swift | 136 +++++++++++++----- apps/ios/SimpleX.xcodeproj/project.pbxproj | 48 ++++--- apps/ios/SimpleXChat/AppGroup.swift | 11 +- .../SimpleXChat/SharedFileSubscriber.swift | 99 +++++++++++++ 9 files changed, 361 insertions(+), 83 deletions(-) create mode 100644 apps/ios/Shared/Model/NSESubscriber.swift create mode 100644 apps/ios/SimpleXChat/SharedFileSubscriber.swift 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/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 3776f9cd42..7ced1351a7 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -12,26 +12,24 @@ import SimpleXChat private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") -let appSuspendTimeout: Int = 15 // seconds - let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds -let activationDelay: Double = 1.5 // seconds +let activationDelay: TimeInterval = 1.5 private func _suspendChat(timeout: Int) { // this is a redundant check to prevent logical errors, like the one fixed in this PR - let state = appStateGroupDefault.get() + let state = AppChatState.shared.value if !state.canSuspend { logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)") } else if ChatModel.ok { - appStateGroupDefault.set(.suspending) + AppChatState.shared.set(.suspending) apiSuspendChat(timeoutMicroseconds: timeout * 1000000) let endTask = beginBGTask(chatSuspended) DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) } else { - appStateGroupDefault.set(.suspended) + AppChatState.shared.set(.suspended) } } @@ -43,7 +41,7 @@ func suspendChat() { func suspendBgRefresh() { suspendLockQueue.sync { - if case .bgRefresh = appStateGroupDefault.get() { + if case .bgRefresh = AppChatState.shared.value { _suspendChat(timeout: bgSuspendTimeout) } } @@ -52,7 +50,7 @@ func suspendBgRefresh() { func terminateChat() { logger.debug("terminateChat") suspendLockQueue.sync { - switch appStateGroupDefault.get() { + switch AppChatState.shared.value { case .suspending: // suspend instantly if already suspending _chatSuspended() @@ -72,7 +70,7 @@ func terminateChat() { func chatSuspended() { suspendLockQueue.sync { - if case .suspending = appStateGroupDefault.get() { + if case .suspending = AppChatState.shared.value { _chatSuspended() } } @@ -80,7 +78,7 @@ func chatSuspended() { private func _chatSuspended() { logger.debug("_chatSuspended") - appStateGroupDefault.set(.suspended) + AppChatState.shared.set(.suspended) if ChatModel.shared.chatRunning == true { ChatReceiver.shared.stop() } @@ -89,14 +87,14 @@ private func _chatSuspended() { func setAppState(_ appState: AppState) { suspendLockQueue.sync { - appStateGroupDefault.set(appState) + AppChatState.shared.set(appState) } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") suspendLockQueue.sync { - appStateGroupDefault.set(appState) + AppChatState.shared.set(appState) if ChatModel.ok { apiActivateChat() } logger.debug("DEBUGGING: activateChat: after apiActivateChat") } @@ -120,17 +118,22 @@ func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ c ChatReceiver.shared.start() logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") } - if .active == appStateGroupDefault.get() { + if .active == AppChatState.shared.value { completion() } else if nseStateGroupDefault.get().inactive { activate() } else { - suspendLockQueue.sync { - appStateGroupDefault.set(.activating) - } - // TODO can be replaced with Mach messenger to notify the NSE to terminate and continue after reply, with timeout - dispatchQueue.asyncAfter(deadline: .now() + activationDelay) { - if appStateGroupDefault.get() == .activating { + // setting app state to "activating" to notify NSE that it should suspend + setAppState(.activating) + waitNSESuspended(timeout: 10, dispatchQueue: dispatchQueue) { ok in + if !ok { + // if for some reason NSE failed to suspend, + // e.g., it crashed previously without setting its state to "suspended", + // set it to "suspended" state anyway, so that next time app + // does not have to wait when activating. + nseStateGroupDefault.set(.suspended) + } + if AppChatState.shared.value == .activating { activate() } } @@ -143,3 +146,19 @@ func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ c logger.debug("DEBUGGING: startChatAndActivate: after activateChat") } } + +// appStateGroupDefault must not be used in the app directly, only via this singleton +class AppChatState { + static let shared = AppChatState() + private var value_ = appStateGroupDefault.get() + + var value: AppState { + value_ + } + + func set(_ state: AppState) { + appStateGroupDefault.set(state) + sendAppState(state) + value_ = state + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 991cb1a29e..d75738d04e 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -76,7 +76,7 @@ struct SimpleXApp: App { NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.shouldSuspendChat = false - let appState = appStateGroupDefault.get() + let appState = AppChatState.shared.value startChatAndActivate { if appState.inactive && chatModel.chatRunning == true { updateChats() diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 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/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/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 6732bb7665..c937c9ec93 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -144,19 +144,37 @@ enum NSENotification { class NSEThreads { static let shared = NSEThreads() private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") - private var threads: Set = [] + private var allThreads: Set = [] + private var activeThreads: Set = [] - func startThread() -> UUID { + func newThread() -> UUID { NSEThreads.queue.sync { - let (_, t) = threads.insert(UUID()) + let (_, t) = allThreads.insert(UUID()) return t } } + func startThread(_ t: UUID) { + NSEThreads.queue.sync { + if allThreads.contains(t) { + _ = activeThreads.insert(t) + } else { + logger.warning("NotificationService startThread: thread \(t) was removed before it started") + } + } + } + func endThread(_ t: UUID) -> Bool { NSEThreads.queue.sync { - let t_ = threads.remove(t) - return t_ != nil && threads.isEmpty + let tActive = activeThreads.remove(t) + let t = allThreads.remove(t) + if tActive != nil && activeThreads.isEmpty { + return true + } + if t != nil && allThreads.isEmpty { + NSEChatState.shared.set(.suspended) + } + return false } } } @@ -169,16 +187,20 @@ class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptNtf: NSENotification? var badgeCount: Int = 0 + // thread is added to allThreads here - if thread did not start chat, + // chat does not need to be suspended but NSE state still needs to be set to "suspended". var threadId: UUID? var receiveEntityId: String? var cancelRead: (() -> Void)? + var appSubscriber: AppSubscriber? + var returnedSuspension = false override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - threadId = NSEThreads.shared.startThread() logger.debug("DEBUGGING: NotificationService.didReceive") - if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent { - setBestAttemptNtf(ntf) - } + let newThreadId = NSEThreads.shared.newThread() + threadId = newThreadId + let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } + setBestAttemptNtf(ntf) self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() @@ -186,20 +208,32 @@ class NotificationService: UNNotificationServiceExtension { case .suspended: logger.debug("NotificationService: app is suspended") setBadgeCount() - receiveNtfMessages(request, contentHandler) + receiveNtfMessages(newThreadId, request, contentHandler) case .suspending: logger.debug("NotificationService: app is suspending") setBadgeCount() Task { - var state = appState - for _ in 1...6 { - _ = try await Task.sleep(nanoseconds: suspendingDelay) - state = appStateGroupDefault.get() - if state == .suspended || state != .suspending { break } + let state: AppState = await withCheckedContinuation { cont in + appSubscriber = appStateSubscriber { s in + if s == .suspended { appSuspension(s) } + } + DispatchQueue.global().asyncAfter(deadline: .now() + Double(appSuspendTimeout) + 1) { + logger.debug("NotificationService: appSuspension timeout") + appSuspension(appStateGroupDefault.get()) + } + + @Sendable + func appSuspension(_ s: AppState) { + if !self.returnedSuspension { + self.returnedSuspension = true + self.appSubscriber = nil // this disposes of appStateSubscriber + cont.resume(returning: s) + } + } } logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)") if state.inactive { - receiveNtfMessages(request, contentHandler) + receiveNtfMessages(newThreadId, request, contentHandler) } else { deliverBestAttemptNtf() } @@ -210,7 +244,7 @@ class NotificationService: UNNotificationServiceExtension { } } - func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { + func receiveNtfMessages(_ newThreadId: UUID, _ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { deliverBestAttemptNtf() @@ -220,7 +254,11 @@ class NotificationService: UNNotificationServiceExtension { if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], let nonce = ntfData["nonce"] as? String, let encNtfInfo = ntfData["message"] as? String, - let dbStatus = startChat() { + // check it here again + appStateGroupDefault.get().inactive { + // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended + NSEThreads.shared.startThread(newThreadId) + let dbStatus = startChat() if case .ok = dbStatus, let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo), privacy: .public)") @@ -244,7 +282,7 @@ class NotificationService: UNNotificationServiceExtension { return } } - } else { + } else if let dbStatus = dbStatus { setBestAttemptNtf(createErrorNtf(dbStatus)) } } @@ -324,6 +362,7 @@ class NotificationService: UNNotificationServiceExtension { } } +// nseStateGroupDefault must not be used in NSE directly, only via this singleton class NSEChatState { static let shared = NSEChatState() private var value_ = NSEState.created @@ -334,14 +373,34 @@ class NSEChatState { func set(_ state: NSEState) { nseStateGroupDefault.set(state) + sendNSEState(state) value_ = state } init() { + // This is always set to .created state, as in case previous start of NSE crashed in .active state, it is stored correctly. + // Otherwise the app will be activating slower set(.created) } } +var appSubscriber: AppSubscriber = appStateSubscriber { state in + logger.debug("NotificationService: appSubscriber") + if state.running && NSEChatState.shared.value.canSuspend { + logger.debug("NotificationService: appSubscriber app state \(state.rawValue), suspending") + suspendChat(nseSuspendTimeout) + } +} + +func appStateSubscriber(onState: @escaping (AppState) -> Void) -> AppSubscriber { + appMessageSubscriber { msg in + if case let .state(state) = msg { + logger.debug("NotificationService: appStateSubscriber \(state.rawValue, privacy: .public)") + onState(state) + } + } +} + var receiverStarted = false let startLock = DispatchSemaphore(value: 1) let suspendLock = DispatchSemaphore(value: 1) @@ -359,6 +418,7 @@ func startChat() -> DBMigrationResult? { return switch NSEChatState.shared.value { case .created: doStartChat() + case .starting: .ok // it should never get to this branch, as it would be waiting for start on startLock case .active: .ok case .suspending: activateChat() case .suspended: activateChat() @@ -374,6 +434,8 @@ func doStartChat() -> DBMigrationResult? { NSEChatState.shared.set(.created) return dbStatus } + let state = NSEChatState.shared.value + NSEChatState.shared.set(.starting) if let user = apiGetActiveUser() { logger.debug("NotificationService active user \(String(describing: user))") do { @@ -382,24 +444,31 @@ func doStartChat() -> DBMigrationResult? { try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) - let justStarted = try apiStartChat() - NSEChatState.shared.set(.active) - if justStarted { - chatLastStartGroupDefault.set(Date.now) - Task { - if !receiverStarted { - receiverStarted = true - await receiveMessages() + // prevent suspension while starting chat + suspendLock.wait() + defer { suspendLock.signal() } + if NSEChatState.shared.value == .starting { + updateNetCfg() + let justStarted = try apiStartChat() + NSEChatState.shared.set(.active) + if justStarted { + chatLastStartGroupDefault.set(Date.now) + Task { + if !receiverStarted { + receiverStarted = true + await receiveMessages() + } } } + return .ok } - return .ok } catch { logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") } } else { logger.debug("NotificationService: no active user") } + if NSEChatState.shared.value == .starting { NSEChatState.shared.set(state) } return nil } @@ -450,15 +519,10 @@ func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { switch NSEChatState.shared.value { + // it should never get to "created" and "starting" branches, as NSE state is set to .active before the loop start case .created: await delayWhenInactive() - case .active: - if appStateGroupDefault.get().running { - suspendChat(nseSuspendTimeout) - await delayWhenInactive() - } else { - updateNetCfg() - await receiveMsg() - } + case .starting: await delayWhenInactive() + case .active: await receiveMsg() case .suspending: await receiveMsg() case .suspended: await delayWhenInactive() } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7f6a1a2524..64ed240e4f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,11 +43,6 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; - 5C4BB4CC2B20E177007981AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C72B20E176007981AA /* libffi.a */; }; - 5C4BB4CD2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */; }; - 5C4BB4CE2B20E177007981AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4C92B20E177007981AA /* libgmpxx.a */; }; - 5C4BB4CF2B20E177007981AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4CA2B20E177007981AA /* libgmp.a */; }; - 5C4BB4D02B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -68,6 +63,11 @@ 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; + 5C8EA13D2B25206A001DE5E4 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA1382B25206A001DE5E4 /* libgmp.a */; }; + 5C8EA13E2B25206A001DE5E4 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */; }; + 5C8EA13F2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */; }; + 5C8EA1402B25206A001DE5E4 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13B2B25206A001DE5E4 /* libffi.a */; }; + 5C8EA1412B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; }; @@ -151,6 +151,8 @@ 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; + 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; }; + 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -291,11 +293,6 @@ 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; - 5C4BB4C72B20E176007981AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a"; sourceTree = ""; }; - 5C4BB4C92B20E177007981AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C4BB4CA2B20E177007981AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; @@ -336,6 +333,11 @@ 5C8B41C929AF41BC00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 5C8B41CB29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = "cs.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C8B41CC29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = ""; }; + 5C8EA1382B25206A001DE5E4 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a"; sourceTree = ""; }; + 5C8EA13B2B25206A001DE5E4 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a"; sourceTree = ""; }; 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServersView.swift; sourceTree = ""; }; 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServerView.swift; sourceTree = ""; }; 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecPlay.swift; sourceTree = ""; }; @@ -436,6 +438,8 @@ 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = ""; }; + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -513,12 +517,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C4BB4D02B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a in Frameworks */, + 5C8EA13F2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a in Frameworks */, + 5C8EA1402B25206A001DE5E4 /* libffi.a in Frameworks */, + 5C8EA13E2B25206A001DE5E4 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C4BB4CE2B20E177007981AA /* libgmpxx.a in Frameworks */, - 5C4BB4CC2B20E177007981AA /* libffi.a in Frameworks */, - 5C4BB4CD2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a in Frameworks */, - 5C4BB4CF2B20E177007981AA /* libgmp.a in Frameworks */, + 5C8EA13D2B25206A001DE5E4 /* libgmp.a in Frameworks */, + 5C8EA1412B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -581,11 +585,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C4BB4C72B20E176007981AA /* libffi.a */, - 5C4BB4CA2B20E177007981AA /* libgmp.a */, - 5C4BB4C92B20E177007981AA /* libgmpxx.a */, - 5C4BB4CB2B20E177007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy-ghc9.6.3.a */, - 5C4BB4C82B20E176007981AA /* libHSsimplex-chat-5.4.0.6-CwRTIkaIEVTLXC76NTPbOy.a */, + 5C8EA13B2B25206A001DE5E4 /* libffi.a */, + 5C8EA1382B25206A001DE5E4 /* libgmp.a */, + 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */, + 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */, + 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */, ); path = Libraries; sourceTree = ""; @@ -610,6 +614,7 @@ 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, @@ -811,6 +816,7 @@ 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, @@ -1183,6 +1189,7 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, + 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */, @@ -1270,6 +1277,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */, 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index eebdefb09d..0804741c94 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -9,6 +9,8 @@ import Foundation import SwiftUI +public let appSuspendTimeout: Int = 15 // seconds + let GROUP_DEFAULT_APP_STATE = "appState" let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" @@ -67,7 +69,7 @@ public func registerGroupDefaults() { ]) } -public enum AppState: String { +public enum AppState: String, Codable { case active case activating case bgRefresh @@ -102,8 +104,9 @@ public enum AppState: String { } } -public enum NSEState: String { +public enum NSEState: String, Codable { case created + case starting case active case suspending case suspended @@ -126,16 +129,18 @@ public enum DBContainer: String { case group } +// appStateGroupDefault must not be used in the app directly, only via AppChatState singleton public let appStateGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_APP_STATE, withDefault: .active ) +// nseStateGroupDefault must not be used in NSE directly, only via NSEChatState singleton public let nseStateGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_NSE_STATE, - withDefault: .created + withDefault: .suspended // so that NSE that was never launched does not delay the app from resuming ) public func allowBackgroundRefresh() -> Bool { diff --git a/apps/ios/SimpleXChat/SharedFileSubscriber.swift b/apps/ios/SimpleXChat/SharedFileSubscriber.swift new file mode 100644 index 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)) +} From 8a41a4c214c08719728005dfdc193e8122c56ca4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Dec 2023 12:59:49 +0000 Subject: [PATCH 06/69] ios: do not start chat if it was stopped, deliver "app stopped" notifications (#3535) * add stopped notifications, remove full off mode * core: allow initializing chat data without starting chat * ios: ask before starting chat if it was stopped * correct text Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * fix comment --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/ios/Shared/AppDelegate.swift | 1 + apps/ios/Shared/Model/BGManager.swift | 1 + apps/ios/Shared/Model/ChatModel.swift | 6 +--- apps/ios/Shared/Model/SimpleXAPI.swift | 5 ++++ apps/ios/Shared/Model/SuspendChat.swift | 28 +++++++++++++++-- apps/ios/Shared/SimpleXApp.swift | 18 ++++++----- .../UserSettings/NotificationsView.swift | 12 +------- .../ios/SimpleX NSE/NotificationService.swift | 30 +++++++++---------- apps/ios/SimpleXChat/APITypes.swift | 8 +++-- apps/ios/SimpleXChat/AppGroup.swift | 9 ++---- apps/ios/SimpleXChat/Notifications.swift | 7 +++++ src/Simplex/Chat.hs | 16 ++++++---- 12 files changed, 84 insertions(+), 57 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index b083361a05..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) } diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index aae1e15fa0..a39155efe8 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -15,6 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive" // TCP timeout + 2 sec private let waitForMessages: TimeInterval = 6 +// This is the smallest interval between refreshes, and also target interval in "off" mode private let bgRefreshInterval: TimeInterval = 600 private let maxTimerCount = 9 diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a7f4bcdbee..e7932f2d92 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -104,14 +104,10 @@ final class ChatModel: ObservableObject { static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } - var ntfEnableLocal: Bool { - true -// notificationMode == .off || ntfEnableLocalGroupDefault.get() - } + let ntfEnableLocal = true var ntfEnablePeriodic: Bool { notificationMode != .off -// notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() } var activeRemoteCtrl: Bool { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e2161cbf96..e67dab18fa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1235,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() } } @@ -1251,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 7ced1351a7..9b03f38f3c 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -9,6 +9,7 @@ import Foundation import UIKit import SimpleXChat +import SwiftUI private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") @@ -103,11 +104,32 @@ func activateChat(appState: AppState = .active) { func initChatAndMigrate(refreshInvitations: Bool = true) { 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))" + ) } } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index d75738d04e..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() } } @@ -77,15 +77,17 @@ struct SimpleXApp: App { case .active: CallController.shared.shouldSuspendChat = false let appState = AppChatState.shared.value - startChatAndActivate { - if appState.inactive && chatModel.chatRunning == true { - updateChats() - if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { - updateCallInvitations() + 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/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/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index c937c9ec93..eaa1131eb8 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -23,8 +23,8 @@ typealias NtfStream = ConcurrentQueue // Notifications are delivered via concurrent queues, as they are all received from chat controller in a single loop that // writes to ConcurrentQueue and when notification is processed, the instance of Notification service extension reads from the queue. // One queue per connection (entity) is used. -// The concurrent queues allow for read cancellation, to ensure that notifications are not lost in case the next the current thread completes -// before expected notification is read (multiple notifications can be expected, because one notification can be delivered for several messages. +// 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] = [:] @@ -181,7 +181,7 @@ class NSEThreads { // Notification service extension creates a new instance of the class and calls didReceive for each notification. // Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never -// more than one process for notification service extension. +// 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)? @@ -189,7 +189,7 @@ class NotificationService: UNNotificationServiceExtension { var badgeCount: Int = 0 // thread is added to allThreads here - if thread did not start chat, // chat does not need to be suspended but NSE state still needs to be set to "suspended". - var threadId: UUID? + var threadId: UUID? = NSEThreads.shared.newThread() var receiveEntityId: String? var cancelRead: (() -> Void)? var appSubscriber: AppSubscriber? @@ -197,20 +197,21 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") - let newThreadId = NSEThreads.shared.newThread() - threadId = newThreadId let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } setBestAttemptNtf(ntf) self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() + logger.debug("NotificationService: app is \(appState.rawValue, privacy: .public)") switch appState { - case .suspended: - logger.debug("NotificationService: app is suspended") + case .stopped: setBadgeCount() - receiveNtfMessages(newThreadId, request, contentHandler) + setBestAttemptNtf(createAppStoppedNtf()) + deliverBestAttemptNtf() + case .suspended: + setBadgeCount() + receiveNtfMessages(request, contentHandler) case .suspending: - logger.debug("NotificationService: app is suspending") setBadgeCount() Task { let state: AppState = await withCheckedContinuation { cont in @@ -231,20 +232,19 @@ class NotificationService: UNNotificationServiceExtension { } } } - 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(newThreadId, request, contentHandler) + receiveNtfMessages(request, contentHandler) } else { deliverBestAttemptNtf() } } default: - logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)") deliverBestAttemptNtf() } } - func receiveNtfMessages(_ newThreadId: UUID, _ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { + func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { deliverBestAttemptNtf() @@ -257,7 +257,7 @@ class NotificationService: UNNotificationServiceExtension { // check it here again appStateGroupDefault.get().inactive { // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended - NSEThreads.shared.startThread(newThreadId) + if let t = threadId { NSEThreads.shared.startThread(t) } let dbStatus = startChat() if case .ok = dbStatus, let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index c03951e609..4d14469656 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1498,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" @@ -1505,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 0804741c94..10625e2edf 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -16,8 +16,8 @@ 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" @@ -143,6 +143,7 @@ public let nseStateGroupDefault = EnumDefault( 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 } @@ -163,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/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/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index eb322bcd9e..91ca3857af 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -599,7 +599,7 @@ processChatCommand = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) - APIGetChats userId withPCC -> withUserId userId $ \user -> + APIGetChats userId withPCC -> withUserId' userId $ \user -> CRApiChats user <$> withStoreCtx' (Just "APIGetChats, getChatPreviews") (\db -> getChatPreviews db user withPCC) APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled @@ -1205,8 +1205,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 @@ -1224,7 +1223,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 @@ -1483,9 +1482,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} @@ -5911,6 +5910,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 From 0bfe37137cdc8f03fb4dd77a4600d1c113a93bf6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Dec 2023 13:11:35 +0000 Subject: [PATCH 07/69] core: update simplexmq (message notification markers) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index b2ec38d28b..e1c8b11a7f 100644 --- a/cabal.project +++ b/cabal.project @@ -11,7 +11,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 64bc203c7f827b99d846dbc368e43c278e4546d2 + tag: 560dc553127851fa1fb201d0a9c80dcf1ad6e5dc source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 24256824e5..ae2eb59ab2 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."64bc203c7f827b99d846dbc368e43c278e4546d2" = "1xz3lw5dsh7gm136jzwmsbqjigsqsnjlbhg38mpc6lm586lg8f9x"; + "https://github.com/simplex-chat/simplexmq.git"."560dc553127851fa1fb201d0a9c80dcf1ad6e5dc" = "1xz3lw5dsh7gm136jzwmsbqjigsqsnjlbhg38mpc6lm586lg8f9x"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; From 35c1975d66d817937395aeca07402790243015e7 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 11 Dec 2023 15:50:32 +0200 Subject: [PATCH 08/69] core: chat list pagination (#3505) * add pagination args to APIGetChats * add search to chat list API * rename arg to paginationTs_ to match type * lift another condition to ids query * collect all chat refs before sorting, then get details * split remaining preview functions * roll back to collecting ids first with query cleanup * add connection join back to filter out groups * extract and expand tests * add fav/unread args * WIP * lay out the queries with favs * tweak tests * add fav tests * fix order by in the before case * build query footer wholly from pagination * add migration for direct contacts * fix setting contact_used * fix setting contact_used for group link contacts * align search x filters space with UI, support filter by either favorite or unread, optimize queries, indexes * always set chat_ts, fix tests * refactor tests * fix pagination logic, more tests * refactor, rename * increase default pagination count * comments * refactor * comment * report errors * refactor * remove unused type --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 118 ++-- src/Simplex/Chat/Controller.hs | 25 +- src/Simplex/Chat/Messages.hs | 6 - .../Chat/Migrations/M20221222_chat_ts.hs | 4 +- .../M20231207_chat_list_pagination.hs | 44 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 15 + src/Simplex/Chat/Store/Direct.hs | 18 +- src/Simplex/Chat/Store/Groups.hs | 8 +- src/Simplex/Chat/Store/Messages.hs | 606 +++++++++++------- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 8 +- src/Simplex/Chat/Store/Shared.hs | 10 +- src/Simplex/Chat/Types.hs | 7 +- src/Simplex/Chat/View.hs | 1 + tests/ChatClient.hs | 2 +- tests/ChatTests.hs | 2 + tests/ChatTests/ChatList.hs | 227 +++++++ 18 files changed, 805 insertions(+), 302 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs create mode 100644 tests/ChatTests/ChatList.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 40a1539dca..d9d06f8707 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -125,6 +125,7 @@ library Simplex.Chat.Migrations.M20231113_group_forward Simplex.Chat.Migrations.M20231114_remote_control Simplex.Chat.Migrations.M20231126_remote_ctrl_address + Simplex.Chat.Migrations.M20231207_chat_list_pagination Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -532,6 +533,7 @@ test-suite simplex-chat-test Bots.DirectoryTests ChatClient ChatTests + ChatTests.ChatList ChatTests.Direct ChatTests.Files ChatTests.Groups diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6e3d299404..9c06e80d20 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -34,7 +34,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char import Data.Constraint (Dict (..)) -import Data.Either (fromRight, rights) +import Data.Either (fromRight, partitionEithers, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) @@ -596,8 +596,10 @@ processChatCommand = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) - APIGetChats userId withPCC -> withUserId userId $ \user -> - CRApiChats user <$> withStoreCtx' (Just "APIGetChats, getChatPreviews") (\db -> getChatPreviews db user withPCC) + 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 APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do @@ -1048,10 +1050,12 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIAcceptContact incognito connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do - (user, cReq) <- withStore $ \db -> getContactRequest' db connReqId + (user@User {userId}, cReq@UserContactRequest {userContactLinkId}) <- withStore $ \db -> getContactRequest' db connReqId + ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + let contactUsed = (\(_, groupId_, _) -> isNothing groupId_) ucl -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequest user cReq incognitoProfile + ct <- acceptContactRequest user cReq incognitoProfile contactUsed pure $ CRAcceptingContactRequest user ct APIRejectContact connReqId -> withUser $ \user -> withChatLock "rejectContact" $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- @@ -1824,8 +1828,10 @@ processChatCommand = \case let mc = MCText msg processChatCommand . APISendMessage (ChatRef CTGroup groupId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc LastChats count_ -> withUser' $ \user -> do - chats <- withStore' $ \db -> getChatPreviews db user False - pure $ CRChats $ maybe id take count_ chats + let count = fromMaybe 5000 count_ + (errs, previews) <- partitionEithers <$> withStore' (\db -> getChatPreviews db user False (PTLast count) clqNoFilters) + toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search @@ -2691,21 +2697,21 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of getTmpHandle :: FilePath -> m Handle getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) -acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> m Contact +acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile contactUsed = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile dm <- directMessage $ XInfo profileToSend acId <- withAgent $ \a -> acceptContact a True invId dm subMode - withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode + withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode contactUsed -acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> m Contact +acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode withStore' $ \db -> do - ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode + ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode contactUsed forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct @@ -3384,20 +3390,20 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do doProbeContacts = isJust groupLinkId probeMatchingContactsAndMembers ct (contactConnIncognito ct) doProbeContacts withStore' $ \db -> resetContactConnInitiated db user conn - forM_ viaUserContactLink $ \userContactLinkId -> - withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_, gLinkMemRole) -> do - forM_ mc_ $ \mc -> do - (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) - forM_ groupId_ $ \groupId -> do - groupInfo <- withStore $ \db -> getGroupInfo db user groupId - subMode <- chatReadVar subscriptionMode - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode - gVar <- asks idsDrg - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode - _ -> pure () + forM_ viaUserContactLink $ \userContactLinkId -> do + ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl + forM_ autoAccept $ \(AutoAccept {autoReply = mc_}) -> + forM_ mc_ $ \mc -> do + (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) + forM_ groupId_ $ \groupId -> do + groupInfo <- withStore $ \db -> getGroupInfo db user groupId + subMode <- chatReadVar subscriptionMode + groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode + gVar <- asks idsDrg + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct @@ -3915,28 +3921,27 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do - withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept}, groupId_, gLinkMemRole) -> - case autoAccept of - Just AutoAccept {acceptIncognito} -> case groupId_ of - Nothing -> do - -- [incognito] generate profile to send, create connection with incognito profile - incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequestAsync user cReq incognitoProfile - toView $ CRAcceptingContactRequest user ct - Just groupId -> do - gInfo <- withStore $ \db -> getGroupInfo db user groupId - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if isCompatibleRange chatVRange groupLinkNoContactVRange - then do - mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode - createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else do - ct <- acceptContactRequestAsync user cReq profileMode - toView $ CRAcceptingGroupJoinRequest user gInfo ct - _ -> toView $ CRReceivedContactRequest user cReq - _ -> pure () + ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl + case autoAccept of + Just AutoAccept {acceptIncognito} -> case groupId_ of + Nothing -> do + -- [incognito] generate profile to send, create connection with incognito profile + incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + ct <- acceptContactRequestAsync user cReq incognitoProfile True + toView $ CRAcceptingContactRequest user ct + Just groupId -> do + gInfo <- withStore $ \db -> getGroupInfo db user groupId + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + if isCompatibleRange chatVRange groupLinkNoContactVRange + then do + mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + else do + ct <- acceptContactRequestAsync user cReq profileMode False + toView $ CRAcceptingGroupJoinRequest user gInfo ct + _ -> toView $ CRReceivedContactRequest user cReq memberCanSend :: GroupMember -> m () -> m () memberCanSend mem a @@ -4932,7 +4937,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - ct <- withStore $ \db -> createDirectContact db user conn' p + let contactUsed = connDirect activeConn + ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed toView $ CRContactConnecting user ct pure conn' XGrpLinkInv glInv -> do @@ -5982,7 +5988,13 @@ chatCommandP = "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, - "/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), + "/_get chats " + *> ( APIGetChats + <$> A.decimal + <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False) + <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) + <*> (A.space *> jsonP <|> pure clqNoFilters) + ), "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), @@ -6222,6 +6234,10 @@ chatCommandP = (CPLast <$ "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) + paginationByTimeP = + (PTLast <$ "count=" <*> A.decimal) + <|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal) + <|> (PTBefore <$ "before=" <*> strP <* A.space <* "count=" <*> A.decimal) mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c3bf84b33e..04c47a6462 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -247,7 +247,7 @@ data ChatCommand | ExecChatStoreSQL Text | ExecAgentStoreSQL Text | SlowSQLQueries - | APIGetChats {userId :: UserId, pendingConnections :: Bool} + | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId @@ -685,6 +685,7 @@ data ChatResponse | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRChatError {user_ :: Maybe User, chatError :: ChatError} + | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) @@ -733,6 +734,26 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False +data ChatPagination + = CPLast Int + | CPAfter ChatItemId Int + | CPBefore ChatItemId Int + deriving (Show) + +data PaginationByTime + = PTLast Int + | PTAfter UTCTime Int + | PTBefore UTCTime Int + deriving (Show) + +data ChatListQuery + = CLQFilters {favorite :: Bool, unread :: Bool} + | CLQSearch {search :: String} + deriving (Show) + +clqNoFilters :: ChatListQuery +clqNoFilters = CLQFilters {favorite = False, unread = False} + data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} @@ -1266,6 +1287,8 @@ withAgent action = $(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CLQ") ''ChatListQuery) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 77c053fdf3..9604b71838 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -713,12 +713,6 @@ type ChatItemId = Int64 type ChatItemTs = UTCTime -data ChatPagination - = CPLast Int - | CPAfter ChatItemId Int - | CPBefore ChatItemId Int - deriving (Show) - data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect SCTGroup :: SChatType 'CTGroup diff --git a/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs b/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs index 5cadd03fe3..9a83c81821 100644 --- a/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs +++ b/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs @@ -8,7 +8,7 @@ import Database.SQLite.Simple.QQ (sql) m20221222_chat_ts :: Query m20221222_chat_ts = [sql| -ALTER TABLE contacts ADD COLUMN chat_ts TEXT; +ALTER TABLE contacts ADD COLUMN chat_ts TEXT; -- must be not NULL -ALTER TABLE groups ADD COLUMN chat_ts TEXT; +ALTER TABLE groups ADD COLUMN chat_ts TEXT; -- must be not NULL |] diff --git a/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs b/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs new file mode 100644 index 0000000000..cf272ae651 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231207_chat_list_pagination where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231207_chat_list_pagination :: Query +m20231207_chat_list_pagination = + [sql| +UPDATE contacts SET contact_used = 1 +WHERE contact_id = ( + SELECT contact_id FROM connections + WHERE conn_level = 0 AND via_group_link = 0 +); + +UPDATE contacts +SET chat_ts = updated_at +WHERE chat_ts IS NULL; + +UPDATE groups +SET chat_ts = updated_at +WHERE chat_ts IS NULL; + +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 +down_m20231207_chat_list_pagination = + [sql| +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 19b4d72379..ab431f84d0 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -810,3 +810,18 @@ CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts( CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers( ctrl_fingerprint ); +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 +); diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 0046bc990d..7504f19c95 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -201,15 +201,15 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact -createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do +createDirectContact :: DB.Connection -> User -> Connection -> Profile -> Bool -> ExceptT StoreError IO Contact +createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} contactUsed = do currentTs <- liftIO getCurrentTime - (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs (Just currentTs) + (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs contactUsed liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn - pure $ Contact {contactId, localDisplayName, profile, activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile, activeConn = Just conn, viaGroup = Nothing, contactUsed, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () deleteContactConnectionsAndFiles db userId Contact {contactId} = do @@ -650,8 +650,8 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> Bool -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode contactUsed = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -660,12 +660,12 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences DB.execute db - "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId) + "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id, contact_used) VALUES (?,?,?,?,?,?,?,?,?,?)" + (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId, contactUsed) contactId <- insertedRowId db conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn - pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn = Just conn, viaGroup = Nothing, contactUsed, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 75df495615..302f9bbb56 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1149,7 +1149,7 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM Just (directCmdId, directAgentConnId) -> do Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode liftIO $ setCommandConnId db user directCmdId directConnId - (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs Nothing + (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs False liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId) pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Just contactId, memProfileId} Nothing -> do @@ -1178,12 +1178,12 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = DB.execute db [sql| - INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at) - SELECT contact_profile_id, group_id, ?, ?, ?, ? + INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at, chat_ts) + SELECT contact_profile_id, group_id, ?, ?, ?, ?, ? FROM group_members WHERE group_member_id = ? |] - (localDisplayName, userId, ts, ts, groupMemberId) + (localDisplayName, userId, ts, ts, ts, groupMemberId) contactId <- insertedRowId db DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, ts, connId) pure contactId diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 102612b4ee..9986eacf6c 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -109,14 +110,15 @@ import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import Data.Either (fromRight, rights) import Data.Int (Int64) -import Data.List (sortOn) +import Data.List (sortBy) import Data.Maybe (fromMaybe, isJust, mapMaybe) -import Data.Ord (Down (..)) +import Data.Ord (Down (..), comparing) import Data.Text (Text) import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) +import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..)) import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent @@ -467,7 +469,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe <$> DB.queryNamed db [sql| - SELECT i.chat_item_id, + SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, @@ -486,209 +488,402 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow -getChatPreviews :: DB.Connection -> User -> Bool -> IO [AChat] -getChatPreviews db user withPCC = do - directChats <- getDirectChatPreviews_ db user - groupChats <- getGroupChatPreviews_ db user - cReqChats <- getContactRequestChatPreviews_ db user - connChats <- getContactConnectionChatPreviews_ db user withPCC - pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats <> connChats) +getChatPreviews :: DB.Connection -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] +getChatPreviews db user withPCC pagination query = do + directChats <- findDirectChatPreviews_ db user pagination query + groupChats <- findGroupChatPreviews_ db user pagination query + cReqChats <- getContactRequestChatPreviews_ db user pagination query + connChats <- if withPCC then getContactConnectionChatPreviews_ db user pagination query else pure [] + let refs = sortTake $ concat [directChats, groupChats, cReqChats, connChats] + mapM (runExceptT <$> getChatPreview) refs where - ts :: AChat -> UTCTime - ts (AChat _ Chat {chatInfo, chatItems}) = case chatInfoChatTs chatInfo of - Just chatTs -> chatTs - Nothing -> case chatItems of - ci : _ -> max (chatItemTs ci) (chatInfoUpdatedAt chatInfo) - _ -> chatInfoUpdatedAt chatInfo + ts :: AChatPreviewData -> UTCTime + ts (ACPD _ cpd) = case cpd of + (DirectChatPD t _ _) -> t + (GroupChatPD t _ _) -> t + (ContactRequestPD t _) -> t + (ContactConnectionPD t _) -> t + sortTake = case pagination of + PTLast count -> take count . sortBy (comparing $ Down . ts) + PTAfter _ count -> reverse . take count . sortBy (comparing ts) + PTBefore _ count -> take count . sortBy (comparing $ Down . ts) + getChatPreview :: AChatPreviewData -> ExceptT StoreError IO AChat + getChatPreview (ACPD cType cpd) = case cType of + SCTDirect -> getDirectChatPreview_ db user cpd + SCTGroup -> getGroupChatPreview_ db user cpd + SCTContactRequest -> let (ContactRequestPD _ chat) = cpd in pure chat + SCTContactConnection -> let (ContactConnectionPD _ chat) = cpd in pure chat -getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] -getDirectChatPreviews_ db user@User {userId} = do - currentTs <- getCurrentTime - map (toDirectChatPreview currentTs) - <$> DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version, - -- ChatStats - COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat, - -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, - -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- DirectQuote - ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - 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 chat_items i ON i.contact_id = LastItems.contact_id - AND i.chat_item_id = LastItems.chat_item_id - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN ( - SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE item_status = ? - GROUP BY contact_id - ) ChatStats ON ChatStats.contact_id = ct.contact_id - LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id - WHERE ct.user_id = ? - AND ct.is_user = 0 - AND ct.deleted = 0 - AND ( - ( - ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1) - AND c.connection_id = ( - SELECT cc_connection_id FROM ( - SELECT - cc.connection_id AS cc_connection_id, - cc.created_at AS cc_created_at, - (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_created_at DESC - LIMIT 1 - ) - ) - ) - OR c.connection_id IS NULL +data ChatPreviewData (c :: ChatType) where + DirectChatPD :: UTCTime -> ContactId -> Maybe ChatStats -> ChatPreviewData 'CTDirect + GroupChatPD :: UTCTime -> GroupId -> Maybe ChatStats -> ChatPreviewData 'CTGroup + ContactRequestPD :: UTCTime -> AChat -> ChatPreviewData 'CTContactRequest + ContactConnectionPD :: UTCTime -> AChat -> ChatPreviewData 'CTContactConnection + +data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreviewData c) + +paginationByTimeFilter :: PaginationByTime -> (Query, [NamedParam]) +paginationByTimeFilter = \case + PTLast count -> ("\nORDER BY ts DESC LIMIT :count", [":count" := count]) + 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) + +toMaybeChatStats :: MaybeChatStatsRow -> Maybe ChatStats +toMaybeChatStats (Just unreadCount, Just minUnreadItemId, Just unreadChat) = Just ChatStats {unreadCount, minUnreadItemId, unreadChat} +toMaybeChatStats _ = Nothing + +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_) + (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 + |] + <> pagQuery ) - ORDER BY i.item_ts DESC - |] - (CISRcvNew, userId, ConnReady, ConnSndReady) - where - toDirectChatPreview :: UTCTime -> ContactRow :. MaybeConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat - toDirectChatPreview currentTs (contactRow :. connRow :. statsRow :. ciRow_) = - let contact = toContact user $ contactRow :. connRow - ci_ = toDirectChatItemList currentTs ciRow_ - stats = toChatStats statsRow - in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats + ([":user_id" := userId] <> 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 + |] + <> pagQuery + ) + ([":user_id" := userId] <> 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) + |] + <> 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) + |] + <> 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 || '%' + ) + |] + <> pagQuery + ) + ([":user_id" := userId, ":search" := search] <> pagParams) -getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] -getGroupChatPreviews_ db User {userId, userContactId} = do - currentTs <- getCurrentTime - map (toGroupChatPreview currentTs) - <$> DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, - -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - -- ChatStats - COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat, - -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, - -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- CIMeta forwardedByMember - i.forwarded_by_group_member_id, - -- Maybe GroupMember - sender - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - -- quoted ChatItem - ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, - -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, - rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, - -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, - dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences - FROM groups g - JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - JOIN group_members mu ON mu.group_id = g.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - 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 chat_items i ON i.group_id = LastItems.group_id - AND i.chat_item_id = LastItems.chat_item_id - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread - FROM chat_items - WHERE item_status = ? - GROUP BY group_id - ) ChatStats ON ChatStats.group_id = g.group_id - LEFT JOIN group_members m ON m.group_member_id = i.group_member_id - LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id - LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id - LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) - LEFT JOIN group_members dbm ON dbm.group_member_id = i.item_deleted_by_group_member_id - LEFT JOIN contact_profiles dbp ON dbp.contact_profile_id = COALESCE(dbm.member_profile_id, dbm.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - ORDER BY i.item_ts DESC - |] - (CISRcvNew, userId, userContactId) +getDirectChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat +getDirectChatPreview_ db user (DirectChatPD _ contactId stats_) = do + contact <- getContact db user contactId + lastItem <- getLastItem + stats <- maybe getChatStats pure stats_ + pure $ AChat SCTDirect (Chat (DirectChat contact) lastItem stats) where - toGroupChatPreview :: UTCTime -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat - toGroupChatPreview currentTs (groupInfoRow :. statsRow :. ciRow_) = - let groupInfo = toGroupInfo userContactId groupInfoRow - ci_ = toGroupChatItemList currentTs userContactId ciRow_ - stats = toChatStats statsRow - in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats + 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) -getContactRequestChatPreviews_ :: DB.Connection -> User -> IO [AChat] -getContactRequestChatPreviews_ db User {userId} = - map toContactRequestChatPreview - <$> DB.query - db - [sql| - SELECT - cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version - FROM contact_requests cr - JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id - JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id - JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id - WHERE cr.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL - |] - (userId, userId) +findGroupChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] +findGroupChatPreviews_ db User {userId} pagination clq = + map toPreview <$> getPreviews where - toContactRequestChatPreview :: ContactRequestRow -> AChat - toContactRequestChatPreview cReqRow = - let cReq = toContactRequest cReqRow + toPreview :: (GroupId, UTCTime) :. MaybeChatStatsRow -> AChatPreviewData + toPreview ((groupId, ts) :. statsRow_) = + ACPD SCTGroup $ GroupChatPD ts groupId (toMaybeChatStats statsRow_) + (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 + |] + <> pagQuery + ) + ([":user_id" := userId] <> 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 + |] + <> pagQuery + ) + ([":user_id" := userId] <> 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) + |] + <> 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) + |] + <> 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 || '%' + ) + |] + <> pagQuery + ) + ([":user_id" := userId, ":search" := search] <> pagParams) + +getGroupChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat +getGroupChatPreview_ db user (GroupChatPD _ groupId stats_) = do + groupInfo <- getGroupInfo db user groupId + lastItem <- getLastItem + stats <- maybe getChatStats pure stats_ + 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 + CLQFilters {favorite = False, unread = False} -> query "" + CLQFilters {favorite = True, unread = False} -> pure [] + CLQFilters {favorite = False, unread = True} -> query "" + CLQFilters {favorite = True, unread = True} -> query "" + CLQSearch {search} -> query search + where + (pagQuery, pagParams) = paginationByTimeFilter pagination + query search = + map toPreview + <$> DB.queryNamed + db + ( [sql| + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, p.preferences, + cr.created_at, cr.updated_at as ts, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = :user_id + AND uc.user_id = :user_id + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + cr.local_display_name LIKE '%' || :search || '%' + OR p.display_name LIKE '%' || :search || '%' + OR p.full_name LIKE '%' || :search || '%' + ) + |] + <> pagQuery + ) + ([":user_id" := userId, ":search" := search] <> pagParams) + toPreview :: ContactRequestRow -> AChatPreviewData + toPreview cReqRow = + let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats + aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats + in ACPD SCTContactRequest $ ContactRequestPD updatedAt aChat -getContactConnectionChatPreviews_ :: DB.Connection -> User -> Bool -> IO [AChat] -getContactConnectionChatPreviews_ _ _ False = pure [] -getContactConnectionChatPreviews_ db User {userId} _ = - map toContactConnectionChatPreview - <$> DB.query - db - [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at - FROM connections - WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL AND (via_group_link = 0 || (via_group_link = 1 AND group_link_id IS NOT NULL)) - |] - (userId, ConnContact) +getContactConnectionChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] +getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of + CLQFilters {favorite = False, unread = False} -> query "" + CLQFilters {favorite = True, unread = False} -> pure [] + CLQFilters {favorite = False, unread = True} -> pure [] + CLQFilters {favorite = True, unread = True} -> pure [] + CLQSearch {search} -> query search where - toContactConnectionChatPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> AChat - toContactConnectionChatPreview connRow = - let conn = toPendingContactConnection connRow + (pagQuery, pagParams) = paginationByTimeFilter pagination + query search = + map toPreview + <$> DB.queryNamed + db + ( [sql| + SELECT + connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at as ts + FROM connections + WHERE user_id = :user_id + AND conn_type = :conn_contact + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 || (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND local_alias LIKE '%' || :search || '%' + |] + <> pagQuery + ) + ([":user_id" := userId, ":conn_contact" := ConnContact, ":search" := search] <> pagParams) + toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> AChatPreviewData + toPreview connRow = + let conn@PendingContactConnection {updatedAt} = toPendingContactConnection connRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChat db user contactId pagination search_ = do @@ -993,19 +1188,12 @@ setGroupChatItemDeleteAt db User {userId} groupId chatItemId deleteAt = "UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" (deleteAt, userId, groupId, chatItemId) -type ChatStatsRow = (Int, ChatItemId, Bool) - -toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, minUnreadItemId, unreadChat) = ChatStats {unreadCount, minUnreadItemId, unreadChat} - type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe AMsgDirection, Maybe Text, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId) :. (Maybe Int, Maybe UTCTime, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow - type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) toDirectQuote :: QuoteRow -> Maybe (CIQuote 'CTDirect) @@ -1055,15 +1243,8 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -toDirectChatItemList :: UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] -toDirectChatItemList currentTs (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. quoteRow) = - either (const []) (: []) $ toDirectChatItem currentTs (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. quoteRow) -toDirectChatItemList _ _ = [] - type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow -type MaybeGroupChatItemRow = MaybeChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow - toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup) toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction quotedSent quotedMember_ where @@ -1114,11 +1295,6 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -toGroupChatItemList :: UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = - either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) -toGroupChatItemList _ _ _ = [] - getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] getAllChatItems db user@User {userId} pagination search_ = do itemRefs <- diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 31d0525dba..c8a04c42a1 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -91,6 +91,7 @@ import Simplex.Chat.Migrations.M20231107_indexes import Simplex.Chat.Migrations.M20231113_group_forward import Simplex.Chat.Migrations.M20231114_remote_control import Simplex.Chat.Migrations.M20231126_remote_ctrl_address +import Simplex.Chat.Migrations.M20231207_chat_list_pagination import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -181,7 +182,8 @@ schemaMigrations = ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward), ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control), - ("20231126_remote_ctrl_address", m20231126_remote_ctrl_address, Just down_m20231126_remote_ctrl_address) + ("20231126_remote_ctrl_address", m20231126_remote_ctrl_address, Just down_m20231126_remote_ctrl_address), + ("20231207_chat_list_pagination", m20231207_chat_list_pagination, Just down_m20231207_chat_list_pagination) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c51a3e4997..ce1d17859a 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -116,8 +116,8 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, profileId <- insertedRowId db DB.execute db - "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (profileId, displayName, userId, True, currentTs, currentTs) + "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)" + (profileId, displayName, userId, True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) pure $ toUser $ (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, Nothing, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, Nothing, Nothing) @@ -429,9 +429,9 @@ getUserAddress db User {userId} = |] (Only userId) -getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> IO (Maybe (UserContactLink, Maybe GroupId, GroupMemberRole)) +getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupId, GroupMemberRole) getUserContactLinkById db userId userContactLinkId = - maybeFirstRow (\(ucl :. (groupId_, mRole_)) -> (toUserContactLink ucl, groupId_, fromMaybe GRMember mRole_)) $ + ExceptT . firstRow (\(ucl :. (groupId_, mRole_)) -> (toUserContactLink ucl, groupId_, fromMaybe GRMember mRole_)) SEUserContactLinkNotFound $ DB.query db [sql| diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 93c3ab197c..e1125adc3a 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -235,10 +235,10 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () createContact db User {userId} profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db userId profile "" Nothing currentTs Nothing + void $ createContact_ db userId profile "" Nothing currentTs True -createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> Maybe UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId) -createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs chatTs = +createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> Bool -> ExceptT StoreError IO (Text, ContactId, ProfileId) +createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs contactUsed = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -247,8 +247,8 @@ createContact_ db userId Profile {displayName, fullName, image, contactLink, pre profileId <- insertedRowId db DB.execute db - "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)" - (profileId, ldn, userId, viaGroup, currentTs, currentTs, chatTs) + "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used) VALUES (?,?,?,?,?,?,?,?)" + (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, contactUsed) contactId <- insertedRowId db pure $ Right (ldn, contactId, profileId) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 3f66aa3212..d5a1300911 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -186,9 +186,10 @@ contactConnIncognito :: Contact -> IncognitoEnabled contactConnIncognito = maybe False connIncognito . contactConn contactDirect :: Contact -> Bool -contactDirect Contact {activeConn} = maybe True direct activeConn - where - direct Connection {connLevel, viaGroupLink} = connLevel == 0 && not viaGroupLink +contactDirect Contact {activeConn} = maybe True connDirect activeConn + +connDirect :: Connection -> Bool +connDirect Connection {connLevel, viaGroupLink} = connLevel == 0 && not viaGroupLink directOrUsed :: Contact -> Bool directOrUsed ct@Contact {contactUsed} = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7eeddefbcd..f801e30753 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -364,6 +364,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel testView e CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e + CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError logLevel testView) errs CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRTimedAction _ _ -> [] where diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 824e6be0a0..665ef33f97 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -275,8 +275,8 @@ getTermLine cc = 5000000 `timeout` atomically (readTQueue $ termQ cc) >>= \case Just s -> do -- remove condition to always echo virtual terminal + -- when True $ do when (printOutput cc) $ do - -- when True $ do name <- userName cc putStrLn $ name <> ": " <> s pure s diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index eeb96503e3..a00274a541 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,5 +1,6 @@ module ChatTests where +import ChatTests.ChatList import ChatTests.Direct import ChatTests.Files import ChatTests.Groups @@ -12,3 +13,4 @@ chatTests = do describe "group tests" chatGroupTests describe "file tests" chatFileTests describe "profile tests" chatProfileTests + describe "chat list pagination tests" chatListTests diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs new file mode 100644 index 0000000000..f42067c7e5 --- /dev/null +++ b/tests/ChatTests/ChatList.hs @@ -0,0 +1,227 @@ +module ChatTests.ChatList where + +import ChatClient +import ChatTests.Utils +import Data.Time.Clock (getCurrentTime) +import Data.Time.Format.ISO8601 (iso8601Show) +import Test.Hspec + +chatListTests :: SpecWith FilePath +chatListTests = do + it "get last chats" testPaginationLast + it "get chats before/after timestamp" testPaginationTs + it "filter by search query" testFilterSearch + it "filter favorite" testFilterFavorite + it "filter unread" testFilterUnread + it "filter favorite or unread" testFilterFavoriteOrUnread + it "sort and filter chats of all types" testPaginationAllChatTypes + +testPaginationLast :: HasCallStack => FilePath -> IO () +testPaginationLast = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice <##> bob + connectUsers alice cath + cath <##> alice + + alice ##> "/chats 0" + alice ##> "/chats 1" + alice <# "@cath hey" + alice ##> "/chats 2" + alice <# "bob> hey" + alice <# "@cath hey" + +testPaginationTs :: HasCallStack => FilePath -> IO () +testPaginationTs = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + tsStart <- iso8601Show <$> getCurrentTime + connectUsers alice bob + alice <##> bob + tsAliceBob <- iso8601Show <$> getCurrentTime + connectUsers alice cath + cath <##> alice + tsFinish <- iso8601Show <$> getCurrentTime + -- syntax smoke check + getChats_ alice "count=0" [] + getChats_ alice ("after=" <> tsFinish <> " count=2") [] + getChats_ alice ("before=" <> tsFinish <> " count=0") [] + -- limited reads + getChats_ alice "count=1" [("@cath", "hey")] + getChats_ alice ("after=" <> tsStart <> " count=1") [("@bob", "hey")] + getChats_ alice ("before=" <> tsFinish <> " count=1") [("@cath", "hey")] + -- interval bounds + getChats_ alice ("after=" <> tsAliceBob <> " count=10") [("@cath", "hey")] + getChats_ alice ("before=" <> tsAliceBob <> " count=10") [("@bob", "hey")] + +getChats_ :: HasCallStack => TestCC -> String -> [(String, String)] -> Expectation +getChats_ cc query expected = do + cc #$> ("/_get chats 1 pcc=on " <> query, chats, expected) + +testFilterSearch :: HasCallStack => FilePath -> IO () +testFilterSearch = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice <##> bob + connectUsers alice cath + cath <##> alice + + let query s = "count=1 {\"type\": \"search\", \"search\": \"" <> s <> "\"}" + + getChats_ alice (query "abc") [] + getChats_ alice (query "alice") [] + getChats_ alice (query "bob") [("@bob", "hey")] + getChats_ alice (query "Bob") [("@bob", "hey")] + +testFilterFavorite :: HasCallStack => FilePath -> IO () +testFilterFavorite = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice <##> bob + connectUsers alice cath + cath <##> alice + + let query = "{\"type\": \"filters\", \"favorite\": true, \"unread\": false}" + + -- no favorite chats + getChats_ alice query [] + + -- 1 favorite chat + alice ##> "/_settings @2 {\"enableNtfs\":\"all\",\"favorite\":true}" + alice <## "ok" + getChats_ alice query [("@bob", "hey")] + + -- 1 favorite chat, unread chat not included + alice ##> "/_unread chat @3 on" + alice <## "ok" + getChats_ alice query [("@bob", "hey")] + +testFilterUnread :: HasCallStack => FilePath -> IO () +testFilterUnread = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice <##> bob + connectUsers alice cath + cath <##> alice + + let query = "{\"type\": \"filters\", \"favorite\": false, \"unread\": true}" + + -- no unread chats + getChats_ alice query [] + + -- 1 unread chat + alice ##> "/_unread chat @2 on" + alice <## "ok" + getChats_ alice query [("@bob", "hey")] + + -- 1 unread chat, favorite chat not included + alice ##> "/_settings @3 {\"enableNtfs\":\"all\",\"favorite\":true}" + alice <## "ok" + getChats_ alice query [("@bob", "hey")] + +testFilterFavoriteOrUnread :: HasCallStack => FilePath -> IO () +testFilterFavoriteOrUnread = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice <##> bob + connectUsers alice cath + cath <##> alice + + let query = "{\"type\": \"filters\", \"favorite\": true, \"unread\": true}" + + -- no favorite or unread chats + getChats_ alice query [] + + -- 1 unread chat + alice ##> "/_unread chat @2 on" + alice <## "ok" + getChats_ alice query [("@bob", "hey")] + + -- 1 favorite chat + alice ##> "/_unread chat @2 off" + alice <## "ok" + alice ##> "/_settings @3 {\"enableNtfs\":\"all\",\"favorite\":true}" + alice <## "ok" + getChats_ alice query [("@cath", "hey")] + + -- 1 unread chat, 1 favorite chat + alice ##> "/_unread chat @2 on" + alice <## "ok" + getChats_ alice query [("@cath", "hey"), ("@bob", "hey")] + +testPaginationAllChatTypes :: HasCallStack => FilePath -> IO () +testPaginationAllChatTypes = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + ts1 <- iso8601Show <$> getCurrentTime + + -- @bob + connectUsers alice bob + alice <##> bob + + ts2 <- iso8601Show <$> getCurrentTime + + -- <@cath + alice ##> "/ad" + cLink <- getContactLink alice True + cath ##> ("/c " <> cLink) + alice <#? cath + + ts3 <- iso8601Show <$> getCurrentTime + + -- :3 + alice ##> "/c" + _ <- getInvitation alice + + ts4 <- iso8601Show <$> getCurrentTime + + -- #team + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + + ts5 <- iso8601Show <$> getCurrentTime + + -- @dan + connectUsers alice dan + alice <##> dan + + ts6 <- iso8601Show <$> getCurrentTime + + getChats_ alice "count=10" [("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice "count=3" [("@dan", "hey"), ("#team", ""), (":3", "")] + getChats_ alice ("after=" <> ts2 <> " count=2") [(":3", ""), ("<@cath", "")] + getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", ""), (":3", "")] + getChats_ alice ("after=" <> ts3 <> " count=10") [("@dan", "hey"), ("#team", ""), (":3", "")] + getChats_ alice ("before=" <> ts4 <> " count=10") [(":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("after=" <> ts1 <> " count=10") [("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("before=" <> ts6 <> " count=10") [("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("after=" <> ts6 <> " count=10") [] + getChats_ alice ("before=" <> ts1 <> " count=10") [] + + let queryFavorite = "{\"type\": \"filters\", \"favorite\": true, \"unread\": false}" + getChats_ alice queryFavorite [] + + alice ##> "/_settings @2 {\"enableNtfs\":\"all\",\"favorite\":true}" + alice <## "ok" + alice ##> "/_settings #1 {\"enableNtfs\":\"all\",\"favorite\":true}" + alice <## "ok" + + getChats_ alice queryFavorite [("#team", ""), ("@bob", "hey")] + getChats_ alice ("before=" <> ts4 <> " count=1 " <> queryFavorite) [("@bob", "hey")] + getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "")] + getChats_ alice ("after=" <> ts1 <> " count=1 " <> queryFavorite) [("@bob", "hey")] + getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "")] + + let queryUnread = "{\"type\": \"filters\", \"favorite\": false, \"unread\": true}" + + getChats_ alice queryUnread [("<@cath", "")] + getChats_ alice ("before=" <> ts2 <> " count=10 " <> queryUnread) [] + getChats_ alice ("before=" <> ts3 <> " count=10 " <> queryUnread) [("<@cath", "")] + getChats_ alice ("after=" <> ts2 <> " count=10 " <> queryUnread) [("<@cath", "")] + getChats_ alice ("after=" <> ts3 <> " count=10 " <> queryUnread) [] From 8a66390a785a34226eabda8c54bfbc6e5f808bb6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Dec 2023 16:14:56 +0000 Subject: [PATCH 09/69] fix for GHC 8.10.7 --- src/Simplex/Chat.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 807fbe38f1..27ac307e75 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1994,7 +1994,7 @@ processChatCommand = \case DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess ShowVersion -> do - let versionInfo = coreVersionInfo $(simplexmqCommitQ) + let versionInfo = coreVersionInfo "" -- $(simplexmqCommitQ) chatMigrations <- map upMigration <$> withStore' (Migrations.getCurrent . DB.conn) agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} From aca3a71b38d845beefe062dff17c302b9c4f1500 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Dec 2023 18:57:42 +0000 Subject: [PATCH 10/69] ios: update library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 64ed240e4f..60a9e2ff00 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -63,11 +63,6 @@ 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; - 5C8EA13D2B25206A001DE5E4 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA1382B25206A001DE5E4 /* libgmp.a */; }; - 5C8EA13E2B25206A001DE5E4 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */; }; - 5C8EA13F2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */; }; - 5C8EA1402B25206A001DE5E4 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13B2B25206A001DE5E4 /* libffi.a */; }; - 5C8EA1412B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; }; @@ -121,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, ); }; }; @@ -333,11 +333,6 @@ 5C8B41C929AF41BC00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 5C8B41CB29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = "cs.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C8B41CC29AF44CF00888272 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = ""; }; - 5C8EA1382B25206A001DE5E4 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a"; sourceTree = ""; }; - 5C8EA13B2B25206A001DE5E4 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a"; sourceTree = ""; }; 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServersView.swift; sourceTree = ""; }; 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolServerView.swift; sourceTree = ""; }; 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecPlay.swift; sourceTree = ""; }; @@ -407,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 = ""; }; @@ -517,13 +517,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C8EA13F2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a in Frameworks */, - 5C8EA1402B25206A001DE5E4 /* libffi.a in Frameworks */, - 5C8EA13E2B25206A001DE5E4 /* libgmpxx.a in Frameworks */, + 5CCD1A602B27927E001A4199 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C8EA13D2B25206A001DE5E4 /* libgmp.a in Frameworks */, - 5C8EA1412B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.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; }; @@ -585,11 +585,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C8EA13B2B25206A001DE5E4 /* libffi.a */, - 5C8EA1382B25206A001DE5E4 /* libgmp.a */, - 5C8EA1392B25206A001DE5E4 /* libgmpxx.a */, - 5C8EA13A2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX-ghc9.6.3.a */, - 5C8EA13C2B25206A001DE5E4 /* libHSsimplex-chat-5.4.0.7-1uCDT6bmj7t4ctyD1vFaZX.a */, + 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 = ""; From a5048db6fa460696181a3df3a9e403273e8bfd1f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 12 Dec 2023 09:04:48 +0000 Subject: [PATCH 11/69] ios: improve media picker for multiple images/videos (#3538) * ios: improve media picker to work with multiple images reliably * MainActor --- apps/ios/Shared/Model/ImageUtils.swift | 8 +- .../Chat/ComposeMessage/ComposeView.swift | 23 ++- .../Views/Chat/Group/GroupProfileView.swift | 6 +- .../Shared/Views/Helpers/ImagePicker.swift | 174 +++++++++--------- .../Shared/Views/NewChat/AddGroupView.swift | 6 +- .../Views/UserSettings/UserProfile.swift | 6 +- 6 files changed, 127 insertions(+), 96 deletions(-) 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/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/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/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/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/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 From ca6dfb5ea1c3cbe166c72bf17ae81ab2fcd69f58 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:24:50 +0000 Subject: [PATCH 12/69] docs: update latest version --- docs/DOWNLOADS.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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). From 7ec39d1ffaff7e22478c8bbbc63951033d8ef9b3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 12 Dec 2023 13:13:36 +0000 Subject: [PATCH 13/69] all: increase default TCP timeouts, update simplexmq (#3540) --- .../Shared/Views/UserSettings/AdvancedNetworkSettings.swift | 6 +++--- apps/ios/SimpleXChat/APITypes.swift | 6 +++--- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 6 +++--- .../common/views/usersettings/AdvancedNetworkSettings.kt | 6 +++--- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) 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/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4d14469656..a199966bab 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1207,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, 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 e1c8b11a7f..500858fb4b 100644 --- a/cabal.project +++ b/cabal.project @@ -11,7 +11,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 560dc553127851fa1fb201d0a9c80dcf1ad6e5dc + tag: f576260594b9898e26dbac1bcb4b5061fa4fa242 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ae2eb59ab2..4be84e1254 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."560dc553127851fa1fb201d0a9c80dcf1ad6e5dc" = "1xz3lw5dsh7gm136jzwmsbqjigsqsnjlbhg38mpc6lm586lg8f9x"; + "https://github.com/simplex-chat/simplexmq.git"."f576260594b9898e26dbac1bcb4b5061fa4fa242" = "0lmfncha6dxxg5ck9f4a155kyd6267k5m9w5mli121lir6ikvk7z"; "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"; From ae94bb6f87d3e1f7e332247c3d5b94ccb8d1ad36 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:20:03 +0000 Subject: [PATCH 14/69] core: use crypton instead of cryptonite (#3542) * update hackage * use crypton instead of cryptonite * remove http2 from cabal.project * simplexmq --- cabal.project | 12 +++++------- flake.lock | 6 +++--- flake.nix | 2 +- package.yaml | 4 ++-- scripts/nix/sha256map.nix | 3 +-- simplex-chat.cabal | 28 ++++++++++++++-------------- 6 files changed, 26 insertions(+), 29 deletions(-) diff --git a/cabal.project b/cabal.project index 500858fb4b..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: f576260594b9898e26dbac1bcb4b5061fa4fa242 + 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/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 4be84e1254..3733163f49 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,6 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f576260594b9898e26dbac1bcb4b5061fa4fa242" = "0lmfncha6dxxg5ck9f4a155kyd6267k5m9w5mli121lir6ikvk7z"; + "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 d9d06f8707..d8c6f24fb4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -175,7 +175,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.* @@ -199,7 +199,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.* @@ -234,7 +234,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.* @@ -259,7 +259,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.* @@ -294,7 +294,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.* @@ -319,7 +319,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.* @@ -356,7 +356,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.* @@ -381,7 +381,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.* @@ -417,7 +417,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.* @@ -442,7 +442,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.* @@ -482,7 +482,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.* @@ -507,7 +507,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.* @@ -571,7 +571,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.* @@ -600,7 +600,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.* From 7f9f9a674cf9b6bc50582d749857fbebca7ccc98 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:27:28 +0000 Subject: [PATCH 15/69] ios: fix member view freezing on iOS 15, closes #3487 (#3547) --- .../Views/Chat/Group/GroupChatInfoView.swift | 1 - .../Chat/Group/GroupMemberInfoView.swift | 22 ++++++++++--------- apps/ios/Shared/Views/NewChat/QRCode.swift | 1 + 3 files changed, 13 insertions(+), 11 deletions(-) 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/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) } } From b6b041490fcebacecbb10e19a2900f6cd5d99a94 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 13 Dec 2023 15:32:23 +0400 Subject: [PATCH 16/69] core: improve chat list pagination performance, simplify logic by always reading chat stats and last item id for previews (#3541) * core: improve chat list pagination performance * fix query * core: improve chat list pagination performance, simplify logic by always reading chat stats (#3543) * microseconds * fix * update simplexmq * simplify queries --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../M20231207_chat_list_pagination.hs | 6 - src/Simplex/Chat/Migrations/chat_schema.sql | 8 - src/Simplex/Chat/Store/Messages.hs | 325 +++++++----------- 3 files changed, 121 insertions(+), 218 deletions(-) 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 ab431f84d0..3b83b132df 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -817,11 +817,3 @@ 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 -); diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 9986eacf6c..87e6667124 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -499,8 +499,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 @@ -515,8 +515,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 @@ -528,283 +528,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 From 67241ff65c36af444bc36379c780d0596ea9dca0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 13 Dec 2023 16:13:05 +0400 Subject: [PATCH 17/69] ios: fix code scanners only attempting to scan once (#3548) --- apps/ios/Shared/Views/Chat/ScanCodeView.swift | 2 +- apps/ios/Shared/Views/NewChat/ScanToConnectView.swift | 2 +- apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift | 2 +- apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/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) From aea7ff1c8980bb9c20944e004de05862419ceb96 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 13 Dec 2023 20:27:58 +0000 Subject: [PATCH 18/69] nix: fix script --- scripts/nix/update-sha256.awk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/nix/update-sha256.awk b/scripts/nix/update-sha256.awk index e432ec32d2..6ee0c9a155 100644 --- a/scripts/nix/update-sha256.awk +++ b/scripts/nix/update-sha256.awk @@ -11,7 +11,7 @@ BEGIN { /tag/ && isGit == true { ref=$2 } isGit == true && loc != "" && ref != "" { - cmd = "nix-prefetch-git --quiet "loc" "ref" | jq -r .sha256" + cmd = "nix-prefetch-git --fetch-submodules --quiet "loc" "ref" | jq -r .sha256" cmd | getline sha256 close(cmd) print " \""loc"\".\""ref"\" = \""sha256"\";"; From 73130bf3215aa37a8b2ec013a566d1cb650864e2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 13 Dec 2023 21:48:25 +0000 Subject: [PATCH 19/69] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 60a9e2ff00..f5701af155 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -116,11 +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 */; }; + 5CCD1A882B2A5D56001A4199 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A832B2A5D55001A4199 /* libgmp.a */; }; + 5CCD1A892B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A842B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a */; }; + 5CCD1A8A2B2A5D56001A4199 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A852B2A5D55001A4199 /* libgmpxx.a */; }; + 5CCD1A8B2B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A862B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a */; }; + 5CCD1A8C2B2A5D56001A4199 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A872B2A5D56001A4199 /* libffi.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, ); }; }; @@ -402,11 +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 = ""; }; + 5CCD1A832B2A5D55001A4199 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CCD1A842B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a"; sourceTree = ""; }; + 5CCD1A852B2A5D55001A4199 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CCD1A862B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a"; sourceTree = ""; }; + 5CCD1A872B2A5D56001A4199 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.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 = ""; }; @@ -517,13 +517,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CCD1A602B27927E001A4199 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CCD1A612B27927E001A4199 /* libgmp.a in Frameworks */, - 5CCD1A622B27927E001A4199 /* libffi.a in Frameworks */, + 5CCD1A8B2B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a in Frameworks */, + 5CCD1A8A2B2A5D56001A4199 /* libgmpxx.a in Frameworks */, + 5CCD1A882B2A5D56001A4199 /* libgmp.a in Frameworks */, + 5CCD1A8C2B2A5D56001A4199 /* libffi.a in Frameworks */, + 5CCD1A892B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.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; }; @@ -585,11 +585,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 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 */, + 5CCD1A872B2A5D56001A4199 /* libffi.a */, + 5CCD1A832B2A5D55001A4199 /* libgmp.a */, + 5CCD1A852B2A5D55001A4199 /* libgmpxx.a */, + 5CCD1A862B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a */, + 5CCD1A842B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a */, ); path = Libraries; sourceTree = ""; From 8cec5428ee59cd84c54929524d28496801ff7851 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 14 Dec 2023 17:08:40 +0400 Subject: [PATCH 20/69] core: save CIContent tag in chat_items table (#3555) --- simplex-chat.cabal | 1 + src/Simplex/Chat/Messages/CIContent.hs | 29 +++++++++++++++++++ .../Migrations/M20231214_item_content_tag.hs | 18 ++++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/Messages.hs | 9 +++--- src/Simplex/Chat/Store/Migrations.hs | 4 ++- 6 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d8c6f24fb4..ce066bc3c1 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -126,6 +126,7 @@ library Simplex.Chat.Migrations.M20231114_remote_control Simplex.Chat.Migrations.M20231126_remote_ctrl_address Simplex.Chat.Migrations.M20231207_chat_list_pagination + Simplex.Chat.Migrations.M20231214_item_content_tag Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 6b7e66bdb3..a987603bb6 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -574,3 +574,32 @@ dbParseACIContent = fmap aciContentDBJSON . J.eitherDecodeStrict' . encodeUtf8 -- platform specific instance FromJSON ACIContent where parseJSON = fmap aciContentJSON . J.parseJSON + +toCIContentTag :: CIContent e -> Text +toCIContentTag ciContent = case ciContent of + CISndMsgContent _ -> "sndMsgContent" + CIRcvMsgContent _ -> "rcvMsgContent" + CISndDeleted _ -> "sndDeleted" + CIRcvDeleted _ -> "rcvDeleted" + CISndCall {} -> "sndCall" + CIRcvCall {} -> "rcvCall" + CIRcvIntegrityError _ -> "rcvIntegrityError" + CIRcvDecryptionError {} -> "rcvDecryptionError" + CIRcvGroupInvitation {} -> "rcvGroupInvitation" + CISndGroupInvitation {} -> "sndGroupInvitation" + CIRcvDirectEvent _ -> "rcvDirectEvent" + CIRcvGroupEvent _ -> "rcvGroupEvent" + CISndGroupEvent _ -> "sndGroupEvent" + CIRcvConnEvent _ -> "rcvConnEvent" + CISndConnEvent _ -> "sndConnEvent" + CIRcvChatFeature {} -> "rcvChatFeature" + CISndChatFeature {} -> "sndChatFeature" + CIRcvChatPreference {} -> "rcvChatPreference" + CISndChatPreference {} -> "sndChatPreference" + CIRcvGroupFeature {} -> "rcvGroupFeature" + CISndGroupFeature {} -> "sndGroupFeature" + CIRcvChatFeatureRejected _ -> "rcvChatFeatureRejected" + CIRcvGroupFeatureRejected _ -> "rcvGroupFeatureRejected" + CISndModerated -> "sndModerated" + CIRcvModerated -> "rcvModerated" + CIInvalidJSON _ -> "invalidJSON" diff --git a/src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs b/src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs new file mode 100644 index 0000000000..cd4cd136e5 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231214_item_content_tag where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231214_item_content_tag :: Query +m20231214_item_content_tag = + [sql| +ALTER TABLE chat_items ADD COLUMN item_content_tag TEXT; +|] + +down_m20231214_item_content_tag :: Query +down_m20231214_item_content_tag = + [sql| +ALTER TABLE chat_items DROP COLUMN item_content_tag; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 3b83b132df..7f5945d394 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -379,7 +379,8 @@ CREATE TABLE chat_items( item_live INTEGER, item_deleted_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, item_deleted_ts TEXT, - forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL + forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + item_content_tag TEXT ); CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 87e6667124..b817c844d5 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -399,18 +399,19 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q -- user and IDs user_id, created_by_msg_id, contact_id, group_id, group_member_id, -- meta - item_sent, item_ts, item_content, item_text, item_status, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + item_sent, item_ts, item_content, item_content_tag, item_text, item_status, shared_msg_id, + forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index c8a04c42a1..af9985b837 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -92,6 +92,7 @@ import Simplex.Chat.Migrations.M20231113_group_forward import Simplex.Chat.Migrations.M20231114_remote_control import Simplex.Chat.Migrations.M20231126_remote_ctrl_address import Simplex.Chat.Migrations.M20231207_chat_list_pagination +import Simplex.Chat.Migrations.M20231214_item_content_tag import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -183,7 +184,8 @@ schemaMigrations = ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward), ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control), ("20231126_remote_ctrl_address", m20231126_remote_ctrl_address, Just down_m20231126_remote_ctrl_address), - ("20231207_chat_list_pagination", m20231207_chat_list_pagination, Just down_m20231207_chat_list_pagination) + ("20231207_chat_list_pagination", m20231207_chat_list_pagination, Just down_m20231207_chat_list_pagination), + ("20231214_item_content_tag", m20231214_item_content_tag, Just down_m20231214_item_content_tag) ] -- | The list of migrations in ascending order by date From 974fa448b4ec52387ca9e062734d3378d1967266 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 14 Dec 2023 21:11:19 +0800 Subject: [PATCH 21/69] android, desktop: some alerts became privacy sensitive (#3554) * android, desktop: some alerts became privacy sensitive * changes --- .../java/chat/simplex/app/MainActivity.kt | 4 +- .../kotlin/chat/simplex/common/App.kt | 21 ++++++-- .../chat/simplex/common/model/ChatModel.kt | 4 +- .../chat/simplex/common/model/SimpleXAPI.kt | 3 +- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../views/chatlist/ChatListNavLinkView.kt | 10 ++-- .../common/views/chatlist/ChatListView.kt | 9 +--- .../common/views/helpers/AlertManager.kt | 9 +++- .../common/views/localauth/LocalAuthView.kt | 3 +- .../common/views/newchat/ScanToConnectView.kt | 50 +++++++++---------- 10 files changed, 67 insertions(+), 48 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index cbe0ef7b16..082c10582c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -124,7 +124,9 @@ fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId(), uri.toURI(), ChatModel) + if (uri != null) { + chatModel.appOpenUrl.value = null to uri.toURI() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 4387adf95e..0082972c7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -162,11 +162,26 @@ fun MainScreen() { AuthView() } else { SplashView() + ModalManager.fullscreen.showPasscodeInView() + } + } else { + if (chatModel.showCallView.value) { + ActiveCallView() + } else { + // It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked + ModalManager.fullscreen.showPasscodeInView() + } + AlertManager.privacySensitive.showInView() + if (onboarding == OnboardingStage.OnboardingComplete) { + LaunchedEffect(chatModel.currentUser.value, chatModel.appOpenUrl.value) { + val (rhId, url) = chatModel.appOpenUrl.value ?: (null to null) + if (url != null) { + chatModel.appOpenUrl.value = null + connectIfOpenedViaUri(rhId, url, chatModel) + } + } } - } else if (chatModel.showCallView.value) { - ActiveCallView() } - ModalManager.fullscreen.showPasscodeInView() val invitation = chatModel.activeCallInvitation.value if (invitation != null) IncomingCallAlertView(invitation, chatModel) AlertManager.shared.showInView() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 25c87d64ad..2305862b68 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -70,8 +70,8 @@ object ChatModel { // Only needed during onboarding when user skipped password setup (left as random password) val desktopOnboardingRandomPassword = mutableStateOf(false) - // set when app is opened via contact or invitation URI - val appOpenUrl = mutableStateOf(null) + // set when app is opened via contact or invitation URI (rhId, uri) + val appOpenUrl = mutableStateOf?>(null) // preferences val notificationPreviewMode by lazy { 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 ad897c60fd..4af3e3f2ed 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 @@ -2023,7 +2023,8 @@ object ChatController { chatModel.chatId.value = null ModalManager.center.closeModals() ModalManager.end.closeModals() - AlertManager.shared.alertViews.clear() + AlertManager.shared.hideAllAlerts() + AlertManager.privacySensitive.hideAllAlerts() chatModel.currentRemoteHost.value = switchRemoteHost(rhId) reloadRemoteHosts() val user = apiGetActiveUser(rhId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index ddcfcf5947..b230d261f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -201,7 +201,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: // Image val drawable = getDrawableFromUri(uri) // Do not show alert in case it's already shown from the function above - bitmap = getBitmapFromUri(uri, withAlertOnException = AlertManager.shared.alertViews.isEmpty()) + bitmap = getBitmapFromUri(uri, withAlertOnException = !AlertManager.shared.hasAlertsShown()) if (isAnimImage(uri, drawable)) { // It's a gif or webp val fileSize = getFileSize(uri) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 9d662758f8..9ae0da2a31 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -611,12 +611,12 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( close: (() -> Unit)?, openChat: Boolean ) { - AlertManager.shared.showAlertDialogButtonsColumn( + AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName), buttons = { Column { SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { close?.invoke() val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) @@ -628,7 +628,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { close?.invoke() val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) @@ -640,7 +640,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -654,7 +654,7 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactI val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { chatModel.updateContact(rhId, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted), hostDevice = hostDevice(rhId), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index a91e5e7b3c..18252d0e22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -49,13 +49,6 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf LaunchedEffect(chatModel.clearOverlays.value) { if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false) } - LaunchedEffect(chatModel.appOpenUrl.value) { - val url = chatModel.appOpenUrl.value - if (url != null) { - chatModel.appOpenUrl.value = null - connectIfOpenedViaUri(chatModel.remoteHostId(), url, chatModel) - } - } if (appPlatform.isDesktop) { KeyChangeEffect(chatModel.chatId.value) { if (chatModel.chatId.value != null) { @@ -302,7 +295,7 @@ expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow Unit)>() + private var alertViews = mutableStateListOf<(@Composable () -> Unit)>() fun showAlert(alert: @Composable () -> Unit) { Log.d(TAG, "AlertManager.showAlert") @@ -35,6 +35,12 @@ class AlertManager { alertViews.removeLastOrNull() } + fun hideAllAlerts() { + alertViews.clear() + } + + fun hasAlertsShown() = alertViews.isNotEmpty() + fun showAlertDialogButtons( title: String, text: String? = null, @@ -220,6 +226,7 @@ class AlertManager { companion object { val shared = AlertManager() + val privacySensitive = AlertManager() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index c64c3dd29a..468dd8580e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -70,7 +70,8 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( m.controller.startChat(createdUser) } ModalManager.fullscreen.closeModals() - AlertManager.shared.hideAlert() + AlertManager.shared.hideAllAlerts() + AlertManager.privacySensitive.hideAllAlerts() completed(LAResult.Success) } catch (e: Exception) { completed(LAResult.Error(generalGetString(MR.strings.incorrect_passcode))) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 2439b16c36..9f28074aef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -20,7 +20,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.res.MR import java.net.URI @@ -58,7 +58,7 @@ suspend fun planAndConnect( InvitationLinkPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .InvitationLink, .OwnLink, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), @@ -80,13 +80,13 @@ suspend fun planAndConnect( val contact = connectionPlan.invitationLinkPlan.contact_ if (contact != null) { openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), hostDevice = hostDevice(rhId), ) } else { - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_already_connecting), generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link), hostDevice = hostDevice(rhId), @@ -97,7 +97,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), hostDevice = hostDevice(rhId), @@ -121,7 +121,7 @@ suspend fun planAndConnect( ContactAddressPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .ContactAddress, .OwnLink, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), @@ -141,7 +141,7 @@ suspend fun planAndConnect( ContactAddressPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingConfirmReconnect, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), @@ -162,7 +162,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), hostDevice = hostDevice(rhId), @@ -172,7 +172,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), hostDevice = hostDevice(rhId), @@ -193,7 +193,7 @@ suspend fun planAndConnect( GroupLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .GroupLink, .Ok, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), @@ -217,7 +217,7 @@ suspend fun planAndConnect( GroupLinkPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), @@ -238,12 +238,12 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingProhibit, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo_ if (groupInfo != null) { - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) ) } else { - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_already_joining_the_group), generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), hostDevice = hostDevice(rhId), @@ -254,7 +254,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo openKnownGroup(chatModel, rhId, close, groupInfo) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName), hostDevice = hostDevice(rhId), @@ -289,7 +289,7 @@ suspend fun connectViaUri( if (pcc != null) { chatModel.updateContactConnection(rhId, pcc) close?.invoke() - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = when (connLinkType) { @@ -320,14 +320,14 @@ fun askCurrentOrIncognitoProfileAlert( text: AnnotatedString? = null, connectDestructive: Boolean, ) { - AlertManager.shared.showAlertDialogButtonsColumn( + AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = title, text = text, buttons = { Column { val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } @@ -335,7 +335,7 @@ fun askCurrentOrIncognitoProfileAlert( Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } @@ -343,7 +343,7 @@ fun askCurrentOrIncognitoProfileAlert( Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -372,14 +372,14 @@ fun ownGroupLinkConfirmConnect( groupInfo: GroupInfo, close: (() -> Unit)?, ) { - AlertManager.shared.showAlertDialogButtonsColumn( + AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.connect_plan_join_your_group), text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)), buttons = { Column { // Open group SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() openKnownGroup(chatModel, rhId, close, groupInfo) }) { Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) @@ -387,7 +387,7 @@ fun ownGroupLinkConfirmConnect( if (incognito != null) { // Join incognito / Join with current profile SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } @@ -400,7 +400,7 @@ fun ownGroupLinkConfirmConnect( } else { // Use current profile SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } @@ -409,7 +409,7 @@ fun ownGroupLinkConfirmConnect( } // Use new incognito profile SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } @@ -419,7 +419,7 @@ fun ownGroupLinkConfirmConnect( } // Cancel SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } From 6fa0001ea72e80fd1124f59efa1f53ad665398cf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 18 Dec 2023 10:36:25 +0000 Subject: [PATCH 22/69] ios: delay suspendChat in NSE, background schedule depends on notifications mode (#3561) * ios: delay suspendChat in NSE * different background refresh interval depending on the settings * simplify * comment * reduce NSE suspend interval * space --- apps/ios/Shared/AppDelegate.swift | 2 +- apps/ios/Shared/Model/BGManager.swift | 30 +++++++++++++++---- .../ios/SimpleX NSE/NotificationService.swift | 25 ++++++++++++---- apps/ios/SimpleXChat/AppGroup.swift | 3 ++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index bb1de94359..145e362797 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -81,7 +81,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") - if m.ntfEnablePeriodic && allowBackgroundRefresh() { + if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo { receiveMessages(completionHandler) } else { completionHandler(.noData) diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index a39155efe8..25eab6c69e 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -16,7 +16,12 @@ private let receiveTaskId = "chat.simplex.app.receive" private let waitForMessages: TimeInterval = 6 // This is the smallest interval between refreshes, and also target interval in "off" mode -private let bgRefreshInterval: TimeInterval = 600 +private let bgRefreshInterval: TimeInterval = 600 // 10 minutes + +// This intervals are used for background refresh in instant and periodic modes +private let periodicBgRefreshInterval: TimeInterval = 1200 // 20 minutes + +private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes private let maxTimerCount = 9 @@ -34,14 +39,14 @@ class BGManager { } } - func schedule() { + func schedule(interval: TimeInterval? = nil) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.schedule: disabled") return } logger.debug("BGManager.schedule") let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) - request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval) + request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval) do { try BGTaskScheduler.shared.submit(request) } catch { @@ -49,20 +54,34 @@ class BGManager { } } + var runInterval: TimeInterval { + switch ChatModel.shared.notificationMode { + case .instant: maxBgRefreshInterval + case .periodic: periodicBgRefreshInterval + case .off: bgRefreshInterval + } + } + + var lastRanLongAgo: Bool { + Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval + } + private func handleRefresh(_ task: BGAppRefreshTask) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.handleRefresh: disabled") return } logger.debug("BGManager.handleRefresh") - schedule() - if allowBackgroundRefresh() { + let shouldRun_ = lastRanLongAgo + if allowBackgroundRefresh() && shouldRun_ { + schedule() let completeRefresh = completionHandler { task.setTaskCompleted(success: true) } task.expirationHandler = { completeRefresh("expirationHandler") } receiveMessages(completeRefresh) } else { + schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval) logger.debug("BGManager.completionHandler: already active, not started") task.setTaskCompleted(success: true) } @@ -91,6 +110,7 @@ class BGManager { } self.completed = false DispatchQueue.main.async { + chatLastBackgroundRunGroupDefault.set(Date.now) let m = ChatModel.shared if (!m.chatInitialized) { setAppState(.bgRefresh) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index eaa1131eb8..c286ee1c3c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -14,9 +14,11 @@ import SimpleXChat let logger = Logger() -let suspendingDelay: UInt64 = 2_500_000_000 +let appSuspendingDelay: UInt64 = 2_500_000_000 -let nseSuspendTimeout: Int = 10 +let nseSuspendDelay: TimeInterval = 2 + +let nseSuspendTimeout: Int = 5 typealias NtfStream = ConcurrentQueue @@ -177,6 +179,10 @@ class NSEThreads { return false } } + + var noThreads: Bool { + allThreads.isEmpty + } } // Notification service extension creates a new instance of the class and calls didReceive for each notification. @@ -261,7 +267,7 @@ class NotificationService: UNNotificationServiceExtension { let dbStatus = startChat() if case .ok = dbStatus, let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { - logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo), privacy: .public)") + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo.ntfMessages.count), privacy: .public)") if let connEntity = ntfInfo.connEntity_ { setBestAttemptNtf( ntfInfo.ntfsEnabled @@ -326,7 +332,15 @@ class NotificationService: UNNotificationServiceExtension { if let t = threadId { threadId = nil if NSEThreads.shared.endThread(t) { - suspendChat(nseSuspendTimeout) + logger.debug("NotificationService.deliverBestAttemptNtf: will suspend") + // suspension is delayed to allow chat core finalise any processing + // (e.g., send delivery receipts) + DispatchQueue.global().asyncAfter(deadline: .now() + nseSuspendDelay) { + if NSEThreads.shared.noThreads { + logger.debug("NotificationService.deliverBestAttemptNtf: suspending...") + suspendChat(nseSuspendTimeout) + } + } } } if let handler = contentHandler, let ntf = bestAttemptNtf { @@ -497,7 +511,7 @@ func suspendChat(_ timeout: Int) { NSEChatState.shared.set(.suspending) if apiSuspendChat(timeoutMicroseconds: timeout * 1000000) { - logger.debug("NotificationService: activateChat: after apiActivateChat") + logger.debug("NotificationService: suspendChat: after apiSuspendChat") DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: chatSuspended) } else { NSEChatState.shared.set(state) @@ -510,6 +524,7 @@ func chatSuspended() { if case .suspending = NSEChatState.shared.value { NSEChatState.shared.set(.suspended) chatCloseStore() + logger.debug("NotificationService chatSuspended: suspended") } } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 10625e2edf..f79c294e0c 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -15,6 +15,7 @@ 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" +public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used @@ -156,6 +157,8 @@ public let dbContainerGroupDefault = EnumDefault( public let chatLastStartGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_START) +public let chatLastBackgroundRunGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN) + public let ntfPreviewModeGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_PREVIEW_MODE, From f0338a03d1c986c33fb91711a58ea12e5ee21836 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 18 Dec 2023 10:41:08 +0000 Subject: [PATCH 23/69] directory: better search, allow both simplex:/ and simplex.chat links in description (#3546) * directory: new commands * better search * search test * return group links in simplex.chat domain, allow both simplex:/ and simplex.chat links in group description --- .../src/Directory/Events.hs | 24 +++- .../src/Directory/Options.hs | 2 + .../src/Directory/Search.hs | 32 +++++ .../src/Directory/Service.hs | 128 ++++++++++++++---- simplex-chat.cabal | 2 + tests/Bots/DirectoryTests.hs | 95 ++++++++++++- 6 files changed, 250 insertions(+), 33 deletions(-) create mode 100644 apps/simplex-directory-service/src/Directory/Search.hs diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 89231e4db1..a187ac3e82 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -21,14 +21,18 @@ where import Control.Applicative ((<|>)) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import Data.Functor (($>)) import Data.Text (Text) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Directory.Store import Simplex.Chat.Controller import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Types +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util ((<$?>)) import Data.Char (isSpace) import Data.Either (fromRight) @@ -83,6 +87,10 @@ deriving instance Show (SDirectoryRole r) data DirectoryCmdTag (r :: DirectoryRole) where DCHelp_ :: DirectoryCmdTag 'DRUser + DCSearchNext_ :: DirectoryCmdTag 'DRUser + DCAllGroups_ :: DirectoryCmdTag 'DRUser + DCRecentGroups_ :: DirectoryCmdTag 'DRUser + DCSubmitGroup_ :: DirectoryCmdTag 'DRUser DCConfirmDuplicateGroup_ :: DirectoryCmdTag 'DRUser DCListUserGroups_ :: DirectoryCmdTag 'DRUser DCDeleteGroup_ :: DirectoryCmdTag 'DRUser @@ -100,6 +108,10 @@ data ADirectoryCmdTag = forall r. ADCT (SDirectoryRole r) (DirectoryCmdTag r) data DirectoryCmd (r :: DirectoryRole) where DCHelp :: DirectoryCmd 'DRUser DCSearchGroup :: Text -> DirectoryCmd 'DRUser + DCSearchNext :: DirectoryCmd 'DRUser + DCAllGroups :: DirectoryCmd 'DRUser + DCRecentGroups :: DirectoryCmd 'DRUser + DCSubmitGroup :: ConnReqContact -> DirectoryCmd 'DRUser DCConfirmDuplicateGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser DCListUserGroups :: DirectoryCmd 'DRUser DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser @@ -120,7 +132,9 @@ deriving instance Show ADirectoryCmd directoryCmdP :: Parser ADirectoryCmd directoryCmdP = - (A.char '/' *> cmdStrP) <|> (ADC SDRUser . DCSearchGroup <$> A.takeText) + (A.char '/' *> cmdStrP) + <|> (A.char '.' $> ADC SDRUser DCSearchNext) + <|> (ADC SDRUser . DCSearchGroup <$> A.takeText) where cmdStrP = (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) @@ -128,6 +142,10 @@ directoryCmdP = tagP = A.takeTill (== ' ') >>= \case "help" -> u DCHelp_ "h" -> u DCHelp_ + "next" -> u DCSearchNext_ + "all" -> u DCAllGroups_ + "new" -> u DCRecentGroups_ + "submit" -> u DCSubmitGroup_ "confirm" -> u DCConfirmDuplicateGroup_ "list" -> u DCListUserGroups_ "ls" -> u DCListUserGroups_ @@ -146,6 +164,10 @@ directoryCmdP = cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r) cmdP = \case DCHelp_ -> pure DCHelp + DCSearchNext_ -> pure DCSearchNext + DCAllGroups_ -> pure DCAllGroups + DCRecentGroups_ -> pure DCRecentGroups + DCSubmitGroup_ -> fmap DCSubmitGroup . strDecode . encodeUtf8 <$?> (A.takeWhile1 isSpace *> A.takeText) DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup DCListUserGroups_ -> pure DCListUserGroups DCDeleteGroup_ -> gc DCDeleteGroup diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 0ca8cee789..6d4e1296f2 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -21,6 +21,7 @@ data DirectoryOpts = DirectoryOpts superUsers :: [KnownContact], directoryLog :: Maybe FilePath, serviceName :: String, + searchResults :: Int, testing :: Bool } @@ -54,6 +55,7 @@ directoryOpts appDir defaultDbFileName = do superUsers, directoryLog, serviceName, + searchResults = 10, testing = False } diff --git a/apps/simplex-directory-service/src/Directory/Search.hs b/apps/simplex-directory-service/src/Directory/Search.hs new file mode 100644 index 0000000000..822182b053 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Search.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Directory.Search where + +import Data.List (sortOn) +import Data.Ord (Down (..)) +import Data.Set (Set) +import qualified Data.Set as S +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Types + +data SearchRequest = SearchRequest + { searchType :: SearchType, + searchTime :: UTCTime, + sentGroups :: Set GroupId + } + +data SearchType = STAll | STRecent | STSearch Text + +takeTop :: Int -> [(GroupInfo, GroupSummary)] -> [(GroupInfo, GroupSummary)] +takeTop n = take n . sortOn (Down . currentMembers . snd) + +takeRecent :: Int -> [(GroupInfo, GroupSummary)] -> [(GroupInfo, GroupSummary)] +takeRecent n = take n . sortOn (Down . (\GroupInfo {createdAt} -> createdAt) . fst) + +groupIds :: [(GroupInfo, GroupSummary)] -> Set GroupId +groupIds = S.fromList . map (\(GroupInfo {groupId}, _) -> groupId) + +filterNotSent :: Set GroupId -> [(GroupInfo, GroupSummary)] -> [(GroupInfo, GroupSummary)] +filterNotSent sentGroups = filter (\(GroupInfo {groupId}, _) -> groupId `S.notMember` sentGroups) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index fb187bbebe..ea79dabb10 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -17,16 +17,16 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad import qualified Data.ByteString.Char8 as B -import Data.List (sortOn) import Data.Maybe (fromMaybe, maybeToList) -import Data.Ord (Down(..)) +import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (getCurrentTime) +import Data.Time.Clock (diffUTCTime, getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Directory.Events import Directory.Options +import Directory.Search import Directory.Store import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts @@ -36,8 +36,10 @@ import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Types -import Simplex.Chat.View (serializeChatResponse) +import Simplex.Chat.View (serializeChatResponse, simplexChatContact) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.TMap (TMap) +import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) import System.Directory (getAppUserDataDirectory) @@ -55,6 +57,15 @@ data GroupRolesStatus | GRSBadRoles deriving (Eq) +data ServiceState = ServiceState + { searchRequests :: TMap ContactId SearchRequest + } + +newServiceState :: IO ServiceState +newServiceState = do + searchRequests <- atomically TM.empty + pure ServiceState {searchRequests} + welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" @@ -65,8 +76,9 @@ welcomeGetOpts = do pure opts directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () -directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {userId} cc = do +directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testing} user@User {userId} cc = do initializeBotAddress' (not testing) cc + env <- newServiceState race_ (forever $ void getLine) . forever $ do (_, _, resp) <- atomically . readTBQueue $ outputQ cc forM_ (crDirectoryEvent resp) $ \case @@ -84,7 +96,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { DEItemEditIgnored _ct -> pure () DEItemDeleteIgnored _ct -> pure () DEContactCommand ct ciId aCmd -> case aCmd of - ADC SDRUser cmd -> deUserCommand ct ciId cmd + ADC SDRUser cmd -> deUserCommand env ct ciId cmd ADC SDRSuperUser cmd -> deSuperUserCommand ct ciId cmd where withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId @@ -105,8 +117,11 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already listed in the directory, please choose another name." getGroups :: Text -> IO (Maybe [(GroupInfo, GroupSummary)]) - getGroups search = - sendChatCmd cc (APIListGroups userId Nothing $ Just $ T.unpack search) >>= \case + getGroups = getGroups_ . Just + + getGroups_ :: Maybe Text -> IO (Maybe [(GroupInfo, GroupSummary)]) + getGroups_ search_ = + sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case CRGroupsList {groups} -> pure $ Just groups _ -> pure Nothing @@ -140,7 +155,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { sendMessage cc ct $ "Welcome to " <> serviceName <> " service!\n\ \Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\ - \For example, send _privacy_ to find groups about privacy.\n\n\ + \For example, send _privacy_ to find groups about privacy.\n\ + \Or send */all* or */new* to list groups.\n\n\ \Content and privacy policy: https://simplex.chat/docs/directory.html" deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO () @@ -201,7 +217,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { "Created the public link to join the group via this directory service that is always online.\n\n\ \Please add it to the group welcome message.\n\ \For example, add:" - notifyOwner gr $ "Link to join the group " <> T.unpack displayName <> ": " <> B.unpack (strEncode connReqContact) + notifyOwner gr $ "Link to join the group " <> T.unpack displayName <> ": " <> B.unpack (strEncode $ simplexChatContact connReqContact) CRChatCmdError _ (ChatError e) -> case e of CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." @@ -276,9 +292,10 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { where profileUpdate = \case CRGroupLink {connReqContact} -> - let groupLink = safeDecodeUtf8 $ strEncode connReqContact - hadLinkBefore = groupLink `isInfix` description p - hasLinkNow = groupLink `isInfix` description p' + let groupLink1 = safeDecodeUtf8 $ strEncode connReqContact + groupLink2 = safeDecodeUtf8 $ strEncode $ simplexChatContact connReqContact + hadLinkBefore = groupLink1 `isInfix` description p || groupLink2 `isInfix` description p + hasLinkNow = groupLink1 `isInfix` description p' || groupLink2 `isInfix` description p' in if | hadLinkBefore && hasLinkNow -> GPHasServiceLink | hadLinkBefore -> GPServiceLinkRemoved @@ -379,8 +396,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." - deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () - deUserCommand ct ciId = \case + deUserCommand :: ServiceState -> Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () + deUserCommand env@ServiceState {searchRequests} ct ciId = \case DCHelp -> sendMessage cc ct $ "You must be the owner to add the group to the directory:\n\ @@ -389,20 +406,25 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { \3. You will then need to add this link to the group welcome message.\n\ \4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\ \Start from inviting the bot to your group as admin - it will guide you through the process" - DCSearchGroup s -> - getGroups s >>= \case - Just groups -> - atomically (filterListedGroups st groups) >>= \case - [] -> sendReply "No groups found" - gs -> do - sendReply $ "Found " <> show (length gs) <> " group(s)" <> if length gs > 10 then ", sending 10." else "" - void . forkIO $ forM_ (take 10 $ sortOn (Down . currentMembers . snd) gs) $ - \(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" - text = groupInfoText p <> "\n" <> membersStr - msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - Nothing -> sendReply "Error: getGroups. Please notify the developers." + DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s + DCSearchNext -> + atomically (TM.lookup (contactId' ct) searchRequests) >>= \case + Just search@SearchRequest {searchType, searchTime} -> do + currentTime <- getCurrentTime + if diffUTCTime currentTime searchTime > 300 -- 5 minutes + then do + atomically $ TM.delete (contactId' ct) searchRequests + showAllGroups + else case searchType of + STSearch s -> withFoundListedGroups (Just s) $ sendNextSearchResults takeTop search + STAll -> withFoundListedGroups Nothing $ sendNextSearchResults takeTop search + STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search + Nothing -> showAllGroups + where + showAllGroups = deUserCommand env ct ciId DCAllGroups + DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll + DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent + DCSubmitGroup _link -> pure () DCConfirmDuplicateGroup ugrId gName -> atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found" @@ -429,6 +451,54 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { DCCommandError tag -> sendReply $ "Command error: " <> show tag where sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent + withFoundListedGroups s_ action = + getGroups_ s_ >>= \case + Just groups -> atomically (filterListedGroups st groups) >>= action + Nothing -> sendReply "Error: getGroups. Please notify the developers." + sendSearchResults s = \case + [] -> sendReply "No groups found" + gs -> do + let gs' = takeTop searchResults gs + moreGroups = length gs - length gs' + more = if moreGroups > 0 then ", sending top " <> show (length gs') else "" + sendReply $ "Found " <> show (length gs) <> " group(s)" <> more <> "." + updateSearchRequest (STSearch s) $ groupIds gs' + sendFoundGroups gs' moreGroups + sendAllGroups takeFirst sortName searchType = \case + [] -> sendReply "No groups listed" + gs -> do + let gs' = takeFirst searchResults gs + moreGroups = length gs - length gs' + more = if moreGroups > 0 then ", sending " <> sortName <> " " <> show (length gs') else "" + sendReply $ show (length gs) <> " group(s) listed" <> more <> "." + updateSearchRequest searchType $ groupIds gs' + sendFoundGroups gs' moreGroups + sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case + [] -> do + sendReply "Sorry, no more groups" + atomically $ TM.delete (contactId' ct) searchRequests + gs -> do + let gs' = takeFirst searchResults $ filterNotSent sentGroups gs + sentGroups' = sentGroups <> groupIds gs' + moreGroups = length gs - S.size sentGroups' + sendReply $ "Sending " <> show (length gs') <> " more group(s)." + updateSearchRequest searchType sentGroups' + sendFoundGroups gs' moreGroups + updateSearchRequest :: SearchType -> Set GroupId -> IO () + updateSearchRequest searchType sentGroups = do + searchTime <- getCurrentTime + let search = SearchRequest {searchType, searchTime, sentGroups} + atomically $ TM.insert (contactId' ct) search searchRequests + sendFoundGroups gs moreGroups = + void . forkIO $ do + forM_ gs $ + \(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do + let membersStr = "_" <> tshow currentMembers <> " members_" + text = groupInfoText p <> "\n" <> membersStr + msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ + sendComposedMessage cc ct Nothing msg + when (moreGroups > 0) $ + sendComposedMessage cc ct Nothing $ MCText $ "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)." deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ce066bc3c1..f3918dfecd 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -467,6 +467,7 @@ executable simplex-directory-service other-modules: Directory.Events Directory.Options + Directory.Search Directory.Service Directory.Store Paths_simplex_chat @@ -553,6 +554,7 @@ test-suite simplex-chat-test Broadcast.Options Directory.Events Directory.Options + Directory.Search Directory.Service Directory.Store Paths_simplex_chat diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index b31d6f36fe..3c6991bb52 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -30,6 +30,7 @@ directoryServiceTests = do it "should suspend and resume group" testSuspendResume it "should join found group via link" testJoinGroup it "should support group names with spaces" testGroupNameWithSpaces + it "should return more groups in search, all and recent groups" testSearchGroups describe "de-listing the group" $ do it "should de-list if owner leaves the group" testDelistedOwnerLeaves it "should de-list if owner is removed from the group" testDelistedOwnerRemoved @@ -67,6 +68,7 @@ mkDirectoryOpts tmp superUsers = superUsers, directoryLog = Just $ tmp "directory_service.log", serviceName = "SimpleX-Directory", + searchResults = 3, testing = True } @@ -158,7 +160,7 @@ testDirectoryService tmp = search u s welcome = do u #> ("@SimpleX-Directory " <> s) u <# ("SimpleX-Directory> > " <> s) - u <## " Found 1 group(s)" + u <## " Found 1 group(s)." u <# "SimpleX-Directory> PSA (Privacy, Security & Anonymity)" u <## "Welcome message:" u <## welcome @@ -206,7 +208,7 @@ testJoinGroup tmp = cath `connectVia` dsLink cath #> "@SimpleX-Directory privacy" cath <# "SimpleX-Directory> > privacy" - cath <## " Found 1 group(s)" + cath <## " Found 1 group(s)." cath <# "SimpleX-Directory> privacy (Privacy)" cath <## "Welcome message:" welcomeMsg <- getTermLine cath @@ -263,6 +265,92 @@ testGroupNameWithSpaces tmp = bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is listed in the directory again!" groupFound bob "Privacy & Security" +testSearchGroups :: HasCallStack => FilePath -> IO () +testSearchGroups tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + cath `connectVia` dsLink + forM_ [1..8 :: Int] $ \i -> registerGroupId superUser bob (groups !! (i - 1)) "" i i + connectUsers bob cath + fullAddMember "MyGroup" "" bob cath GRMember + joinGroup "MyGroup" cath bob + cath <## "#MyGroup: member SimpleX-Directory_1 is connected" + cath <## "contact and member are merged: SimpleX-Directory, #MyGroup SimpleX-Directory_1" + cath <## "use @SimpleX-Directory to send messages" + cath #> "@SimpleX-Directory MyGroup" + cath <# "SimpleX-Directory> > MyGroup" + cath <## " Found 7 group(s), sending top 3." + receivedGroup cath 0 3 + receivedGroup cath 1 2 + receivedGroup cath 2 2 + cath <# "SimpleX-Directory> Send /next or just . for 4 more result(s)." + cath #> "@SimpleX-Directory /next" + cath <# "SimpleX-Directory> > /next" + cath <## " Sending 3 more group(s)." + receivedGroup cath 3 2 + receivedGroup cath 4 2 + receivedGroup cath 5 2 + cath <# "SimpleX-Directory> Send /next or just . for 1 more result(s)." + -- search of another user does not affect the search of the first user + groupFound bob "Another" + cath #> "@SimpleX-Directory ." + cath <# "SimpleX-Directory> > ." + cath <## " Sending 1 more group(s)." + receivedGroup cath 6 2 + cath #> "@SimpleX-Directory /all" + cath <# "SimpleX-Directory> > /all" + cath <## " 8 group(s) listed, sending top 3." + receivedGroup cath 0 3 + receivedGroup cath 1 2 + receivedGroup cath 2 2 + cath <# "SimpleX-Directory> Send /next or just . for 5 more result(s)." + cath #> "@SimpleX-Directory /new" + cath <# "SimpleX-Directory> > /new" + cath <## " 8 group(s) listed, sending the most recent 3." + receivedGroup cath 7 2 + receivedGroup cath 6 2 + receivedGroup cath 5 2 + cath <# "SimpleX-Directory> Send /next or just . for 5 more result(s)." + cath #> "@SimpleX-Directory term3" + cath <# "SimpleX-Directory> > term3" + cath <## " Found 3 group(s)." + receivedGroup cath 4 2 + receivedGroup cath 5 2 + receivedGroup cath 6 2 + cath #> "@SimpleX-Directory term1" + cath <# "SimpleX-Directory> > term1" + cath <## " Found 6 group(s), sending top 3." + receivedGroup cath 1 2 + receivedGroup cath 2 2 + receivedGroup cath 3 2 + cath <# "SimpleX-Directory> Send /next or just . for 3 more result(s)." + cath #> "@SimpleX-Directory ." + cath <# "SimpleX-Directory> > ." + cath <## " Sending 3 more group(s)." + receivedGroup cath 4 2 + receivedGroup cath 5 2 + receivedGroup cath 6 2 + where + groups :: [String] + groups = + [ "MyGroup", + "MyGroup term1 1", + "MyGroup term1 2", + "MyGroup term1 term2", + "MyGroup term1 term2 term3", + "MyGroup term1 term2 term3 term4", + "MyGroup term1 term2 term3 term4 term5", + "Another" + ] + receivedGroup :: TestCC -> Int -> Int -> IO () + receivedGroup u ix count = do + u <#. ("SimpleX-Directory> " <> groups !! ix) + u <## "Welcome message:" + u <##. "Link to join the group " + u <## (show count <> " members") + testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -930,6 +1018,7 @@ u `connectVia` dsLink = do u <## "Send a search string to find groups or /help to learn how to add groups to directory." u <## "" u <## "For example, send privacy to find groups about privacy." + u <## "Or send /all or /new to list groups." u <## "" u <## "Content and privacy policy: https://simplex.chat/docs/directory.html" @@ -967,7 +1056,7 @@ groupFoundN :: Int -> TestCC -> String -> IO () groupFoundN count u name = do u #> ("@SimpleX-Directory " <> name) u <# ("SimpleX-Directory> > " <> name) - u <## " Found 1 group(s)" + u <## " Found 1 group(s)." u <#. ("SimpleX-Directory> " <> name) u <## "Welcome message:" u <##. "Link to join the group " From ce9218b186f1d53ee8dff66c5b3cef53309ef76c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 18 Dec 2023 22:04:49 +0400 Subject: [PATCH 24/69] ios: rework authentication (#3556) --- apps/ios/Shared/ContentView.swift | 157 +++++++++++------- apps/ios/Shared/Model/ChatModel.swift | 2 + apps/ios/Shared/SimpleXApp.swift | 52 ++---- .../Views/UserSettings/PrivacySettings.swift | 2 + 4 files changed, 118 insertions(+), 95 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b69ccbb7c7..d7b9fef218 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -14,11 +14,14 @@ struct ContentView: View { @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared @Environment(\.colorScheme) var colorScheme - @Binding var doAuthenticate: Bool - @Binding var userAuthorized: Bool? - @Binding var canConnectCall: Bool - @Binding var lastSuccessfulUnlock: TimeInterval? - @Binding var showInitializationView: Bool + + var contentAccessAuthenticationExtended: Bool + + @Environment(\.scenePhase) var scenePhase + @State private var automaticAuthenticationAttempted = false + @State private var canConnectViewCall = false + @State private var lastSuccessfulUnlock: TimeInterval? = nil + @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -40,9 +43,19 @@ struct ContentView: View { } } + private var accessAuthenticated: Bool { + chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended + } + var body: some View { ZStack { - contentView() + // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. + // i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() } + if !prefPerformLA || accessAuthenticated { + contentView() + } else { + lockButton() + } if chatModel.showCallView, let call = chatModel.activeCall { callView(call) } @@ -50,6 +63,7 @@ struct ContentView: View { LocalAuthView(authRequest: la) } else if showSetPasscode { SetAppPasscodeView { + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true showSetPasscode = false privacyLocalAuthModeDefault.set(.passcode) @@ -60,13 +74,9 @@ struct ContentView: View { alertManager.showAlert(laPasscodeNotSetAlert()) } } - } - .onAppear { - if prefPerformLA { requestNtfAuthorization() } - initAuthenticate() - } - .onChange(of: doAuthenticate) { _ in - initAuthenticate() + if chatModel.chatDbStatus == nil { + initializationView() + } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .sheet(isPresented: $showSettings) { @@ -76,14 +86,44 @@ struct ContentView: View { Button("System authentication") { initialEnableLA() } Button("Passcode entry") { showSetPasscode = true } } + .onChange(of: scenePhase) { phase in + logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") + switch (phase) { + case .background: + // also see .onChange(of: scenePhase) in SimpleXApp: on entering background + // it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false + automaticAuthenticationAttempted = false + canConnectViewCall = false + case .active: + canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently() + + // condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice + if prefPerformLA && !chatModel.contentViewAccessAuthenticated { + if AppChatState.shared.value != .stopped { + if contentAccessAuthenticationExtended { + chatModel.contentViewAccessAuthenticated = true + } else { + if !automaticAuthenticationAttempted { + automaticAuthenticationAttempted = true + // authenticate if call kit call is not in progress + if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) { + authenticateContentViewAccess() + } + } + } + } else { + // when app is stopped automatic authentication is not attempted + chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended + } + } + default: + break + } + } } @ViewBuilder private func contentView() -> some View { - if prefPerformLA && userAuthorized != true { - lockButton() - } else if chatModel.chatDbStatus == nil && showInitializationView { - initializationView() - } else if let status = chatModel.chatDbStatus, status != .ok { + if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) } else if !chatModel.v3DBMigration.startChat { MigrateToAppGroupView() @@ -106,11 +146,11 @@ struct ContentView: View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) .onDisappear { - if userAuthorized == false && doAuthenticate { runAuthenticate() } + if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() } } } else { - ActiveCallView(call: call, canConnectCall: $canConnectCall) - if prefPerformLA && userAuthorized != true { + ActiveCallView(call: call, canConnectCall: $canConnectViewCall) + if prefPerformLA && !accessAuthenticated { Rectangle() .fill(colorScheme == .dark ? .black : .white) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -120,22 +160,27 @@ struct ContentView: View { } private func lockButton() -> some View { - Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } + Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } private func initializationView() -> some View { VStack { ProgressView().scaleEffect(2) - Text("Opening database…") + Text("Opening app…") .padding() } + .frame(maxWidth: .infinity, maxHeight: .infinity ) + .background( + Rectangle() + .fill(.background) + ) } private func mainView() -> some View { ZStack(alignment: .top) { ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) .onAppear { - if !prefPerformLA { requestNtfAuthorization() } + requestNtfAuthorization() // Local Authentication notice is to be shown on next start after onboarding is complete if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) { prefLANoticeShown = true @@ -187,48 +232,37 @@ struct ContentView: View { } } - private func initAuthenticate() { - logger.debug("initAuthenticate") - if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { - userAuthorized = false - } else if doAuthenticate { - runAuthenticate() - } - } - - private func runAuthenticate() { - logger.debug("DEBUGGING: runAuthenticate") - if !prefPerformLA { - userAuthorized = true + private func unlockedRecently() -> Bool { + if let lastSuccessfulUnlock = lastSuccessfulUnlock { + return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 } else { - logger.debug("DEBUGGING: before dismissAllSheets") - dismissAllSheets(animated: false) { - logger.debug("DEBUGGING: in dismissAllSheets callback") - chatModel.chatId = nil - justAuthenticate() - } + return false } } - private func justAuthenticate() { - userAuthorized = false - let laMode = privacyLocalAuthModeDefault.get() - authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in - logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") - switch (laResult) { - case .success: - userAuthorized = true - canConnectCall = true - lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime - case .failed: - if laMode == .passcode { - AlertManager.shared.showAlert(laFailedAlert()) + private func authenticateContentViewAccess() { + logger.debug("DEBUGGING: authenticateContentViewAccess") + dismissAllSheets(animated: false) { + logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback") + chatModel.chatId = nil + + authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in + logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") + switch (laResult) { + case .success: + chatModel.contentViewAccessAuthenticated = true + canConnectViewCall = true + lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime + case .failed: + chatModel.contentViewAccessAuthenticated = false + if privacyLocalAuthModeDefault.get() == .passcode { + AlertManager.shared.showAlert(laFailedAlert()) + } + case .unavailable: + prefPerformLA = false + canConnectViewCall = true + AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } - case .unavailable: - userAuthorized = true - prefPerformLA = false - canConnectCall = true - AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } } } @@ -259,6 +293,7 @@ struct ContentView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true alertManager.showAlert(laTurnedOnAlert()) case .failed: diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e7932f2d92..0cc281fda9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,6 +54,8 @@ final class ChatModel: ObservableObject { @Published var chatDbChanged = false @Published var chatDbEncrypted: Bool? @Published var chatDbStatus: DBMigrationResult? + // local authentication + @Published var contentViewAccessAuthenticated: Bool = false @Published var laRequest: LocalAuthRequest? // list of chat "previews" @Published var chats: [Chat] = [] diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 057188c37c..c023f375d3 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -16,18 +16,13 @@ struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @ObservedObject var alertManager = AlertManager.shared + @Environment(\.scenePhase) var scenePhase - @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false - @State private var userAuthorized: Bool? - @State private var doAuthenticate = false - @State private var enteredBackground: TimeInterval? = nil - @State private var canConnectCall = false - @State private var lastSuccessfulUnlock: TimeInterval? = nil - @State private var showInitializationView = false + @State private var enteredBackgroundAuthenticated: TimeInterval? = nil init() { // DispatchQueue.global(qos: .background).sync { - haskell_init() + haskell_init() // hs_init(0, nil) // } UserDefaults.standard.register(defaults: appDefaults) @@ -39,21 +34,16 @@ struct SimpleXApp: App { } var body: some Scene { - return WindowGroup { - ContentView( - doAuthenticate: $doAuthenticate, - userAuthorized: $userAuthorized, - canConnectCall: $canConnectCall, - lastSuccessfulUnlock: $lastSuccessfulUnlock, - showInitializationView: $showInitializationView - ) + WindowGroup { + // contentAccessAuthenticationExtended has to be passed to ContentView on view initialization, + // so that it's computed by the time view renders, and not on event after rendering + ContentView(contentAccessAuthenticationExtended: !authenticationExpired()) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") chatModel.appOpenUrl = url } .onAppear() { - showInitializationView = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } @@ -62,21 +52,25 @@ struct SimpleXApp: App { logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") switch (phase) { case .background: + // --- authentication + // see ContentView .onChange(of: scenePhase) for remaining authentication logic + if chatModel.contentViewAccessAuthenticated { + enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime + } + chatModel.contentViewAccessAuthenticated = false + // authentication --- + if CallController.useCallKit() && chatModel.activeCall != nil { CallController.shared.shouldSuspendChat = true } else { suspendChat() BGManager.shared.schedule() } - if userAuthorized == true { - enteredBackground = ProcessInfo.processInfo.systemUptime - } - doAuthenticate = false - canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.shouldSuspendChat = false let appState = AppChatState.shared.value + if appState != .stopped { startChatAndActivate { if appState.inactive && chatModel.chatRunning == true { @@ -85,8 +79,6 @@ struct SimpleXApp: App { updateCallInvitations() } } - doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() } } default: @@ -121,22 +113,14 @@ struct SimpleXApp: App { } private func authenticationExpired() -> Bool { - if let enteredBackground = enteredBackground { + if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated { let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) - return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay + return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay } else { return true } } - private func unlockedRecently() -> Bool { - if let lastSuccessfulUnlock = lastSuccessfulUnlock { - return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 - } else { - return false - } - } - private func updateChats() { do { let chats = try apiGetChats() diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 90b83fa4f3..d8ff2c2f89 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -467,6 +467,7 @@ struct SimplexLockView: View { switch a { case .enableAuth: SetAppPasscodeView { + m.contentViewAccessAuthenticated = true laLockDelay = 30 prefPerformLA = true showChangePassword = true @@ -619,6 +620,7 @@ struct SimplexLockView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + m.contentViewAccessAuthenticated = true prefPerformLA = true laAlert = .laTurnedOnAlert case .failed: From 26a189917bb7a93b704dda09be2298d7c6e593e3 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 19 Dec 2023 05:37:10 +0800 Subject: [PATCH 25/69] sctipt: check string formatting (#3570) * sctipt: check string formatting * all --- apps/multiplatform/common/build.gradle.kts | 64 +++++++++++++++++-- .../commonMain/resources/MR/ar/strings.xml | 1 - .../commonMain/resources/MR/el/strings.xml | 2 +- .../commonMain/resources/MR/es/strings.xml | 2 +- .../commonMain/resources/MR/fi/strings.xml | 1 - .../commonMain/resources/MR/ja/strings.xml | 2 - .../commonMain/resources/MR/tr/strings.xml | 6 +- .../resources/MR/zh-rCN/strings.xml | 3 +- .../resources/MR/zh-rTW/strings.xml | 2 +- 9 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 4b0e38d8a0..32bfadd37e 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -155,6 +155,34 @@ afterEvaluate { val endTagRegex = Regex("]*>.*(<|>).*|[^>]*>.*(<|>).*") val correctHtmlRegex = Regex("[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*]*>.*.*") + val possibleFormat = listOf("s", "d", "1\$s", "1\$d", "2s", "f") + + fun String.id(): String = replace(" { + if (!contains("%")) return emptyList() + val value = substringAfter("\">").substringBeforeLast("") + + val formats = ArrayList() + var substring = value.substringAfter("%") + while (true) { + var foundFormat = false + for (format in possibleFormat) { + if (substring.startsWith(format)) { + formats.add(format) + foundFormat = true + break + } + } + if (!foundFormat) { + throw Exception("Unknown formatting in string. Add it to 'possibleFormat' in common/build.gradle.kts if needed: $this \nin $filepath") + } + val was = substring + substring = substring.substringAfter("%") + if (was.length == substring.length) break + } + return formats + } fun String.removeCDATA(): String = if (contains(" + val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath) }.asFileTree + val baseStringsFile = tree.first { it.absolutePath.endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") + val treeList = ArrayList(tree.toList()) + treeList.remove(baseStringsFile) + treeList.add(0, baseStringsFile) + val baseFormatting = mutableMapOf>() + treeList.forEachIndexed { index, file -> + val isBase = index == 0 val initialLines = ArrayList() val finalLines = ArrayList() + val errors = ArrayList() + file.useLines { lines -> val multiline = ArrayList() lines.forEach { line -> initialLines.add(line) if (stringRegex.matches(line)) { - finalLines.add(line.removeCDATA().addCDATA(file.absolutePath)) + val fixedLine = line.removeCDATA().addCDATA(file.absolutePath) + val lineId = fixedLine.id() + if (isBase) { + baseFormatting[lineId] = fixedLine.formatting(file.absolutePath) + } else if (baseFormatting[lineId] != fixedLine.formatting(file.absolutePath)) { + errors.add("Incorrect formatting in string: $fixedLine \nin ${file.absolutePath}") + } + finalLines.add(fixedLine) } else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) { multiline.add(line) } else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) { multiline.add(line) - finalLines.addAll(multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n")) + val fixedLines = multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n") + val fixedLinesJoined = fixedLines.joinToString("") + val lineId = fixedLinesJoined.id() + if (isBase) { + baseFormatting[lineId] = fixedLinesJoined.formatting(file.absolutePath) + } else if (baseFormatting[lineId] != fixedLinesJoined.formatting(file.absolutePath)) { + errors.add("Incorrect formatting in string: $fixedLinesJoined \nin ${file.absolutePath}") + } + finalLines.addAll(fixedLines) multiline.clear() } else if (multiline.isNotEmpty()) { multiline.add(line) @@ -217,10 +269,14 @@ afterEvaluate { } } if (multiline.isNotEmpty()) { - throw Exception("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}") + errors.add("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}") } } + if (errors.isNotEmpty()) { + throw Exception("Found errors: \n\n${errors.joinToString("\n\n")}") + } + if (!debug && finalLines != initialLines) { file.writer().use { finalLines.forEachIndexed { index, line -> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 098c748355..fd5a827ba2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -150,7 +150,6 @@ إضافة جهة اتصال جديدة : لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.]]> امسح رمز الاستجابة السريعة : للاتصال بجهة الاتصال التي تعرض لك رمز الاستجابة السريعة.]]> مكالمتك تحت الإجراء - انتهت المكالمة تغيير عبارة مرور قاعدة البيانات؟ لا يمكن الوصول إلى Keystore لحفظ كلمة مرور قاعدة البيانات إلغاء معاينة الملف diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 714f31732c..7063eb9007 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -41,7 +41,7 @@ Αποδοχή Αποδοχή αιτήματος σύνδεσης; αποδεκτή κλήση - Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα 9050; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. + Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. Προσθήκη διακομιστή… Προχωρημένες ρυθμίσεις δικτύου Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 08cc7f9820..381d28afa6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -298,7 +298,7 @@ Cancelar mensaje en directo Confirmar Vaciar - Build de la aplicación + Build de la aplicación: %s ¡La llamada ha terminado! el servidor de envío ha cambiado para tí cancelar vista previa del enlace diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index ce8692130f..5410778c44 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -171,7 +171,6 @@ Arkisto Poista keskusteluarkisto\? Luotu %1$s - %s:n rooli muutettu %s:ksi poistettu ryhmä yhdistää yhdistäminen (hyväksytty) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 94a35dbd8b..fb03dd1f28 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -687,7 +687,6 @@ ファイル送信が中止されました。 送信元が繋がりリクエストを削除したかもしれません。 このサーバで待ち行列を作るには認証が必要です。パスワードをご確認ください。 - アプリが定期的に新しいメッセージを受信します。一日の電池使用量が約3%で、プッシュ通知に頼らずに、あなたの端末のデータをサーバに送ることはありません。 SimpleXロック 通知を受けるには、データベースの暗証フレーズを入力してください。 SimpleX Chat サービス @@ -904,7 +903,6 @@ SIMPLEX CHATを支援 テストサーバ 受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。 - SimpleX バックグラウンド・サービス を使ってます。一日の電池使用量は約3%です。]]> あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに送られます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 9824b0d8f0..d7df9655d3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -337,7 +337,7 @@ Mesaj gönderilirken hata oluştu Adres oluştururken hata oluştu Adres değiştirirken hata oluştu - 1$s sizinle şu yolla bağlantı kurmak istiyor + %1$s sizinle şu yolla bağlantı kurmak istiyor Ayarları değiştirirken hata oluştu Toplu konuşma bağlantısı oluştururken hata oluştu Yetki değiştirirken hata oluştu @@ -747,9 +747,9 @@ Aklınızda bulunsun: kaybederseniz, parolayı kurtaramaz veya değiştiremezsiniz.]]> Sohbet arşivi SOHBET ARŞİVİ - 1$s grubuna davet + %1$s grubuna davet Gruba katıl\? - 1$s davet edildi + %1$s davet edildi grup bağlantınız üzerinden davet edildi davet edildi Gruba davet edin diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index ed2b9986c0..31be2f187c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1344,7 +1344,7 @@ 我们错过的第二个\"√\"!✅ 设定数据库密码 为群组禁用回执吗? - %s、%s 和 %d 已连接 + %s、%s 和 %s 已连接 修复群组成员不支持的问题 已为 %d 组启用送达回执功能 重新协商 @@ -1427,7 +1427,6 @@ 通过链接进行连接吗? 已经加入了该群组! %s、 %s 和 %d 名成员 - %s 审核了 %d 条消息 解封成员 连接到你自己? 轻按连接 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index b1d988e465..9caf45dcc1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -11,7 +11,7 @@ 關於 SimpleX Chat 接受連接請求? 已接受通話 - 要在端口啟用 SOCKS 代理伺服器嗎?在啟用這個選項之前,必須先啟用代理伺服器。 + 要在端口啟用 SOCKS 代理伺服器嗎 %d?在啟用這個選項之前,必須先啟用代理伺服器。 管理員 然後,選按: 新增預設伺服器 From 5e042d222eeb54b570946499f2381d142d9f3672 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 19 Dec 2023 18:24:13 +0800 Subject: [PATCH 26/69] desktop: saving qr code as an image (#3572) --- .../chat/simplex/common/views/newchat/QRCode.kt | 2 +- .../simplex/common/views/helpers/Utils.desktop.kt | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 763addae66..7f9fae60a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -67,7 +67,7 @@ fun QRCode( scope.launch { val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) .let { if (withLogo) it.addLogo() else it } - val file = saveTempImageUncompressed(image, false) + val file = saveTempImageUncompressed(image, true) if (file != null) { shareFile("", CryptoFile.plain(file.absolutePath)) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index eb1792474a..19c9fc0fd7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -5,11 +5,11 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density -import chat.simplex.common.model.* +import chat.simplex.common.model.CIFile +import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState -import java.io.ByteArrayInputStream -import java.io.File +import java.io.* import java.net.URI import javax.imageio.ImageIO import kotlin.io.encoding.Base64 @@ -148,9 +148,8 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return if (file != null) { try { val ext = if (asPng) "png" else "jpg" - val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(getAppFilePath("")))) - // LALAL FILE IS EMPTY - ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(file.absolutePath))) + ImageIO.write(image.toAwtImage(), ext, newFile.outputStream()) newFile } catch (e: Exception) { Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}") From 7b073ba9f83e808bc19cff24bbe2732dfd630bd9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 19 Dec 2023 10:26:01 +0000 Subject: [PATCH 27/69] core: allow deleting last user (#3567) * core: allow deleting last user (tests fail) * tests, allow activating the hidden user when there is no active user * hide logs Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * comment Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * comment Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- src/Simplex/Chat.hs | 25 +++++++++------ src/Simplex/Chat/View.hs | 4 ++- tests/ChatClient.hs | 4 +-- tests/ChatTests/Direct.hs | 67 ++++++++++++++++++++++++--------------- 4 files changed, 61 insertions(+), 39 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dbccfbdfcf..6b619c5bd6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -473,12 +473,14 @@ processChatCommand = \case coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withStoreCtx' (Just "ListUsers, getUsersInfo") getUsersInfo - APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do + APISetActiveUser userId' viewPwd_ -> do + unlessM chatStarted $ throwChatError CEChatNotStarted + user_ <- chatReadVar currentUser user' <- privateGetUser userId' - validateUserPassword user user' viewPwd_ + validateUserPassword_ user_ user' viewPwd_ withStoreCtx' (Just "APISetActiveUser, setActiveUser") $ \db -> setActiveUser db userId' let user'' = user' {activeUser = True} - asks currentUser >>= atomically . (`writeTVar` Just user'') + chatWriteVar currentUser $ Just user'' pure $ CRActiveUser user'' SetActiveUser uName viewPwd_ -> do tryChatError (withStore (`getUserIdByName` uName)) >>= \case @@ -2300,11 +2302,14 @@ processChatCommand = \case tryChatError (withStore (`getUser` userId)) >>= \case Left _ -> throwChatError CEUserUnknown Right user -> pure user - validateUserPassword :: User -> User -> Maybe UserPwd -> m () - validateUserPassword User {userId} User {userId = userId', viewPwdHash} viewPwd_ = + validateUserPassword :: User -> User -> Maybe UserPwd -> m () + validateUserPassword = validateUserPassword_ . Just + validateUserPassword_ :: Maybe User -> User -> Maybe UserPwd -> m () + validateUserPassword_ user_ User {userId = userId', viewPwdHash} viewPwd_ = forM_ viewPwdHash $ \pwdHash -> - let pwdOk = case viewPwd_ of - Nothing -> userId == userId' + let userId_ = (\User {userId} -> userId) <$> user_ + pwdOk = case viewPwd_ of + Nothing -> userId_ == Just userId' Just (UserPwd viewPwd) -> validPassword viewPwd pwdHash in unless pwdOk $ throwChatError CEUserUnknown validPassword :: Text -> UserPwdHash -> Bool @@ -2327,16 +2332,16 @@ processChatCommand = \case pure $ CRUserPrivacy {user, updatedUser = user'} checkDeleteChatUser :: User -> m () checkDeleteChatUser user@User {userId} = do - when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) users <- withStore' getUsers - unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $ - throwChatError (CECantDeleteLastUser userId) + let otherVisible = filter (\User {userId = userId', viewPwdHash} -> userId /= userId' && isNothing viewPwdHash) users + when (activeUser user && length otherVisible > 0) $ throwChatError (CECantDeleteActiveUser userId) deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues withStore' (`deleteUserRecord` user) + when (activeUser user) $ chatWriteVar currentUser Nothing ok_ updateChatSettings :: ChatName -> (ChatSettings -> ChatSettings) -> m ChatResponse updateChatSettings (ChatName cType name) updateSettings = withUser $ \user -> do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4f7c8698bf..b0408690ae 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -474,7 +474,9 @@ chatItemDeletedText ChatItem {meta = CIMeta {itemDeleted}, content} membership_ _ -> "" viewUsersList :: [UserInfo] -> [StyledString] -viewUsersList = mapMaybe userInfo . sortOn ldn +viewUsersList us = + let ss = mapMaybe userInfo $ sortOn ldn us + in if null ss then ["no users"] else ss where ldn (UserInfo User {localDisplayName = n} _) = T.toLower n userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser, showNtfs, viewPwdHash} count) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 74e29cb0b1..821d7b032b 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -18,7 +18,7 @@ import Control.Monad.Except import Data.ByteArray (ScrubbedBytes) import Data.Functor (($>)) import Data.List (dropWhileEnd, find) -import Data.Maybe (fromJust, isNothing) +import Data.Maybe (isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat @@ -284,7 +284,7 @@ getTermLine cc = _ -> error "no output for 5 seconds" userName :: TestCC -> IO [Char] -userName (TestCC ChatController {currentUser} _ _ _ _ _) = T.unpack . localDisplayName . fromJust <$> readTVarIO currentUser +userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (T.unpack . localDisplayName) <$> readTVarIO currentUser testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7d299e296d..64fa6ff3bf 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1492,16 +1492,16 @@ testDeleteUser = \alice bob cath dan -> do connectUsers alice bob - -- cannot delete active user + alice ##> "/create user alisa" + showActiveUser alice "alisa" - alice ##> "/_delete user 1 del_smp=off" + -- cannot delete active user when there is another user + + alice ##> "/_delete user 2 del_smp=off" alice <## "cannot delete active user" -- delete user without deleting SMP queues - alice ##> "/create user alisa" - showActiveUser alice "alisa" - connectUsers alice cath alice <##> cath @@ -1519,17 +1519,7 @@ testDeleteUser = -- no connection authorization error - connection wasn't deleted (alice "/delete user alisa" - alice <## "cannot delete active user" - - alice ##> "/users" - alice <## "alisa (active)" - - alice <##> cath - - -- delete user deleting SMP queues + -- cannot delete active user when there is another user alice ##> "/create user alisa2" showActiveUser alice "alisa2" @@ -1537,10 +1527,17 @@ testDeleteUser = connectUsers alice dan alice <##> dan + alice ##> "/delete user alisa2" + alice <## "cannot delete active user" + alice ##> "/users" alice <## "alisa" alice <## "alisa2 (active)" + alice <##> dan + + -- delete user deleting SMP queues + alice ##> "/delete user alisa" alice <### ["ok", "completed deleting user"] @@ -1553,6 +1550,16 @@ testDeleteUser = alice <##> dan + -- delete last active user + + alice ##> "/delete user alisa2 del_smp=off" + alice <### ["ok", "completed deleting user"] + alice ##> "/users" + alice <## "no users" + + alice ##> "/create user alisa3" + showActiveUser alice "alisa3" + testUsersDifferentCIExpirationTTL :: HasCallStack => FilePath -> IO () testUsersDifferentCIExpirationTTL tmp = do withNewTestChat tmp "bob" bobProfile $ \bob -> do @@ -2047,12 +2054,23 @@ testUserPrivacy = userVisible alice "current " alice ##> "/hide user new_password" userHidden alice "current " - alice ##> "/_delete user 1 del_smp=on" - alice <## "cannot delete last user" - alice ##> "/_hide user 1 \"password\"" - alice <## "cannot hide the only not hidden user" alice ##> "/user alice" showActiveUser alice "alice (Alice)" + -- delete last visible active user + alice ##> "/_delete user 1 del_smp=on" + alice <### ["ok", "completed deleting user"] + -- hidden user is not shown + alice ##> "/users" + alice <## "no users" + -- but it is still possible to switch to it + alice ##> "/user alisa wrong_password" + alice <## "user does not exist or incorrect password" + alice ##> "/user alisa new_password" + showActiveUser alice "alisa" + alice ##> "/create user alisa2" + showActiveUser alice "alisa2" + alice ##> "/_hide user 3 \"password2\"" + alice <## "cannot hide the only not hidden user" -- change profile privacy for inactive user via API requires correct password alice ##> "/_unmute user 2" alice <## "hidden user always muted when inactive" @@ -2064,17 +2082,14 @@ testUserPrivacy = userVisible alice "" alice ##> "/_hide user 2 \"another_password\"" userHidden alice "" - alice ##> "/user alisa another_password" - showActiveUser alice "alisa" - alice ##> "/user alice" - showActiveUser alice "alice (Alice)" alice ##> "/_delete user 2 del_smp=on" alice <## "user does not exist or incorrect password" alice ##> "/_delete user 2 del_smp=on \"wrong_password\"" alice <## "user does not exist or incorrect password" alice ##> "/_delete user 2 del_smp=on \"another_password\"" - alice <## "ok" - alice <## "completed deleting user" + alice <### ["ok", "completed deleting user"] + alice ##> "/_delete user 3 del_smp=on" + alice <### ["ok", "completed deleting user"] where userHidden alice current = do alice <## (current <> "user alisa:") From 6ba3100d348e23549245e2d435fa9815108584e4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 20 Dec 2023 06:38:39 +0000 Subject: [PATCH 28/69] core: batch sending messages (#3566) * core: batch sending messages * batch without iorefs (#3573) * one-pass * simplexmq * simplexmq * simplexmq * simplexmq * revert change to ios project file * refactor * simplify --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 14 ++--- src/Simplex/Chat.hs | 96 +++++++++++++++++++++------------- src/Simplex/Chat/Controller.hs | 15 ++++++ tests/ChatClient.hs | 1 + 7 files changed, 87 insertions(+), 45 deletions(-) diff --git a/cabal.project b/cabal.project index 873035d7ab..e81c21c990 100644 --- a/cabal.project +++ b/cabal.project @@ -14,7 +14,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 18be2709f59a4cb20fe9758b899622092dba062e + tag: 8c250ebe19f56dd7d53572d984e8016cb0e4d658 source-repository-package type: git diff --git a/package.yaml b/package.yaml index af58ce6729..65f99a7a78 100644 --- a/package.yaml +++ b/package.yaml @@ -45,7 +45,7 @@ dependencies: - sqlcipher-simple == 0.4.* - stm == 2.5.* - terminal == 0.2.* - - time == 1.9.* + - time == 1.12.* - tls >= 1.7.0 && < 1.8 - unliftio == 0.2.* - unliftio-core == 0.2.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3733163f49..9f06b66101 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."18be2709f59a4cb20fe9758b899622092dba062e" = "08dr4vyg1wz2z768iikg8fks5zqf4dw5myr87hbpv964idda3pmj"; + "https://github.com/simplex-chat/simplexmq.git"."8c250ebe19f56dd7d53572d984e8016cb0e4d658" = "080rw86yncf1h3zr5a8y65cndihq6f3ji43vxrdhr2mrb75vmw8m"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f3918dfecd..6462d26008 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -199,7 +199,7 @@ library , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -259,7 +259,7 @@ executable simplex-bot , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -319,7 +319,7 @@ executable simplex-bot-advanced , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -381,7 +381,7 @@ executable simplex-broadcast-bot , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -442,7 +442,7 @@ executable simplex-chat , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -508,7 +508,7 @@ executable simplex-directory-service , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* @@ -602,7 +602,7 @@ test-suite simplex-chat-test , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* + , time ==1.12.* , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6b619c5bd6..4e7a1cab9a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -35,7 +35,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char import Data.Constraint (Dict (..)) -import Data.Either (fromRight, partitionEithers, rights) +import Data.Either (fromRight, lefts, partitionEithers, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) @@ -5002,7 +5002,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - void . sendGroupMessage' user [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ + sendGroupMemberMessage user reMember (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -5529,46 +5529,62 @@ directMessage chatMsgEvent = do pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 -deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do - let msgFlags = MsgFlags {notification = hasNotification cmEventTag} - agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgFlags msgBody - let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - withStore' $ \db -> createSndMsgDelivery db sndMsgDelivery msgId +deliverMessage conn cmEventTag msgBody msgId = + deliverMessages [(conn, cmEventTag, msgBody, msgId)] >>= \case + [r] -> liftEither r + rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) + +deliverMessages :: ChatMonad' m => [(Connection, CMEventTag e, MsgBody, MessageId)] -> m [Either ChatError Int64] +deliverMessages msgReqs = do + sent <- zipWith prepareBatch msgReqs <$> withAgent' (`sendMessages` aReqs) + withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent + where + aReqs = map (\(conn, cmEvTag, msgBody, _msgId) -> (aConnId conn, msgFlags cmEvTag, msgBody)) msgReqs + msgFlags cmEvTag = MsgFlags {notification = hasNotification cmEvTag} + prepareBatch req = bimap (`ChatErrorAgent` Nothing) (req,) + createDelivery :: DB.Connection -> ((Connection, CMEventTag e, MsgBody, MessageId), AgentMsgId) -> IO (Either ChatError Int64) + createDelivery db ((Connection {connId}, _, _, msgId), agentMsgId) = + Right <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) -sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = - sendGroupMessage' user members chatMsgEvent groupId Nothing $ pure () - -sendGroupMessage' :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m (SndMessage, [GroupMember]) -sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do - msg <- createSndMessage chatMsgEvent (GroupId groupId) - -- TODO collect failed deliveries into a single error +sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = do + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) $ \GroupMember {memberRole} -> memberRole - rs <- forM recipientMembers $ \m -> - messageMember m msg `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) - let sentToMembers = catMaybes rs + let tag = toCMEventTag chatMsgEvent + (toSend, pending) = foldr addMember ([], []) recipientMembers + msgReqs = map (\(_, conn) -> (conn, tag, msgBody, msgId)) toSend + delivered <- deliverMessages msgReqs + let errors = lefts delivered + unless (null errors) $ toView $ CRChatErrors (Just user) errors + stored <- withStoreBatch' $ \db -> map (\m -> createPendingGroupMessage db (groupMemberId' m) msgId Nothing) pending + let sentToMembers = filterSent delivered toSend fst <> filterSent stored pending id pure (msg, sentToMembers) where - messageMember :: GroupMember -> SndMessage -> m (Maybe GroupMember) - messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of - Nothing -> pendingOrForwarded - Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted -> pure Nothing - | connStatus == ConnSndReady || connStatus == ConnReady -> do - let tag = toCMEventTag chatMsgEvent - deliverMessage conn tag msgBody msgId >> postDeliver - pure $ Just m - | otherwise -> pendingOrForwarded + addMember m (toSend, pending) = case memberSendAction chatMsgEvent members m of + Just (MSASend conn) -> ((m, conn) : toSend, pending) + Just MSAPending -> (toSend, m : pending) + Nothing -> (toSend, pending) + filterSent :: [Either ChatError a] -> [mem] -> (mem -> GroupMember) -> [GroupMember] + filterSent rs ms mem = [mem m | (Right _, m) <- zip rs ms] + +data MemberSendAction = MSASend Connection | MSAPending + +memberSendAction :: ChatMsgEvent e -> [GroupMember] -> GroupMember -> Maybe MemberSendAction +memberSendAction chatMsgEvent members m = case memberConn m of + Nothing -> pendingOrForwarded + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted -> Nothing + | connStatus == ConnSndReady || connStatus == ConnReady -> Just (MSASend conn) + | otherwise -> pendingOrForwarded + where + pendingOrForwarded + | forwardSupported && isForwardedGroupMsg chatMsgEvent = Nothing + | isXGrpMsgForward chatMsgEvent = Nothing + | otherwise = Just MSAPending where - pendingOrForwarded - | forwardSupported && isForwardedGroupMsg chatMsgEvent = pure Nothing - | isXGrpMsgForward chatMsgEvent = pure Nothing - | otherwise = do - withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ - pure $ Just m - forwardSupported = do + forwardSupported = let mcvr = memberChatVRange' m - isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward + in isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward invitingMemberSupportsForward = case m.invitedByGroupMemberId of Just invMemberId -> -- can be optimized for large groups by replacing [GroupMember] with Map GroupMemberId GroupMember @@ -5582,6 +5598,16 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do XGrpMsgForward {} -> True _ -> False +sendGroupMemberMessage :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> GroupMember -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m () +sendGroupMemberMessage user m@GroupMember {groupMemberId} chatMsgEvent groupId introId_ postDeliver = do + msg <- createSndMessage chatMsgEvent (GroupId groupId) + messageMember msg `catchChatError` (\e -> toView (CRChatError (Just user) e)) + where + messageMember :: SndMessage -> m () + messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction chatMsgEvent [m] m) $ \case + MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver + MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + shuffleMembers :: [a] -> (a -> GroupMemberRole) -> IO [a] shuffleMembers ms role = do let (adminMs, otherMs) = partition ((GRAdmin <=) . role) ms diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 8446c15a81..70e0cc64fc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,6 +84,7 @@ import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitatio import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) +import qualified UnliftIO.Exception as E import UnliftIO.STM versionNumber :: String @@ -1287,12 +1288,26 @@ withStoreCtx ctx_ action = do handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr +withStoreBatch :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO (Either ChatError a))) -> m (t (Either ChatError a)) +withStoreBatch actions = do + ChatController {chatStore} <- ask + liftIO $ withTransaction chatStore $ mapM (`E.catch` handleInternal) . actions + where + handleInternal :: E.SomeException -> IO (Either ChatError a) + handleInternal = pure . Left . ChatError . CEInternalError . show + +withStoreBatch' :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO a)) -> m (t (Either ChatError a)) +withStoreBatch' actions = withStoreBatch $ fmap (fmap Right) . actions + withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a withAgent action = asks smpAgent >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) +withAgent' :: ChatMonad' m => (AgentClient -> m a) -> m a +withAgent' action = asks smpAgent >>= action + $(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CLQ") ''ChatListQuery) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 821d7b032b..c32d8002b9 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -353,6 +353,7 @@ serverCfg = serverStatsBackupFile = Nothing, smpServerVRange = supportedSMPServerVRange, transportConfig = defaultTransportServerConfig, + smpHandshakeTimeout = 1000000, controlPort = Nothing } From 4a4d470859e86b44ebf61447a505957c54ffcf5e Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 21 Dec 2023 02:00:44 +0800 Subject: [PATCH 29/69] android, desktop: try-catch composables (#3575) * android, desktop: try-catch composables * test * better catching on Android * more try-catch'es * Revert "test" This reverts commit adaf92b116fd8453d44cd401055fb0904f41f23c. * more try-catch'es * unneeded imports --- .../main/java/chat/simplex/app/SimplexApp.kt | 17 ++++++ .../simplex/common/platform/UI.android.kt | 34 ++++++----- .../kotlin/chat/simplex/common/App.kt | 8 ++- .../simplex/common/views/chat/ChatView.kt | 6 +- .../views/chat/item/CIBrokenComposableView.kt | 18 ++++++ .../views/chatlist/ChatListNavLinkView.kt | 60 ++++++++++++++++--- .../common/views/chatlist/ChatListView.kt | 26 ++++++-- .../common/views/chatlist/ShareListView.kt | 10 ++-- .../simplex/common/views/helpers/Utils.kt | 22 +++++++ .../commonMain/resources/MR/base/strings.xml | 3 + .../kotlin/chat/simplex/common/DesktopApp.kt | 1 + 11 files changed, 169 insertions(+), 36 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index a345e6e48f..e3f4e69bd4 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -1,6 +1,8 @@ package chat.simplex.app import android.app.Application +import android.os.Handler +import android.os.Looper import chat.simplex.common.platform.Log import androidx.lifecycle.* import androidx.work.* @@ -35,6 +37,21 @@ class SimplexApp: Application(), LifecycleEventObserver { return } else { registerGlobalErrorHandler() + Handler(Looper.getMainLooper()).post { + while (true) { + try { + Looper.loop() + } catch (e: Throwable) { + if (e.message != null && e.message!!.startsWith("Unable to start activity")) { + android.os.Process.killProcess(android.os.Process.myPid()) + break + } else { + // Send it to our exception handled because it will not get the exception otherwise + Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException(Looper.getMainLooper().thread, e) + } + } + } + } } context = this initHaskell() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index 96bb739113..371c140133 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -4,7 +4,7 @@ import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo import android.graphics.Rect -import android.os.Build +import android.os.* import android.view.* import android.view.inputmethod.InputMethodManager import android.widget.Toast @@ -12,7 +12,6 @@ import androidx.activity.compose.setContent import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen -import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 import chat.simplex.res.MR @@ -79,6 +78,7 @@ actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFi actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { actual override fun uncaughtException(thread: Thread, e: Throwable) { Log.e(TAG, "App crashed, thread name: " + thread.name + ", exception: " + e.stackTraceToString()) + includeMoreFailedComposables() if (ModalManager.start.hasModalsOpen()) { ModalManager.start.closeModal() } else if (chatModel.chatId.value != null) { @@ -93,19 +93,25 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { chatModel.callManager.endCall(it) } } - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.app_was_crashed), - text = e.stackTraceToString() - ) - //mainActivity.get()?.recreate() - mainActivity.get()?.apply { - window - ?.decorView - ?.findViewById(android.R.id.content) - ?.removeViewAt(0) - setContent { - AppScreen() + if (thread.name == "main") { + mainActivity.get()?.recreate() + } else { + mainActivity.get()?.apply { + window + ?.decorView + ?.findViewById(android.R.id.content) + ?.removeViewAt(0) + setContent { + AppScreen() + } } } + // Wait until activity recreates to prevent showing two alerts (in case `main` was crashed) + Handler(Looper.getMainLooper()).post { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_was_crashed), + text = e.stackTraceToString() + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 0082972c7a..d457eb57a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -332,9 +332,11 @@ fun DesktopScreen(settingsState: SettingsViewState) { ) } VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) - UserPicker(chatModel, userPickerState) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } - userPickerState.value = AnimatedViewState.GONE + tryOrShowError("UserPicker", error = {}) { + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE + } } ModalManager.fullscreen.showInView() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 8eee43035b..ebec780df7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -900,7 +900,11 @@ fun BoxWithConstraintsScope.ChatItemsList( @Composable fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + tryOrShowError("${cItem.id}ChatItem", error = { + CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) + }) { + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt new file mode 100644 index 0000000000..d49f8526d5 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt @@ -0,0 +1,18 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import chat.simplex.res.MR + +@Composable +fun CIBrokenComposableView(alignment: Alignment) { + Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), contentAlignment = alignment) { + Text(stringResource(MR.strings.error_showing_message), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 9ae0da2a31..8d5446aa53 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -61,9 +62,17 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) + } + }, click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, - dropdownMenuItems = { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) + } + }, showMenu, stopped, selectedChat @@ -71,25 +80,45 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) + } + }, click = { if (!inProgress.value) groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) }, - dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) + } + }, showMenu, stopped, selectedChat ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( - chatLinkPreview = { ContactRequestView(chat.chatInfo) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ContactRequestView(chat.chatInfo) + } + }, click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) }, - dropdownMenuItems = { ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) + } + }, showMenu, stopped, selectedChat ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( - chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ContactConnectionView(chat.chatInfo.contactConnection) + } + }, click = { ModalManager.center.closeModals() ModalManager.end.closeModals() @@ -97,7 +126,11 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) } }, - dropdownMenuItems = { ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) + } + }, showMenu, stopped, selectedChat @@ -105,7 +138,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.InvalidJSON -> ChatListNavLinkLayout( chatLinkPreview = { - InvalidDataView() + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + InvalidDataView() + } }, click = { ModalManager.end.closeModals() @@ -119,6 +154,13 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } +@Composable +private fun ErrorChatListItem() { + Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) { + Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) + } +} + fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 18252d0e22..cf12727d74 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* +import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight @@ -64,7 +65,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val (userPickerState, scaffoldState ) = settingsState Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, scaffoldState = scaffoldState, - drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, + drawerContent = { + tryOrShowError("Settings", error = { ErrorSettingsView() }) { + SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) + } + }, drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerGesturesEnabled = appPlatform.isAndroid, floatingActionButton = { @@ -111,12 +116,16 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf if (searchInList.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) // TODO disable this button and sheet for the duration of the switch - NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + tryOrShowError("NewChatSheet", error = {}) { + NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + } } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } - userPickerState.value = AnimatedViewState.GONE + tryOrShowError("UserPicker", error = {}) { + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE + } } } } @@ -303,6 +312,13 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { } } +@Composable +private fun ErrorSettingsView() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) + } +} + private var lazyListState = 0 to 0 @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 8338d2960f..ac8331007e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -47,10 +47,12 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe } } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = { - chatModel.sharedContent.value = null - userPickerState.value = AnimatedViewState.GONE - }) + tryOrShowError("UserPicker", error = {}) { + UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = { + chatModel.sharedContent.value = null + userPickerState.value = AnimatedViewState.GONE + }) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 0a0ef17c4b..9a81b9f9d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -390,6 +390,28 @@ fun IntSize.Companion.Saver(): Saver = Saver( restore = { IntSize(it.first, it.second) } ) +private var lastExecutedComposables = HashSet() +private val failedComposables = HashSet() + +@Composable +fun tryOrShowError(key: Any = Exception().stackTraceToString().lines()[2], error: @Composable () -> Unit = {}, content: @Composable () -> Unit) { + if (!failedComposables.contains(key)) { + lastExecutedComposables.add(key) + content() + lastExecutedComposables.remove(key) + } else { + error() + } +} + +fun includeMoreFailedComposables() { + lastExecutedComposables.forEach { + failedComposables.add(it) + Log.i(TAG, "Added composable key as failed: $it") + } + lastExecutedComposables.clear() +} + @Composable fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) { DisposableEffect(Unit) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index e0b8f130db..7ee86c2f53 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -45,6 +45,9 @@ moderated invalid chat invalid data + error showing message + error showing content + Decryption error Encryption re-negotiation error diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 12bead3663..57371e25a7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -45,6 +45,7 @@ fun showApp() { Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString()) window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) closedByError.value = true + includeMoreFailedComposables() // If the left side of screen has open modal, it's probably caused the crash if (ModalManager.start.hasModalsOpen()) { ModalManager.start.closeModal() From 7bcda7e54b8cb3b19bf09d58a62bb7714d7757d9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 21 Dec 2023 00:42:40 +0000 Subject: [PATCH 30/69] core: use ChaChaDRG as the source of randomness (#3551) * core: use ChaChaDRG as the source of randomness * do not use entropy directly * dont use RNG from agent * simplexmq * update iOS --- apps/ios/Shared/Views/Call/WebRTCClient.swift | 3 +- apps/ios/SimpleXChat/CryptoFile.swift | 4 +- apps/ios/SimpleXChat/SimpleX.h | 6 +-- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 35 ++++++++-------- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Mobile.hs | 6 +-- src/Simplex/Chat/Mobile/File.hs | 31 ++++++++------ src/Simplex/Chat/Mobile/WebRTC.hs | 15 ++++--- src/Simplex/Chat/Remote.hs | 4 +- src/Simplex/Chat/Remote/Protocol.hs | 4 +- src/Simplex/Chat/Remote/Transport.hs | 2 +- src/Simplex/Chat/Store/Shared.hs | 8 ++-- tests/ChatTests/Files.hs | 2 +- tests/MobileTests.hs | 40 ++++++++++++------- tests/RemoteTests.hs | 10 ----- tests/Test.hs | 7 ++-- tests/WebRTCTests.hs | 31 +++++++++----- 19 files changed, 120 insertions(+), 94 deletions(-) diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index acb459938f..933a3c745e 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -18,6 +18,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg }() private static let ivTagBytes: Int = 28 private static let enableEncryption: Bool = true + private var chat_ctrl = getChatCtrl() struct Call { var connection: RTCPeerConnection @@ -308,7 +309,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count) let isKeyFrame = unencrypted[0] & 1 == 0 let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 - logCrypto("encrypt", chat_encrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) + logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes) } else { return nil diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index dcb2be9ae0..0e539ba97c 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -17,7 +17,7 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { let ptr: UnsafeMutableRawPointer = malloc(data.count) memcpy(ptr, (data as NSData).bytes, data.count) var cPath = path.cString(using: .utf8)! - let cjson = chat_write_file(&cPath, ptr, Int32(data.count))! + let cjson = chat_write_file(getChatCtrl(), &cPath, ptr, Int32(data.count))! let d = fromCString(cjson).data(using: .utf8)! switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs @@ -50,7 +50,7 @@ public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> D public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { var cFromPath = fromPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)! - let cjson = chat_encrypt_file(&cFromPath, &cToPath)! + let cjson = chat_encrypt_file(getChatCtrl(), &cFromPath, &cToPath)! let d = fromCString(cjson).data(using: .utf8)! switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 6e37a51779..909d76a76c 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -25,11 +25,11 @@ extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_valid_name(char *name); -extern char *chat_encrypt_media(char *key, char *frame, int len); +extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); // chat_write_file returns null-terminated string with JSON of WriteFileResult -extern char *chat_write_file(char *path, char *data, int len); +extern char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); // chat_read_file returns a buffer with: // result status (1 byte), then if @@ -38,7 +38,7 @@ extern char *chat_write_file(char *path, char *data, int len); extern char *chat_read_file(char *path, char *key, char *nonce); // chat_encrypt_file returns null-terminated string with JSON of WriteFileResult -extern char *chat_encrypt_file(char *fromPath, char *toPath); +extern char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath); // chat_decrypt_file returns null-terminated string with the error message extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); diff --git a/cabal.project b/cabal.project index e81c21c990..1ff8aacd77 100644 --- a/cabal.project +++ b/cabal.project @@ -14,7 +14,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8c250ebe19f56dd7d53572d984e8016cb0e4d658 + tag: 13a60d1d3944aa175311563e661161e759b92563 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 9f06b66101..595d40c4e7 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8c250ebe19f56dd7d53572d984e8016cb0e4d658" = "080rw86yncf1h3zr5a8y65cndihq6f3ji43vxrdhr2mrb75vmw8m"; + "https://github.com/simplex-chat/simplexmq.git"."13a60d1d3944aa175311563e661161e759b92563" = "08mvqrbjfnq7c6mhkj4hhy4cxn0cj21n49lqzh67ani71g2g1xwa"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4e7a1cab9a..8bce204f54 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -22,7 +22,6 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader -import Crypto.Random (drgNew) import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A @@ -208,7 +207,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen servers <- agentServers config smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore agentAsync <- newTVarIO Nothing - idsDrg <- newTVarIO =<< liftIO drgNew + random <- liftIO C.newRandom inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize connNetworkStatuses <- atomically TM.empty @@ -243,7 +242,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen agentAsync, chatStore, chatStoreChanged, - idsDrg, + random, inputQ, outputQ, connNetworkStatuses, @@ -1077,8 +1076,9 @@ processChatCommand = \case then do calls <- asks currentCalls withChatLock "sendCallInvitation" $ do - callId <- CallId <$> drgRandomBytes 16 - dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing + g <- asks random + callId <- atomically $ CallId <$> C.randomBytes 16 g + dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage ct (XCallInv callId invitation) @@ -1600,7 +1600,7 @@ processChatCommand = \case processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction APINewGroup userId incognito gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do checkValidName displayName - gVar <- asks idsDrg + gVar <- asks random -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile incognitoProfile @@ -1621,7 +1621,7 @@ processChatCommand = \case let sendInvitation = sendGrpInvitation user contact gInfo case contactMember contact members of Nothing -> do - gVar <- asks idsDrg + gVar <- asks random subMode <- chatReadVar subscriptionMode (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode member <- withStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode @@ -1884,7 +1884,7 @@ processChatCommand = \case SetFileToReceive fileId encrypted_ -> withUser $ \_ -> do withChatLock "setFileToReceive" . procCmd $ do encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles - cfArgs <- if encrypt then Just <$> liftIO CF.randomArgs else pure Nothing + cfArgs <- if encrypt then Just <$> (atomically . CF.randomArgs =<< asks random) else pure Nothing withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ CancelFile fileId -> withUser $ \user@User {userId} -> @@ -2030,7 +2030,7 @@ processChatCommand = \case -- in View.hs `r'` should be defined as `id` in this case -- procCmd :: m ChatResponse -> m ChatResponse -- procCmd action = do - -- ChatController {chatLock = l, smpAgent = a, outputQ = q, idsDrg = gVar} <- ask + -- ChatController {chatLock = l, smpAgent = a, outputQ = q, random = gVar} <- ask -- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 -- void . forkIO $ -- withAgentLock a . withLock l name $ @@ -2296,7 +2296,7 @@ processChatCommand = \case then pure Nothing else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime drgRandomBytes :: Int -> m ByteString - drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n) + drgRandomBytes n = asks random >>= atomically . C.randomBytes n privateGetUser :: UserId -> m User privateGetUser userId = tryChatError (withStore (`getUser` userId)) >>= \case @@ -2571,7 +2571,7 @@ toFSFilePath f = setFileToEncrypt :: ChatMonad m => RcvFileTransfer -> m RcvFileTransfer setFileToEncrypt ft@RcvFileTransfer {fileId} = do - cfArgs <- liftIO CF.randomArgs + cfArgs <- atomically . CF.randomArgs =<< asks random withStore' $ \db -> setFileCryptoArgs db fileId cfArgs pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} @@ -2726,7 +2726,7 @@ acceptGroupJoinRequestAsync ucr@UserContactRequest {agentInvitationId = AgentInvId invId} gLinkMemRole incognitoProfile = do - gVar <- asks idsDrg + gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole let Profile {displayName} = profileToSendOnAccept user incognitoProfile GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -3407,7 +3407,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupInfo <- withStore $ \db -> getGroupInfo db user groupId subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode - gVar <- asks idsDrg + gVar <- asks random withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -4049,7 +4049,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do - gVar <- asks idsDrg + gVar <- asks random contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito then do @@ -4073,7 +4073,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do probeMatchingMemberContact :: GroupMember -> IncognitoEnabled -> m () probeMatchingMemberContact GroupMember {activeConn = Nothing} _ = pure () probeMatchingMemberContact m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do - gVar <- asks idsDrg + gVar <- asks random contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito then do @@ -4774,7 +4774,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta if featureAllowed SCFCalls forContact ct then do - dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing + g <- asks random + dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} @@ -5517,7 +5518,7 @@ sendDirectMessage conn chatMsgEvent connOrGroupId = do createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = do - gVar <- asks idsDrg + gVar <- asks random ChatConfig {chatVRange} <- asks config withStore $ \db -> createNewSndMessage db gVar connOrGroupId $ \sharedMsgId -> let msgBody = strEncode ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 70e0cc64fc..b198cccbf7 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -180,7 +180,7 @@ data ChatController = ChatController agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), chatStore :: SQLiteStore, chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted - idsDrg :: TVar ChaChaDRG, + random :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, Maybe RemoteHostId, ChatResponse), connNetworkStatuses :: TMap AgentConnId NetworkStatus, diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index a7f032c75b..6540352a3d 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -94,15 +94,15 @@ foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CStrin foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString -foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString +foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString -foreign export ccall "chat_write_file" cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString +foreign export ccall "chat_write_file" cChatWriteFile :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CJSONString foreign export ccall "chat_read_file" cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) -foreign export ccall "chat_encrypt_file" cChatEncryptFile :: CString -> CString -> IO CJSONString +foreign export ccall "chat_encrypt_file" cChatEncryptFile :: StablePtr ChatController -> CString -> CString -> IO CJSONString foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 1da64a3044..afbb1bc8c9 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -1,5 +1,6 @@ {-# LANGUAGE BangPatterns #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} @@ -31,7 +32,9 @@ import Data.Word (Word32, Word8) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr +import Foreign.StablePtr import Foreign.Storable (poke, pokeByteOff) +import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Mobile.Shared import Simplex.Chat.Util (chunkSize, encryptFile) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) @@ -39,7 +42,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Util (catchAll) -import UnliftIO (Handle, IOMode (..), withFile) +import UnliftIO (Handle, IOMode (..), atomically, withFile) data WriteFileResult = WFResult {cryptoArgs :: CryptoFileArgs} @@ -47,16 +50,17 @@ data WriteFileResult $(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "WF") ''WriteFileResult) -cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString -cChatWriteFile cPath ptr len = do +cChatWriteFile :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CJSONString +cChatWriteFile cc cPath ptr len = do + c <- deRefStablePtr cc path <- peekCString cPath s <- getByteString ptr len - r <- chatWriteFile path s + r <- chatWriteFile c path s newCStringFromLazyBS $ J.encode r -chatWriteFile :: FilePath -> ByteString -> IO WriteFileResult -chatWriteFile path s = do - cfArgs <- CF.randomArgs +chatWriteFile :: ChatController -> FilePath -> ByteString -> IO WriteFileResult +chatWriteFile ChatController {random} path s = do + cfArgs <- atomically $ CF.randomArgs random let file = CryptoFile path $ Just cfArgs either WFError (\_ -> WFResult cfArgs) <$> runCatchExceptT (withExceptT show $ CF.writeFile file $ LB.fromStrict s) @@ -87,19 +91,20 @@ chatReadFile path keyStr nonceStr = runCatchExceptT $ do let file = CryptoFile path $ Just $ CFArgs key nonce withExceptT show $ CF.readFile file -cChatEncryptFile :: CString -> CString -> IO CJSONString -cChatEncryptFile cFromPath cToPath = do +cChatEncryptFile :: StablePtr ChatController -> CString -> CString -> IO CJSONString +cChatEncryptFile cc cFromPath cToPath = do + c <- deRefStablePtr cc fromPath <- peekCString cFromPath toPath <- peekCString cToPath - r <- chatEncryptFile fromPath toPath + r <- chatEncryptFile c fromPath toPath newCAString . LB'.unpack $ J.encode r -chatEncryptFile :: FilePath -> FilePath -> IO WriteFileResult -chatEncryptFile fromPath toPath = +chatEncryptFile :: ChatController -> FilePath -> FilePath -> IO WriteFileResult +chatEncryptFile ChatController {random} fromPath toPath = either WFError WFResult <$> runCatchExceptT encrypt where encrypt = do - cfArgs <- liftIO CF.randomArgs + cfArgs <- atomically $ CF.randomArgs random encryptFile fromPath toPath cfArgs pure cfArgs diff --git a/src/Simplex/Chat/Mobile/WebRTC.hs b/src/Simplex/Chat/Mobile/WebRTC.hs index 422cfd5a8c..537388b18b 100644 --- a/src/Simplex/Chat/Mobile/WebRTC.hs +++ b/src/Simplex/Chat/Mobile/WebRTC.hs @@ -1,4 +1,5 @@ {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE NamedFieldPuns #-} module Simplex.Chat.Mobile.WebRTC ( cChatEncryptMedia, @@ -21,11 +22,14 @@ import Data.Either (fromLeft) import Data.Word (Word8) import Foreign.C (CInt, CString, newCAString) import Foreign.Ptr (Ptr) +import Foreign.StablePtr +import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Mobile.Shared import qualified Simplex.Messaging.Crypto as C +import UnliftIO (atomically) -cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString -cChatEncryptMedia = cTransformMedia chatEncryptMedia +cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString +cChatEncryptMedia = cTransformMedia . chatEncryptMedia cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString cChatDecryptMedia = cTransformMedia chatDecryptMedia @@ -39,11 +43,12 @@ cTransformMedia f cKey cFrame cFrameLen = do putFrame s = when (B.length s <= fromIntegral cFrameLen) $ putByteString cFrame s {-# INLINE cTransformMedia #-} -chatEncryptMedia :: ByteString -> ByteString -> ExceptT String IO ByteString -chatEncryptMedia keyStr frame = do +chatEncryptMedia :: StablePtr ChatController -> ByteString -> ByteString -> ExceptT String IO ByteString +chatEncryptMedia cc keyStr frame = do + ChatController {random} <- liftIO $ deRefStablePtr cc len <- checkFrameLen frame key <- decodeKey keyStr - iv <- liftIO C.randomGCMIV + iv <- atomically $ C.randomGCMIV random (tag, frame') <- withExceptT show $ C.encryptAESNoPad key iv $ B.take len frame pure $ frame' <> BA.convert (C.unAuthTag tag) <> C.unGCMIV iv diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 3d98eb7e35..f3d0ba4d1b 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -142,7 +142,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do Just (rhId, multicast) -> do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId pure (RHId rhId, multicast, Just $ remoteHostInfo rh $ Just RHSStarting, hostPairing) -- get from the database, start multicast if requested - Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing + Nothing -> withAgent $ \a -> (RHNew,False,Nothing,) <$> rcNewHostPairing a sseq <- startRemoteHostSession rhKey ctrlAppInfo <- mkCtrlAppInfo (localAddrs, invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast rcAddrPrefs_ port_ @@ -352,7 +352,7 @@ storeRemoteFile rhId encrypted_ localPath = do tmpDir <- getChatTempDirectory createDirectoryIfMissing True tmpDir tmpFile <- tmpDir `uniqueCombine` takeFileName localPath - cfArgs <- liftIO CF.randomArgs + cfArgs <- atomically . CF.randomArgs =<< asks random liftError (ChatError . CEFileWrite tmpFile) $ encryptFile localPath tmpFile cfArgs pure $ CryptoFile tmpFile $ Just cfArgs diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index af4c7d33ec..b8ff847091 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -78,7 +78,7 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) mkRemoteHostClient :: ChatMonad m => HTTP2Client -> HostSessKeys -> SessionCode -> FilePath -> HostAppInfo -> m RemoteHostClient mkRemoteHostClient httpClient sessionKeys sessionCode storePath HostAppInfo {encoding, deviceName, encryptFiles} = do - drg <- asks $ agentDRG . smpAgent + drg <- asks random counter <- newTVarIO 1 let HostSessKeys {hybridKey, idPrivKey, sessPrivKey} = sessionKeys signatures = RSSign {idPrivKey, sessPrivKey} @@ -95,7 +95,7 @@ mkRemoteHostClient httpClient sessionKeys sessionCode storePath HostAppInfo {enc mkCtrlRemoteCrypto :: ChatMonad m => CtrlSessKeys -> SessionCode -> m RemoteCrypto mkCtrlRemoteCrypto CtrlSessKeys {hybridKey, idPubKey, sessPubKey} sessionCode = do - drg <- asks $ agentDRG . smpAgent + drg <- asks random counter <- newTVarIO 1 let signatures = RSVerify {idPubKey, sessPubKey} pure RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} diff --git a/src/Simplex/Chat/Remote/Transport.hs b/src/Simplex/Chat/Remote/Transport.hs index ccd10b328a..1c9c3f08eb 100644 --- a/src/Simplex/Chat/Remote/Transport.hs +++ b/src/Simplex/Chat/Remote/Transport.hs @@ -24,7 +24,7 @@ type EncryptedFile = ((Handle, Word32), C.CbNonce, LC.SbState) prepareEncryptedFile :: RemoteCrypto -> (Handle, Word32) -> ExceptT RemoteProtocolError IO EncryptedFile prepareEncryptedFile RemoteCrypto {drg, hybridKey} f = do - nonce <- atomically $ C.pseudoRandomCbNonce drg + nonce <- atomically $ C.randomCbNonce drg sbState <- liftEitherWith (const $ PRERemoteControl RCEEncrypt) $ LC.kcbInit hybridKey nonce pure (f, nonce, sbState) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e1125adc3a..1e69d70767 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -15,7 +15,7 @@ import qualified Control.Exception as E import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Crypto.Random (ChaChaDRG, randomBytesGenerate) +import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as J import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) @@ -35,6 +35,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (allFinally) @@ -389,7 +390,4 @@ createWithRandomBytes size gVar create = tryCreate 3 | otherwise -> throwError . SEInternalError $ show e encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString -encodedRandomBytes gVar = fmap B64.encode . randomBytes gVar - -randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString -randomBytes gVar = atomically . stateTVar gVar . randomBytesGenerate +encodedRandomBytes gVar n = atomically $ B64.encode <$> C.randomBytes n gVar diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 4396a900dc..2a0736bc6f 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -1094,7 +1094,7 @@ testXFTPFileTransferEncrypted = let srcPath = "./tests/tmp/alice/test.pdf" createDirectoryIfMissing True "./tests/tmp/alice/" createDirectoryIfMissing True "./tests/tmp/bob/" - WFResult cfArgs <- chatWriteFile srcPath src + WFResult cfArgs <- chatWriteFile (chatController alice) srcPath src let fileJSON = LB.unpack $ J.encode $ CryptoFile srcPath $ Just cfArgs withXFTPServer $ do connectUsers alice bob diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 64fb7c98b8..a6231fa27e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -8,8 +8,8 @@ module MobileTests where import ChatTests.Utils +import Control.Concurrent.STM import Control.Monad.Except -import Crypto.Random (getRandomBytes) import Data.Aeson (FromJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ @@ -22,8 +22,10 @@ import Data.Word (Word8, Word32) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr +import Foreign.StablePtr import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) +import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -226,25 +228,29 @@ testChatApi tmp = do chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown testMediaApi :: HasCallStack => FilePath -> IO () -testMediaApi _ = do - key :: ByteString <- getRandomBytes 32 - frame <- getRandomBytes 100 +testMediaApi tmp = do + Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" + cc <- newStablePtr c + key <- atomically $ C.randomBytes 32 g + frame <- atomically $ C.randomBytes 100 g let keyStr = strEncode key reserved = B.replicate (C.authTagSize + C.gcmIVSize) 0 frame' = frame <> reserved - Right encrypted <- runExceptT $ chatEncryptMedia keyStr frame' + Right encrypted <- runExceptT $ chatEncryptMedia cc keyStr frame' encrypted `shouldNotBe` frame' B.length encrypted `shouldBe` B.length frame' runExceptT (chatDecryptMedia keyStr encrypted) `shouldReturn` Right frame' testMediaCApi :: HasCallStack => FilePath -> IO () -testMediaCApi _ = do - key :: ByteString <- getRandomBytes 32 - frame <- getRandomBytes 100 +testMediaCApi tmp = do + Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" + cc <- newStablePtr c + key <- atomically $ C.randomBytes 32 g + frame <- atomically $ C.randomBytes 100 g let keyStr = strEncode key reserved = B.replicate (C.authTagSize + C.gcmIVSize) 0 frame' = frame <> reserved - encrypted <- test cChatEncryptMedia keyStr frame' + encrypted <- test (cChatEncryptMedia cc) keyStr frame' encrypted `shouldNotBe` frame' test cChatDecryptMedia keyStr encrypted `shouldReturn` frame' where @@ -266,6 +272,7 @@ instance FromJSON ReadFileResult where testFileCApi :: FilePath -> FilePath -> IO () testFileCApi fileName tmp = do + cc <- mkCCPtr tmp src <- B.readFile "./tests/fixtures/test.pdf" let path = tmp (fileName <> ".pdf") cPath <- newCString path @@ -273,7 +280,7 @@ testFileCApi fileName tmp = do cLen = fromIntegral len ptr <- mallocBytes $ B.length src putByteString ptr src - r <- peekCAString =<< cChatWriteFile cPath ptr cLen + r <- peekCAString =<< cChatWriteFile cc cPath ptr cLen Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r let encryptedFile = CryptoFile path $ Just cfArgs CF.getFileContentsSize encryptedFile `shouldReturn` fromIntegral (B.length src) @@ -292,7 +299,7 @@ testMissingFileCApi :: FilePath -> IO () testMissingFileCApi tmp = do let path = tmp "missing_file" cPath <- newCString path - CFArgs key nonce <- CF.randomArgs + CFArgs key nonce <- atomically . CF.randomArgs =<< C.newRandom cKey <- encodedCString key cNonce <- encodedCString nonce ptr <- cChatReadFile cPath cKey cNonce @@ -302,13 +309,14 @@ testMissingFileCApi tmp = do testFileEncryptionCApi :: FilePath -> FilePath -> IO () testFileEncryptionCApi fileName tmp = do + cc <- mkCCPtr tmp let fromPath = tmp (fileName <> ".source.pdf") copyFile "./tests/fixtures/test.pdf" fromPath src <- B.readFile fromPath cFromPath <- newCString fromPath let toPath = tmp (fileName <> ".encrypted.pdf") cToPath <- newCString toPath - r <- peekCAString =<< cChatEncryptFile cFromPath cToPath + r <- peekCAString =<< cChatEncryptFile cc cFromPath cToPath Just (WFResult cfArgs@(CFArgs key nonce)) <- jDecode r CF.getFileContentsSize (CryptoFile toPath $ Just cfArgs) `shouldReturn` fromIntegral (B.length src) cKey <- encodedCString key @@ -320,14 +328,15 @@ testFileEncryptionCApi fileName tmp = do testMissingFileEncryptionCApi :: FilePath -> IO () testMissingFileEncryptionCApi tmp = do + cc <- mkCCPtr tmp let fromPath = tmp "missing_file.source.pdf" toPath = tmp "missing_file.encrypted.pdf" cFromPath <- newCString fromPath cToPath <- newCString toPath - r <- peekCAString =<< cChatEncryptFile cFromPath cToPath + r <- peekCAString =<< cChatEncryptFile cc cFromPath cToPath Just (WFError err) <- jDecode r err `shouldContain` fromPath - CFArgs key nonce <- CF.randomArgs + CFArgs key nonce <- atomically . CF.randomArgs =<< C.newRandom cKey <- encodedCString key cNonce <- encodedCString nonce let toPath' = tmp "missing_file.decrypted.pdf" @@ -335,6 +344,9 @@ testMissingFileEncryptionCApi tmp = do err' <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' err' `shouldContain` toPath +mkCCPtr :: FilePath -> IO (StablePtr ChatController) +mkCCPtr tmp = either (error . show) newStablePtr =<< chatMigrateInit (tmp "1") "" "yesUp" + testValidNameCApi :: FilePath -> IO () testValidNameCApi _ = do let goodName = "Джон Доу 👍" diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 13bc2942fc..ff0e5cb2d1 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -11,18 +11,14 @@ import Control.Logger.Simple import qualified Data.Aeson as J import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB -import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import qualified Network.TLS as TLS import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..), versionNumber) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types -import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Util import System.FilePath (()) import Test.Hspec @@ -571,12 +567,6 @@ contactBob desktop bob = do (desktop <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") -genTestCredentials :: IO (C.KeyHash, TLS.Credentials) -genTestCredentials = do - caCreds <- liftIO $ genCredentials Nothing (0, 24) "CA" - sessionCreds <- liftIO $ genCredentials (Just caCreds) (0, 24) "Session" - pure . tlsCredentials $ sessionCreds :| [caCreds] - stopDesktop :: HasCallStack => TestCC -> TestCC -> IO () stopDesktop mobile desktop = do logWarn "stopping via desktop" diff --git a/tests/Test.hs b/tests/Test.hs index ee5804aa9a..21aa379c17 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -26,7 +26,7 @@ main = do describe "JSON Tests" jsonTests describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests - describe "WebRTC encryption" webRTCTests + around tmpBracket $ describe "WebRTC encryption" webRTCTests describe "Valid names" validNameTests around testBracket $ do describe "Mobile API Tests" mobileTests @@ -35,10 +35,11 @@ main = do xdescribe'' "SimpleX Directory service bot" directoryServiceTests describe "Remote session" remoteTests where - testBracket test = do + testBracket test = withSmpServer $ tmpBracket test + tmpBracket test = do t <- getSystemTime let ts = show (systemSeconds t) <> show (systemNanoseconds t) - withSmpServer $ withTmpFiles $ withTempDirectory "tests/tmp" ts test + withTmpFiles $ withTempDirectory "tests/tmp" ts test logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} diff --git a/tests/WebRTCTests.hs b/tests/WebRTCTests.hs index 7dd24e6082..a473afef36 100644 --- a/tests/WebRTCTests.hs +++ b/tests/WebRTCTests.hs @@ -1,36 +1,49 @@ +{-# LANGUAGE OverloadedStrings #-} + module WebRTCTests where import Control.Monad.Except import Crypto.Random (getRandomBytes) import qualified Data.ByteString.Base64.URL as U import qualified Data.ByteString.Char8 as B +import Foreign.StablePtr +import Simplex.Chat.Mobile import Simplex.Chat.Mobile.WebRTC import qualified Simplex.Messaging.Crypto as C +import System.FilePath (()) import Test.Hspec -webRTCTests :: Spec +webRTCTests :: SpecWith FilePath webRTCTests = describe "WebRTC crypto" $ do - it "encrypts and decrypts media" $ do + it "encrypts and decrypts media" $ \tmp -> do + Right c <- chatMigrateInit (tmp "1") "" "yesUp" + cc <- newStablePtr c key <- U.encode <$> getRandomBytes 32 frame <- getRandomBytes 1000 - Right frame' <- runExceptT $ chatEncryptMedia key $ frame <> B.replicate reservedSize '\NUL' + Right frame' <- runExceptT $ chatEncryptMedia cc key $ frame <> B.replicate reservedSize '\NUL' B.length frame' `shouldBe` B.length frame + reservedSize Right frame'' <- runExceptT $ chatDecryptMedia key frame' frame'' `shouldBe` frame <> B.replicate reservedSize '\NUL' - it "should fail on invalid frame size" $ do + it "should fail on invalid frame size" $ \tmp -> do + Right c <- chatMigrateInit (tmp "1") "" "yesUp" + cc <- newStablePtr c key <- U.encode <$> getRandomBytes 32 frame <- getRandomBytes 10 - runExceptT (chatEncryptMedia key frame) `shouldReturn` Left "frame has no [reserved space for] IV and/or auth tag" + runExceptT (chatEncryptMedia cc key frame) `shouldReturn` Left "frame has no [reserved space for] IV and/or auth tag" runExceptT (chatDecryptMedia key frame) `shouldReturn` Left "frame has no [reserved space for] IV and/or auth tag" - it "should fail on invalid key" $ do + it "should fail on invalid key" $ \tmp -> do + Right c <- chatMigrateInit (tmp "1") "" "yesUp" + cc <- newStablePtr c let key = B.replicate 32 '#' frame <- (<> B.replicate reservedSize '\NUL') <$> getRandomBytes 100 - runExceptT (chatEncryptMedia key frame) `shouldReturn` Left "invalid key: invalid character at offset: 0" + runExceptT (chatEncryptMedia cc key frame) `shouldReturn` Left "invalid key: invalid character at offset: 0" runExceptT (chatDecryptMedia key frame) `shouldReturn` Left "invalid key: invalid character at offset: 0" - it "should fail on invalid auth tag" $ do + it "should fail on invalid auth tag" $ \tmp -> do + Right c <- chatMigrateInit (tmp "1") "" "yesUp" + cc <- newStablePtr c key <- U.encode <$> getRandomBytes 32 frame <- getRandomBytes 1000 - Right frame' <- runExceptT $ chatEncryptMedia key $ frame <> B.replicate reservedSize '\NUL' + Right frame' <- runExceptT $ chatEncryptMedia cc key $ frame <> B.replicate reservedSize '\NUL' Right frame'' <- runExceptT $ chatDecryptMedia key frame' frame'' `shouldBe` frame <> B.replicate reservedSize '\NUL' let (encFrame, rest) = B.splitAt (B.length frame' - reservedSize) frame From d198d6a8db1ccb72d2db04826ad3803c3d148d36 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 21 Dec 2023 09:57:43 +0000 Subject: [PATCH 31/69] core: build iOS library with ghc 9.6.3 with iPhone7 etc. support (#3577) * bump haskell.nix * bump flake.lock * Try openssl fix * CFLAGS. not CCFLAGS * Fix iOS build issues and improve static library handling --------- Co-authored-by: Moritz Angermann --- flake.lock | 36 +++++++++++++++++++++++++++--------- flake.nix | 13 ++++++++++++- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/flake.lock b/flake.lock index e5f8d531cc..a11e01683e 100644 --- a/flake.lock +++ b/flake.lock @@ -119,12 +119,15 @@ } }, "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1676283394, - "narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=", + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", "owner": "numtide", "repo": "flake-utils", - "rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", "type": "github" }, "original": { @@ -190,11 +193,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1702340598, - "narHash": "sha256-CC0HI+6iKPtH+8r/ZfcpW5v/OYvL7zMwpr0xfkXV1zU=", + "lastModified": 1702513363, + "narHash": "sha256-kloro9uEe8aYhPMoMjVNq2rfrXNgMOZhOPwVH5DH2K0=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "rev": "a9d931d0398da67846fa257922a924829233cb91", "type": "github" }, "original": { @@ -240,11 +243,11 @@ "stackage": "stackage" }, "locked": { - "lastModified": 1700119633, - "narHash": "sha256-nZY2eIo8TkRbXgJXEWMm9zor330GuUtcNzvUN9tN64U=", + "lastModified": 1701163700, + "narHash": "sha256-sOrewUS3LnzV09nGr7+3R6Q6zsgU4smJc61QsHq+4DE=", "owner": "input-output-hk", "repo": "haskell.nix", - "rev": "1fe47a3d52e1ecd6247c8ab83811a21de2e2f074", + "rev": "2808bfe3e62e9eb4ee8974cd623a00e1611f302b", "type": "github" }, "original": { @@ -673,6 +676,21 @@ "repo": "stackage.nix", "type": "github" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 999f097c24..6fabe7d657 100644 --- a/flake.nix +++ b/flake.nix @@ -67,6 +67,9 @@ }); in let iosPostInstall = bundleName: '' ${pkgs.tree}/bin/tree $out + mkdir tmp + find ./dist -name "libHS*-ghc*.a" -exec cp {} tmp \; + (cd tmp; ${pkgs.tree}/bin/tree .; ar x libHS*.a; for o in *.o; do if /usr/bin/otool -xv $o|grep ldadd ; then echo $o; fi; done; cd ..; rm -fR tmp) mkdir -p $out/_pkg # copy over includes, we might want those, but maybe not. # cp -r $out/lib/*/*/include $out/_pkg/ @@ -82,6 +85,13 @@ ${mac2ios.packages.${system}.mac2ios}/bin/mac2ios $pkg chmod -w $pkg done + + mkdir tmp + find $out/_pkg -name "libHS*-ghc*.a" -exec cp {} tmp \; + (cd tmp; ${pkgs.tree}/bin/tree .; ar x libHS*.a; for o in *.o; do if /usr/bin/otool -xv $o|grep ldadd ; then echo $o; fi; done; cd ..; rm -fR tmp) + + sha256sum $out/_pkg/*.a + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/${bundleName}.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support @@ -536,7 +546,8 @@ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ - (pkgs.openssl.override { static = true; }) + # TODO: have a cross override for iOS, that sets this. + ((pkgs.openssl.override { static = true; }).overrideDerivation (old: { CFLAGS = "-mcpu=apple-a7 -march=armv8-a+norcpc" ;})) ]; }]; }).simplex-chat.components.library.override ( From aa037c0662d10fd5f35f5a632e5bb12cce82440f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 21 Dec 2023 10:05:43 +0000 Subject: [PATCH 32/69] ios: update core library (uses GHC 9.6.3) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index f5701af155..4302b6fb80 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -116,11 +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 */; }; - 5CCD1A882B2A5D56001A4199 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A832B2A5D55001A4199 /* libgmp.a */; }; - 5CCD1A892B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A842B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a */; }; - 5CCD1A8A2B2A5D56001A4199 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A852B2A5D55001A4199 /* libgmpxx.a */; }; - 5CCD1A8B2B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A862B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a */; }; - 5CCD1A8C2B2A5D56001A4199 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1A872B2A5D56001A4199 /* libffi.a */; }; + 5CCD1B0A2B3444B9001A4199 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1B052B3444B8001A4199 /* libffi.a */; }; + 5CCD1B0B2B3444B9001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1B062B3444B8001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg-ghc9.6.3.a */; }; + 5CCD1B0C2B3444B9001A4199 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1B072B3444B8001A4199 /* libgmpxx.a */; }; + 5CCD1B0D2B3444B9001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1B082B3444B9001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg.a */; }; + 5CCD1B0E2B3444B9001A4199 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCD1B092B3444B9001A4199 /* libgmp.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, ); }; }; @@ -402,11 +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 = ""; }; - 5CCD1A832B2A5D55001A4199 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CCD1A842B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a"; sourceTree = ""; }; - 5CCD1A852B2A5D55001A4199 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CCD1A862B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a"; sourceTree = ""; }; - 5CCD1A872B2A5D56001A4199 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CCD1B052B3444B8001A4199 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CCD1B062B3444B8001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg-ghc9.6.3.a"; sourceTree = ""; }; + 5CCD1B072B3444B8001A4199 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CCD1B082B3444B9001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg.a"; sourceTree = ""; }; + 5CCD1B092B3444B9001A4199 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.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 = ""; }; @@ -518,12 +518,12 @@ buildActionMask = 2147483647; files = ( 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CCD1A8B2B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a in Frameworks */, - 5CCD1A8A2B2A5D56001A4199 /* libgmpxx.a in Frameworks */, - 5CCD1A882B2A5D56001A4199 /* libgmp.a in Frameworks */, - 5CCD1A8C2B2A5D56001A4199 /* libffi.a in Frameworks */, - 5CCD1A892B2A5D56001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a in Frameworks */, + 5CCD1B0E2B3444B9001A4199 /* libgmp.a in Frameworks */, + 5CCD1B0C2B3444B9001A4199 /* libgmpxx.a in Frameworks */, + 5CCD1B0B2B3444B9001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg-ghc9.6.3.a in Frameworks */, + 5CCD1B0A2B3444B9001A4199 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5CCD1B0D2B3444B9001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -585,11 +585,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CCD1A872B2A5D56001A4199 /* libffi.a */, - 5CCD1A832B2A5D55001A4199 /* libgmp.a */, - 5CCD1A852B2A5D55001A4199 /* libgmpxx.a */, - 5CCD1A862B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn-ghc8.10.7.a */, - 5CCD1A842B2A5D55001A4199 /* libHSsimplex-chat-5.4.0.7-8PiOsot1xukLpqHaIcecqn.a */, + 5CCD1B052B3444B8001A4199 /* libffi.a */, + 5CCD1B092B3444B9001A4199 /* libgmp.a */, + 5CCD1B072B3444B8001A4199 /* libgmpxx.a */, + 5CCD1B062B3444B8001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg-ghc9.6.3.a */, + 5CCD1B082B3444B9001A4199 /* libHSsimplex-chat-5.4.0.7-K3rb8mQtqiP3LyZDoNKwwg.a */, ); path = Libraries; sourceTree = ""; From 2bff3b9c97c76b0ae66a81fb4aeb59147e143d38 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 21 Dec 2023 12:49:18 +0000 Subject: [PATCH 33/69] desktop, android: update api to pass controller when encrypting files (use ChaChaDRG as source of randomness) (#3578) --- .../common/src/commonMain/cpp/android/simplex-api.c | 12 ++++++------ .../common/src/commonMain/cpp/desktop/simplex-api.c | 12 ++++++------ .../kotlin/chat/simplex/common/model/CryptoFile.kt | 6 ++++-- .../kotlin/chat/simplex/common/platform/Core.kt | 4 ++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 4fd62524de..676c58fb49 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -65,9 +65,9 @@ extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); -extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); -extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); JNIEXPORT jobjectArray JNICALL @@ -157,11 +157,11 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) { const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); - jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity)); + jstring res = (*env)->NewStringUTF(env, chat_write_file((void*)controller, _path, buff, capacity)); (*env)->ReleaseStringUTFChars(env, path, _path); return res; } @@ -206,10 +206,10 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) { const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path)); + jstring res = (*env)->NewStringUTF(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index fb561dc38d..292715bdc5 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -38,9 +38,9 @@ extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); -extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); -extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); // As a reference: https://stackoverflow.com/a/60002045 @@ -167,11 +167,11 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) { const char *_path = encode_to_utf8_chars(env, path); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); - jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity)); + jstring res = decode_to_utf8_string(env, chat_write_file((void*)controller, _path, buff, capacity)); (*env)->ReleaseStringUTFChars(env, path, _path); return res; } @@ -216,10 +216,10 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) { const char *_from_path = encode_to_utf8_chars(env, from_path); const char *_to_path = encode_to_utf8_chars(env, to_path); - jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path)); + jstring res = decode_to_utf8_string(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 037d27af33..28b46f592d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -21,10 +21,11 @@ sealed class WriteFileResult { * */ fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { + val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") val buffer = ByteBuffer.allocateDirect(data.size) buffer.put(data) buffer.rewind() - val str = chatWriteFile(path, buffer) + val str = chatWriteFile(ctrl, path, buffer) return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { is WriteFileResult.Result -> d.cryptoArgs is WriteFileResult.Error -> throw Exception(d.writeError) @@ -43,7 +44,8 @@ fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray { } fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs { - val str = chatEncryptFile(fromPath, toPath) + val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") + val str = chatEncryptFile(ctrl, fromPath, toPath) val d = json.decodeFromString(WriteFileResult.serializer(), str) return when (d) { is WriteFileResult.Result -> d.cryptoArgs diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index a4c1c333e5..7d097efb7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -22,9 +22,9 @@ external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String external fun chatValidName(name: String): String -external fun chatWriteFile(path: String, buffer: ByteBuffer): String +external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String external fun chatReadFile(path: String, key: String, nonce: String): Array -external fun chatEncryptFile(fromPath: String, toPath: String): String +external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String val chatModel: ChatModel From c4855313b63fd010dc674d1b51ef1be0f89d13f9 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 21 Dec 2023 21:49:49 +0800 Subject: [PATCH 34/69] android: splash screen with background color on Android 12+ (#3579) --- .../android/src/main/AndroidManifest.xml | 1 + .../java/chat/simplex/app/MainActivity.kt | 2 ++ .../main/java/chat/simplex/app/SimplexApp.kt | 28 +++++++++++++++---- .../src/main/res/values-night/themes.xml | 8 ++++++ .../android/src/main/res/values/themes.xml | 2 +- .../chat/simplex/common/platform/Platform.kt | 1 + .../simplex/common/ui/theme/ThemeManager.kt | 2 ++ 7 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 apps/multiplatform/android/src/main/res/values-night/themes.xml diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 09b33316a4..d8350ee222 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ android:exported="true" android:label="${app_name}" android:windowSoftInputMode="adjustResize" + android:configChanges="uiMode" android:theme="@style/Theme.SimpleX"> diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 082c10582c..8d64ae3c80 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.* import android.view.WindowManager import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatDelegate import androidx.fragment.app.FragmentActivity import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.getUserIdFromIntent @@ -22,6 +23,7 @@ import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + platform.androidSetNightModeIfSupported() applyAppLocale(ChatModel.controller.appPrefs.appLanguage) super.onCreate(savedInstanceState) // testJson() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index e3f4e69bd4..ee43da5d44 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -1,9 +1,8 @@ package chat.simplex.app import android.app.Application -import android.os.Handler -import android.os.Looper -import chat.simplex.common.platform.Log +import android.app.UiModeManager +import android.os.* import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.NtfManager @@ -12,10 +11,12 @@ import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.updatingChatsMutex +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.theme.DefaultTheme +import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.common.platform.* -import chat.simplex.common.views.call.RcvCallInvitation import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* import kotlinx.coroutines.sync.withLock @@ -225,6 +226,23 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidIsBackgroundCallAllowed(): Boolean = !SimplexService.isBackgroundRestricted() + override fun androidSetNightModeIfSupported() { + if (Build.VERSION.SDK_INT < 31) return + + val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM.name) { + null + } else { + CurrentColors.value.colors.isLight + } + val mode = when (light) { + null -> UiModeManager.MODE_NIGHT_AUTO + true -> UiModeManager.MODE_NIGHT_NO + false -> UiModeManager.MODE_NIGHT_YES + } + val uiModeManager = androidAppContext.getSystemService(UI_MODE_SERVICE) as UiModeManager + uiModeManager.setApplicationNightMode(mode) + } + override suspend fun androidAskToAllowBackgroundCalls(): Boolean { if (SimplexService.isBackgroundRestricted()) { val userChoice: CompletableDeferred = CompletableDeferred() diff --git a/apps/multiplatform/android/src/main/res/values-night/themes.xml b/apps/multiplatform/android/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..bb341b71d0 --- /dev/null +++ b/apps/multiplatform/android/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/apps/multiplatform/android/src/main/res/values/themes.xml b/apps/multiplatform/android/src/main/res/values/themes.xml index f59d099fba..eb6d85bf05 100644 --- a/apps/multiplatform/android/src/main/res/values/themes.xml +++ b/apps/multiplatform/android/src/main/res/values/themes.xml @@ -1,7 +1,7 @@ - diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 84ffdb6fd7..e55c2c939a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -10,6 +10,7 @@ interface PlatformInterface { fun androidChatStopped() {} fun androidChatInitializedAndStarted() {} fun androidIsBackgroundCallAllowed(): Boolean = true + fun androidSetNightModeIfSupported() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true } /** diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index 4a7521efb8..49d3203455 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.text.font.FontFamily import chat.simplex.res.MR import chat.simplex.common.model.AppPreferences import chat.simplex.common.model.ChatController +import chat.simplex.common.platform.platform import chat.simplex.common.views.helpers.generalGetString // https://github.com/rsms/inter @@ -96,6 +97,7 @@ object ThemeManager { fun applyTheme(theme: String, darkForSystemTheme: Boolean) { appPrefs.currentTheme.set(theme) CurrentColors.value = currentColors(darkForSystemTheme) + platform.androidSetNightModeIfSupported() } fun changeDarkTheme(theme: String, darkForSystemTheme: Boolean) { From 8b0d2dede7d81cd2041d60ab55b1d856a40bd859 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 21 Dec 2023 23:19:36 +0800 Subject: [PATCH 35/69] android, desktop: saving and sharing files menu item (#3580) --- .../simplex/common/views/chat/item/ChatItemView.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index abb67da50a..daf887e8c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -195,7 +195,13 @@ fun ChatItemView( } val clipboard = LocalClipboardManager.current val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || cachedRemoteReqs[cItem.file.fileSource] != false + val copyAndShareAllowed = when { + cItem.content.text.isNotEmpty() -> true + cItem.file != null && chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded -> true + getLoadedFilePath(cItem.file) != null -> true + else -> false + } + if (copyAndShareAllowed) { ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { var fileSource = getLoadedFileSource(cItem.file) @@ -221,7 +227,7 @@ fun ChatItemView( showMenu.value = false }) } - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false))) { + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false && cItem.file?.loaded == true))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { From c83238c35a9101f3a6c9b2630962ebd22d0d40ad Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 22 Dec 2023 06:48:32 +0800 Subject: [PATCH 36/69] desktop: enable sending images and files with enter (#3582) --- .../common/platform/PlatformTextField.android.kt | 2 +- .../simplex/common/platform/PlatformTextField.kt | 2 +- .../chat/simplex/common/views/chat/SendMsgView.kt | 13 ++++++------- .../common/views/database/DatabaseErrorView.kt | 2 +- .../views/onboarding/SetupDatabasePassphrase.kt | 4 ++-- .../common/platform/PlatformTextField.desktop.kt | 5 +++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 1bc9658496..9e28c4f2bc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -27,7 +27,6 @@ import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doOnTextChanged -import chat.simplex.common.* import chat.simplex.common.R import chat.simplex.common.helpers.toURI import chat.simplex.common.model.ChatModel @@ -45,6 +44,7 @@ import java.net.URI actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index fa99d0f93c..af47f9c3e0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.text.TextStyle import chat.simplex.common.views.chat.ComposeState -import java.io.File import java.net.URI @Composable expect fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 28882e6b73..e566cf30d3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -29,7 +29,6 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* -import java.io.File import java.net.URI @Composable @@ -82,7 +81,10 @@ fun SendMsgView( val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { + val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || + (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || + cs.endLiveDisabled + PlatformTextField(composeState, sendMsgEnabled, sendMsgButtonDisabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { if (!cs.inProgress) { sendMessage(null) } @@ -155,9 +157,6 @@ fun SendMsgView( else -> { val cs = composeState.value val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward) - val disabled = !sendMsgEnabled || !cs.sendEnabled() || - (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || - cs.endLiveDisabled val showDropdown = rememberSaveable { mutableStateOf(false) } @Composable @@ -200,12 +199,12 @@ fun SendMsgView( val menuItems = MenuItems() if (menuItems.isNotEmpty()) { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) { showDropdown.value = true } + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) { showDropdown.value = true } DefaultDropdownMenu(showDropdown) { menuItems.forEach { composable -> composable() } } } else { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 4e5424215b..22d69de1cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -270,7 +270,7 @@ private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onCli } else null ), modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent { - if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + if (onClick != null && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) { onClick() true } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index c117e89971..a51d9c8a0c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -120,7 +120,7 @@ private fun SetupDatabasePassphraseLayout( .padding(horizontal = DEFAULT_PADDING) .focusRequester(focusRequester) .onPreviewKeyEvent { - if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) { focusManager.moveFocus(FocusDirection.Down) true } else { @@ -150,7 +150,7 @@ private fun SetupDatabasePassphraseLayout( modifier = Modifier .padding(horizontal = DEFAULT_PADDING) .onPreviewKeyEvent { - if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + if (!disabled && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) { onClickUpdate() true } else { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 74df6b8251..8016b18b12 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -45,6 +45,7 @@ import kotlin.text.substring actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, @@ -103,7 +104,7 @@ actual fun PlatformTextField( .padding(vertical = 4.dp) .focusRequester(focusRequester) .onPreviewKeyEvent { - if (it.key == Key.Enter && it.type == KeyEventType.KeyDown) { + if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyDown) { if (it.isShiftPressed) { val start = if (minOf(textFieldValue.selection.min) == 0) "" else textFieldValue.text.substring(0 until textFieldValue.selection.min) val newText = start + "\n" + @@ -113,7 +114,7 @@ actual fun PlatformTextField( selection = TextRange(textFieldValue.selection.min + 1) ) onMessageChange(newText) - } else if (cs.message.isNotEmpty()) { + } else if (!sendMsgButtonDisabled) { onDone() } true From 67590f3258d8b72e1fdd6fb31da6cd8d6bb6780d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 22 Dec 2023 16:46:55 +0800 Subject: [PATCH 37/69] Revert "ios: making thumbnails faster" (#3571) This reverts commit cd9cb8e064b3df1902c5c2321556b0ca1dc5e9b8. --- apps/ios/Shared/Views/Helpers/ImagePicker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 0e3f8082b3..efd42ee4bd 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -143,7 +143,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable { config.filter = .any(of: [.images, .videos]) config.selectionLimit = selectionLimit config.selection = .ordered - config.preferredAssetRepresentationMode = .current + //config.preferredAssetRepresentationMode = .current let controller = PHPickerViewController(configuration: config) controller.delegate = context.coordinator return controller From 57a6e85668fc4b461a4881b86c58ae40afb0e3c6 Mon Sep 17 00:00:00 2001 From: Andor Kesselman Date: Fri, 22 Dec 2023 00:47:48 -0800 Subject: [PATCH 38/69] docs: fix typo (#3552) --- docs/protocol/simplex-chat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 95069c794c..71d5efcef7 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -173,7 +173,7 @@ This message is used to delete previously sent chat items. Receiving clients MUS When content message `x.msg.new` contains file attachment (the invitation to receive the file), this sub-protocol is used to accept this file or to notify the recipient that sending the file was cancelled. -File attachement can optionally include connection address to receive the file - clients MUST include it when sending files to direct connections, and MUST NOT include it when sending file attachment to the group (as different members would need different connections to receive the file). +File attachment can optionally include connection address to receive the file - clients MUST include it when sending files to direct connections, and MUST NOT include it when sending file attachment to the group (as different members would need different connections to receive the file). `x.file.acpt` message is used to accept the file in case when file connection address was included in the message (that is the case when the file invitation was sent in direct message). It is sent as part of file connection handshake via file connection, that is why this message contains no reference to the file - the used connection provides sufficient context for the sender. From 23989aca571b04e401435a08d7a251e5a538b189 Mon Sep 17 00:00:00 2001 From: Andor Kesselman Date: Fri, 22 Dec 2023 00:48:26 -0800 Subject: [PATCH 39/69] Update README.md (#3553) --- apps/simplex-chat/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/simplex-chat/README.md b/apps/simplex-chat/README.md index 113f90d185..bbcf40b139 100644 --- a/apps/simplex-chat/README.md +++ b/apps/simplex-chat/README.md @@ -1,3 +1,3 @@ # SimpleX Chat CLI app -See [repo REAMDE](../../README.md#zap-quick-installation-of-a-terminal-app) for installation and usage instructions. +See [repo README](../../README.md#zap-quick-installation-of-a-terminal-app) for installation and usage instructions. From f93f68e425f4b27302a91129cc9eb5a06a51be0c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 23 Dec 2023 13:06:59 +0000 Subject: [PATCH 40/69] core: agent background mode for iOS NSE (#3574) * core: agent background mode for iOS NSE * change parameter for APIActivateChat * fix * update lib * update lib * simplexmq * simplify --- apps/ios/Shared/SimpleXApp.swift | 6 +- .../ios/SimpleX NSE/NotificationService.swift | 2 +- apps/ios/SimpleXChat/API.swift | 4 +- apps/ios/SimpleXChat/SimpleX.h | 2 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 151 +++++++++--------- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/Mobile.hs | 18 +-- tests/ChatClient.hs | 2 +- 10 files changed, 98 insertions(+), 93 deletions(-) diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index c023f375d3..f72ffcaaaf 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -21,10 +21,10 @@ struct SimpleXApp: App { @State private var enteredBackgroundAuthenticated: TimeInterval? = nil init() { -// DispatchQueue.global(qos: .background).sync { - haskell_init() + DispatchQueue.global(qos: .background).sync { + haskell_init() // hs_init(0, nil) -// } + } UserDefaults.standard.register(defaults: appDefaults) setGroupDefaults() registerGroupDefaults() diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index c286ee1c3c..f9b4852e53 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -442,7 +442,7 @@ func startChat() -> DBMigrationResult? { func doStartChat() -> DBMigrationResult? { logger.debug("NotificationService: doStartChat") hs_init(0, nil) - let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation()) + let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation(), backgroundMode: true) if dbStatus != .ok { resetChatCtrl() NSEChatState.shared.set(.created) diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index dfa4caf099..8d05a066e8 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -17,7 +17,7 @@ public func getChatCtrl(_ useKey: String? = nil) -> chat_ctrl { fatalError("chat controller not initialized") } -public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil) -> (Bool, DBMigrationResult) { +public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil, backgroundMode: Bool = false) -> (Bool, DBMigrationResult) { if let res = migrationResult { return res } let dbPath = getAppDatabasePath().path var dbKey = "" @@ -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_key(&cPath, &cKey, 1, &cConfirm, &chatController)! + let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, backgroundMode ? 1 : 0, &chatController)! let dbRes = dbMigrationResult(fromCString(cjson)) let encrypted = dbKey != "" let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey) diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 909d76a76c..c49d104514 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -16,7 +16,7 @@ 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_key(char *path, char *key, int keepKey, char *confirm, chat_ctrl *ctrl); +extern char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, int backgroundMode, 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); diff --git a/cabal.project b/cabal.project index 1ff8aacd77..ca967b458d 100644 --- a/cabal.project +++ b/cabal.project @@ -14,7 +14,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 13a60d1d3944aa175311563e661161e759b92563 + tag: 9ea9b2c7356a9b42be8ab685c343076ff3c452fe source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 595d40c4e7..626d2d8515 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."13a60d1d3944aa175311563e661161e759b92563" = "08mvqrbjfnq7c6mhkj4hhy4cxn0cj21n49lqzh67ani71g2g1xwa"; + "https://github.com/simplex-chat/simplexmq.git"."9ea9b2c7356a9b42be8ab685c343076ff3c452fe" = "16jgsh5wnf8q56hlsdpa5xf2qhlrv80j8088xys0sbwfa4br2nk8"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8bce204f54..61e32cb3d0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -197,79 +197,84 @@ createChatDatabase filePrefix key keepKey confirmMigrations = runExceptT $ do agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations pure ChatDatabase {chatStore, agentStore} -newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do - let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} - firstTime = dbNew chatStore - currentUser <- newTVarIO user - currentRemoteHost <- newTVarIO Nothing - servers <- agentServers config - smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore - agentAsync <- newTVarIO Nothing - random <- liftIO C.newRandom - inputQ <- newTBQueueIO tbqSize - outputQ <- newTBQueueIO tbqSize - connNetworkStatuses <- atomically TM.empty - subscriptionMode <- newTVarIO SMSubscribe - chatLock <- newEmptyTMVarIO - sndFiles <- newTVarIO M.empty - rcvFiles <- newTVarIO M.empty - currentCalls <- atomically TM.empty - localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName - multicastSubscribers <- newTMVarIO 0 - remoteSessionSeq <- newTVarIO 0 - remoteHostSessions <- atomically TM.empty - remoteHostsFolder <- newTVarIO Nothing - remoteCtrlSession <- newTVarIO Nothing - filesFolder <- newTVarIO optFilesFolder - chatStoreChanged <- newTVarIO False - expireCIThreads <- newTVarIO M.empty - expireCIFlags <- newTVarIO M.empty - cleanupManagerAsync <- newTVarIO Nothing - timedItemThreads <- atomically TM.empty - showLiveItems <- newTVarIO False - encryptLocalFiles <- newTVarIO False - userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg - tempDirectory <- newTVarIO tempDir - contactMergeEnabled <- newTVarIO True - pure - ChatController - { firstTime, - currentUser, - currentRemoteHost, - smpAgent, - agentAsync, - chatStore, - chatStoreChanged, - random, - inputQ, - outputQ, - connNetworkStatuses, - subscriptionMode, - chatLock, - sndFiles, - rcvFiles, - currentCalls, - localDeviceName, - multicastSubscribers, - remoteSessionSeq, - remoteHostSessions, - remoteHostsFolder, - remoteCtrlSession, - config, - filesFolder, - expireCIThreads, - expireCIFlags, - cleanupManagerAsync, - timedItemThreads, - showLiveItems, - encryptLocalFiles, - userXFTPFileConfig, - tempDirectory, - logFilePath = logFile, - contactMergeEnabled - } +newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController +newChatController + ChatDatabase {chatStore, agentStore} + user + cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} + backgroundMode = do + let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} + firstTime = dbNew chatStore + currentUser <- newTVarIO user + currentRemoteHost <- newTVarIO Nothing + servers <- agentServers config + smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode + agentAsync <- newTVarIO Nothing + random <- liftIO C.newRandom + inputQ <- newTBQueueIO tbqSize + outputQ <- newTBQueueIO tbqSize + connNetworkStatuses <- atomically TM.empty + subscriptionMode <- newTVarIO SMSubscribe + chatLock <- newEmptyTMVarIO + sndFiles <- newTVarIO M.empty + rcvFiles <- newTVarIO M.empty + currentCalls <- atomically TM.empty + localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName + multicastSubscribers <- newTMVarIO 0 + remoteSessionSeq <- newTVarIO 0 + remoteHostSessions <- atomically TM.empty + remoteHostsFolder <- newTVarIO Nothing + remoteCtrlSession <- newTVarIO Nothing + filesFolder <- newTVarIO optFilesFolder + chatStoreChanged <- newTVarIO False + expireCIThreads <- newTVarIO M.empty + expireCIFlags <- newTVarIO M.empty + cleanupManagerAsync <- newTVarIO Nothing + timedItemThreads <- atomically TM.empty + showLiveItems <- newTVarIO False + encryptLocalFiles <- newTVarIO False + userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg + tempDirectory <- newTVarIO tempDir + contactMergeEnabled <- newTVarIO True + pure + ChatController + { firstTime, + currentUser, + currentRemoteHost, + smpAgent, + agentAsync, + chatStore, + chatStoreChanged, + random, + inputQ, + outputQ, + connNetworkStatuses, + subscriptionMode, + chatLock, + sndFiles, + rcvFiles, + currentCalls, + localDeviceName, + multicastSubscribers, + remoteSessionSeq, + remoteHostSessions, + remoteHostsFolder, + remoteCtrlSession, + config, + filesFolder, + expireCIThreads, + expireCIFlags, + cleanupManagerAsync, + timedItemThreads, + showLiveItems, + encryptLocalFiles, + userXFTPFileConfig, + tempDirectory, + logFilePath = logFile, + contactMergeEnabled + } where configServers :: DefaultAgentServers configServers = diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index c409526a0b..1d870bf381 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -28,7 +28,7 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core exitFailure run db@ChatDatabase {chatStore} = do u <- getCreateActiveUser chatStore testView - cc <- newChatController db (Just u) cfg opts + cc <- newChatController db (Just u) cfg opts False runSimplexChat opts u cc chat runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6540352a3d..d7f2e5a43c 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -72,7 +72,7 @@ $(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_migrate_init_key" cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString @@ -108,10 +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 = cChatMigrateInitKey fp key 0 +cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0 -cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString -cChatMigrateInitKey fp key keepKey conf ctrl = do +cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInitKey fp key keepKey conf background 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 @@ -122,7 +122,7 @@ cChatMigrateInitKey fp key keepKey conf ctrl = do dbKey <- BA.convert <$> B.packCString key confirm <- peekCAString conf r <- - chatMigrateInitKey dbPath dbKey (keepKey /= 0) confirm >>= \case + chatMigrateInitKey dbPath dbKey (keepKey /= 0) confirm (background /= 0) >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -220,10 +220,10 @@ getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) -chatMigrateInit dbFilePrefix dbKey = chatMigrateInitKey dbFilePrefix dbKey False +chatMigrateInit dbFilePrefix dbKey confirm = chatMigrateInitKey dbFilePrefix dbKey False confirm False -chatMigrateInitKey :: String -> ScrubbedBytes -> Bool -> String -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey dbFilePrefix dbKey keepKey confirm = runExceptT $ do +chatMigrateInitKey :: String -> ScrubbedBytes -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm chatStore <- migrate createChatStore (chatStoreFile dbFilePrefix) confirmMigrations agentStore <- migrate createAgentStore (agentStoreFile dbFilePrefix) confirmMigrations @@ -231,7 +231,7 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm = runExceptT $ do where initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix) + newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix) backgroundMode migrate createStore dbFile confirmMigrations = ExceptT $ (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c32d8002b9..044d95eaaf 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -175,7 +175,7 @@ startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t opts - cc <- newChatController db (Just user) cfg opts + cc <- newChatController db (Just user) cfg opts False chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO From 12d1ada25ea972989a53c732b0b0e6baedd5595b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 23 Dec 2023 17:07:23 +0400 Subject: [PATCH 41/69] core: support batch sending in groups, batch introductions; send recent message history to new members (#3519) * core: batch send stubs, comments * multiple events in ChatMessage and supporting types * Revert "multiple events in ChatMessage and supporting types" This reverts commit 9b239b26ba5c8fdec41c6689a6421baf7ffcc27d. * schema, refactor group processing for batched messages * encoding, refactor processing * refactor code to work with updated schema * encoding, remove instances * wip * implement batching * batch introductions * wip * collect and send message history * missing new line * rename * test * rework to build history via chat items * refactor, tests * correctly set member version range, dont include deleted items * tests * fix disappearing messages * check number of errors * comment * check size in encodeChatMessage * fix - don't check msg size for binary * use builder * rename * rename * rework batching * lazy msg body * use withStoreBatch * refactor * reverse batches * comment * possibly fix builder for single msg * refactor batcher * refactor * dont repopulate msg_deliveries on down migration * EncodedChatMessage type * remove type * batcher tests * add tests * group history preference * test group link * fix tests * fix for random update * add test testImageFitsSingleBatch * refactor * rename function * refactor * mconcat * rename feature * catch error on each batch * refactor file inv retrieval * refactor gathering item forward events * refactor message batching * unite migrations * move files * refactor * Revert "unite migrations" This reverts commit 0be7a3117a2b4eb7f13f1ff639188bb3ff826af8. * refactor splitFileDescr * improve tests * Revert "dont repopulate msg_deliveries on down migration" This reverts commit 2944c1cc28acf85282a85d8458c67cefb7787ac7. * fix down migration --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 449 +++++++---- src/Simplex/Chat/Help.hs | 3 +- src/Simplex/Chat/Messages.hs | 19 +- src/Simplex/Chat/Messages/Batch.hs | 53 ++ src/Simplex/Chat/Messages/CIContent.hs | 10 +- .../M20231215_recreate_msg_deliveries.hs | 100 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 48 +- src/Simplex/Chat/Protocol.hs | 58 +- src/Simplex/Chat/Store/Files.hs | 45 +- src/Simplex/Chat/Store/Groups.hs | 2 +- src/Simplex/Chat/Store/Messages.hs | 158 ++-- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 14 +- src/Simplex/Chat/Types.hs | 7 +- src/Simplex/Chat/Types/Preferences.hs | 41 +- src/Simplex/Chat/Util.hs | 14 +- tests/ChatTests/Files.hs | 8 +- tests/ChatTests/Groups.hs | 746 ++++++++++++++++++ tests/ChatTests/Profiles.hs | 9 +- tests/ChatTests/Utils.hs | 10 +- tests/MessageBatching.hs | 120 +++ tests/ProtocolTests.hs | 32 +- tests/SchemaDump.hs | 4 +- tests/Test.hs | 2 + 25 files changed, 1616 insertions(+), 343 deletions(-) create mode 100644 src/Simplex/Chat/Messages/Batch.hs create mode 100644 src/Simplex/Chat/Migrations/M20231215_recreate_msg_deliveries.hs create mode 100644 tests/MessageBatching.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6462d26008..64ab4954fe 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -36,6 +36,7 @@ library Simplex.Chat.Help Simplex.Chat.Markdown Simplex.Chat.Messages + Simplex.Chat.Messages.Batch Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Migrations.M20220101_initial @@ -127,6 +128,7 @@ library Simplex.Chat.Migrations.M20231126_remote_ctrl_address Simplex.Chat.Migrations.M20231207_chat_list_pagination Simplex.Chat.Migrations.M20231214_item_content_tag + Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -543,6 +545,7 @@ test-suite simplex-chat-test ChatTests.Utils JSONTests MarkdownTests + MessageBatching MobileTests ProtocolTests RemoteTests diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 61e32cb3d0..f5803eb626 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,6 +29,7 @@ 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.Builder (toLazyByteString) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB @@ -38,20 +39,19 @@ import Data.Either (fromRight, lefts, partitionEithers, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortBy, sortOn) -import Data.List.NonEmpty (NonEmpty, nonEmpty) +import Data.List (find, foldl', isSuffixOf, partition, sortOn) +import Data.List.NonEmpty (NonEmpty (..), nonEmpty, toList, (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) -import Data.Ord (comparing) import Data.Text (Text) 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 (systemToUTCTime) -import Data.Word (Word16, Word32) +import Data.Word (Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call @@ -59,6 +59,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages +import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Options @@ -77,7 +78,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util -import Simplex.Chat.Util (encryptFile) +import Simplex.Chat.Util (encryptFile, shuffle) import Simplex.FileTransfer.Client.Main (maxFileSize) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.FileTransfer.Description (ValidFileDescription, gb, kb, mb) @@ -607,7 +608,7 @@ processChatCommand = \case <$> withConnection st (readTVarIO . DB.slow) 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) + unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) pure $ CRApiChats user previews APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled @@ -688,7 +689,7 @@ processChatCommand = \case withStore $ \db -> getDirectChatItem db user chatId quotedItemId (origQmc, qd, sent) <- quoteData qci let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - qmc = quoteContent origQmc file + qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) where @@ -702,13 +703,13 @@ processChatCommand = \case assertUserGroupRole gInfo GRAuthor send g where - send g@(Group gInfo@GroupInfo {groupId, membership} ms) + send g@(Group gInfo@GroupInfo {groupId} ms) | isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles | otherwise = do (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL - (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership + (msgContainer, quotedItem_) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ timed_ live (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live withStore' $ \db -> @@ -748,51 +749,9 @@ processChatCommand = \case void . withStore' $ \db -> createSndGroupInlineFT db m conn ft sendMemberFileInline m conn ft sharedMsgId processMember _ = pure () - prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) - prepareMsg fInv_ timed_ membership = case quotedItemId_ of - Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - Just quotedItemId -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withStore $ \db -> getGroupChatItem db user chatId quotedItemId - (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership - let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - qmc = quoteContent origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) - where - quoteData :: ChatItem c d -> GroupMember -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote - quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') - quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) - quoteData _ _ = throwChatError CEInvalidQuote CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where - quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent - quoteContent qmc ciFile_ - | replaceContent = MCText qTextOrFile - | otherwise = case qmc of - MCImage _ image -> MCImage qTextOrFile image - MCFile _ -> MCFile qTextOrFile - -- consider same for voice messages - -- MCVoice _ voice -> MCVoice qTextOrFile voice - _ -> qmc - where - -- if the message we're quoting with is one of the "large" MsgContents - -- we replace the quote's content with MCText - replaceContent = case mc of - MCText _ -> False - MCFile _ -> False - MCLink {} -> True - MCImage {} -> True - MCVideo {} -> True - MCVoice {} -> False - MCUnknown {} -> True - qText = msgContentText qmc - getFileName :: CIFile d -> String - getFileName CIFile {fileName} = fileName - qFileName = maybe qText (T.pack . getFileName) ciFile_ - qTextOrFile = if T.null qText then qFileName else qText xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do let fileName = takeFileName filePath @@ -1836,7 +1795,7 @@ processChatCommand = \case LastChats count_ -> withUser' $ \user -> do let count = fromMaybe 5000 count_ (errs, previews) <- partitionEithers <$> withStore' (\db -> getChatPreviews db user False (PTLast count) clqNoFilters) - toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName @@ -2307,7 +2266,7 @@ processChatCommand = \case tryChatError (withStore (`getUser` userId)) >>= \case Left _ -> throwChatError CEUserUnknown Right user -> pure user - validateUserPassword :: User -> User -> Maybe UserPwd -> m () + validateUserPassword :: User -> User -> Maybe UserPwd -> m () validateUserPassword = validateUserPassword_ . Just validateUserPassword_ :: Maybe User -> User -> Maybe UserPwd -> m () validateUserPassword_ user_ User {userId = userId', viewPwdHash} viewPwd_ = @@ -2433,6 +2392,50 @@ processChatCommand = \case cReqHashes = bimap hash hash cReqSchemas hash = ConnReqUriHash . C.sha256Hash . strEncode +prepareGroupMsg :: forall m. ChatMonad m => User -> GroupInfo -> MsgContent -> Maybe ChatItemId -> Maybe FileInvitation -> Maybe CITimed -> Bool -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ fInv_ timed_ live = case quotedItemId_ of + Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + Just quotedItemId -> do + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- + withStore $ \db -> getGroupChatItem db user groupId quotedItemId + (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + qmc = quoteContent mc origQmc file + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) + where + quoteData :: ChatItem c d -> GroupMember -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote + quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') + quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) + quoteData _ _ = throwChatError CEInvalidQuote + +quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent +quoteContent mc qmc ciFile_ + | replaceContent = MCText qTextOrFile + | otherwise = case qmc of + MCImage _ image -> MCImage qTextOrFile image + MCFile _ -> MCFile qTextOrFile + -- consider same for voice messages + -- MCVoice _ voice -> MCVoice qTextOrFile voice + _ -> qmc + where + -- if the message we're quoting with is one of the "large" MsgContents + -- we replace the quote's content with MCText + replaceContent = case mc of + MCText _ -> False + MCFile _ -> False + MCLink {} -> True + MCImage {} -> True + MCVideo {} -> True + MCVoice {} -> False + MCUnknown {} -> True + qText = msgContentText qmc + getFileName :: CIFile d -> String + getFileName CIFile {fileName} = fileName + qFileName = maybe qText (T.pack . getFileName) ciFile_ + qTextOrFile = if T.null qText then qFileName else qText + assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -2610,7 +2613,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI -- marking file as accepted and reading description in the same transaction -- to prevent race condition with appending description ci <- xftpAcceptRcvFT db user fileId filePath - rfd <- getRcvFileDescrByFileId db fileId + rfd <- getRcvFileDescrByRcvFileId db fileId pure (ci, rfd) receiveViaCompleteFD user fileId rfd cryptoArgs pure ci @@ -3188,17 +3191,29 @@ processAgentMsgSndFile _corrId aFileId msg = sendFileDescription sft rfd msgId sendMsg = do let rfdText = fileDescrText rfd withStore' $ \db -> updateSndFTDescrXFTP db user sft rfdText - partSize <- asks $ xftpDescrPartSize . config - sendParts 1 partSize rfdText + parts <- splitFileDescr rfdText + loopSend parts where - sendParts partNo partSize rfdText = do - let (part, rest) = T.splitAt partSize rfdText - complete = T.null rest - fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} + -- returns msgDeliveryId of the last file description message + loopSend :: NonEmpty FileDescr -> m Int64 + loopSend (fileDescr :| fds) = do (_, msgDeliveryId) <- sendMsg $ XMsgFileDescr {msgId, fileDescr} - if complete - then pure msgDeliveryId - else sendParts (partNo + 1) partSize rest + case L.nonEmpty fds of + Just fds' -> loopSend fds' + Nothing -> pure msgDeliveryId + +splitFileDescr :: ChatMonad m => RcvFileDescrText -> m (NonEmpty FileDescr) +splitFileDescr rfdText = do + partSize <- asks $ xftpDescrPartSize . config + pure $ splitParts 1 partSize rfdText + where + splitParts partNo partSize remText = + let (part, rest) = T.splitAt partSize remText + complete = T.null rest + fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} + in if complete + then fileDescr :| [] + else fileDescr <| splitParts (partNo + 1) partSize rest processAgentMsgRcvFile :: forall m. ChatMonad m => ACorrId -> RcvFileId -> ACommand 'Agent 'AERcvFile -> m () processAgentMsgRcvFile _corrId aFileId msg = @@ -3293,6 +3308,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure () MSG meta _msgFlags msgBody -> do cmdId <- createAckCmd conn + -- TODO only acknowledge without saving message? + -- probably this branch is never executed, so there should be no reason + -- to save message if contact hasn't been created yet - chat item isn't created anyway withAckMessage agentConnId cmdId meta $ do (_conn', _) <- saveDirectRcvMSG conn meta cmdId msgBody pure False @@ -3568,21 +3586,105 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem members <- withStore' $ \db -> getGroupMembers db user gInfo - intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m - shuffledIntros <- liftIO $ shuffleMembers intros $ \GroupMemberIntro {reMember = GroupMember {memberRole}} -> memberRole - forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` (toView . CRChatError (Just user)) + sendIntroductions members + when (groupFeatureAllowed SGFHistory gInfo) sendHistory where sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo profileToSend = profileToSendOnAccept user profileMode void $ sendDirectMessage conn (XGrpLinkMem profileToSend) (GroupId groupId) + sendIntroductions members = do + intros <- withStore' $ \db -> createIntroductions db members m + shuffledIntros <- liftIO $ shuffleIntros intros + if isCompatibleRange (memberChatVRange' m) batchSendVRange + then do + let events = map (XGrpMemIntro . memberInfo . reMember) shuffledIntros + forM_ (L.nonEmpty events) $ \events' -> + sendGroupMemberMessages user conn events' groupId + else forM_ shuffledIntros $ \intro -> + processIntro intro `catchChatError` (toView . CRChatError (Just user)) + shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] + shuffleIntros intros = do + let (admins, others) = partition isAdmin intros + (admPics, admNoPics) = partition hasPicture admins + (othPics, othNoPics) = partition hasPicture others + mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] + where + isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin + hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMessage conn (XGrpMemIntro $ memberInfo (reMember intro)) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent + sendHistory = + when (isCompatibleRange (memberChatVRange' m) batchSendVRange) $ do + (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo 100) + (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items + let errors = map ChatErrorStore errs <> errs' + unless (null errors) $ toView $ CRChatErrors (Just user) errors + forM_ (L.nonEmpty $ concat events) $ \events' -> + sendGroupMemberMessages user conn events' groupId + itemForwardEvents :: CChatItem 'CTGroup -> m [ChatMsgEvent 'Json] + itemForwardEvents cci = case cci of + (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender ci mc fInvDescr_ + (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do + fInvDescr_ <- join <$> forM file getSndFileInvDescr + processContentItem membership ci mc fInvDescr_ + _ -> pure [] + where + getRcvFileInvDescr :: CIFile 'MDRcv -> m (Maybe (FileInvitation, RcvFileDescrText)) + getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired + then pure Nothing + else do + rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + pure $ invCompleteDescr ciFile rfd + getSndFileInvDescr :: CIFile 'MDSnd -> m (Maybe (FileInvitation, RcvFileDescrText)) + getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired + then pure Nothing + else do + -- can also lookup in extra_xftp_file_descriptions, though it can be empty; + -- would be best if snd file had a single rcv description for all members saved in files table + rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId + pure $ invCompleteDescr ciFile rfd + fileExpired :: m Bool + fileExpired = do + ttl <- asks $ rcvFilesTTL . agentConfig . config + cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime + pure $ chatItemTs cci < cutoffTs + invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText) + invCompleteDescr CIFile {fileName, fileSize} RcvFileDescr {fileDescrText, fileDescrComplete} + | fileDescrComplete = + let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} + fInv = xftpFileInvitation fileName fileSize fInvDescr + in Just (fInv, fileDescrText) + | otherwise = Nothing + processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> m [ChatMsgEvent Json] + processContentItem sender ChatItem {meta, quotedItem} mc fInvDescr_ = + if isNothing fInvDescr_ && not (msgContentHasText mc) + then pure [] + else do + let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta + quotedItemId_ = quoteItemId =<< quotedItem + fInv_ = fst <$> fInvDescr_ + (msgContainer, _) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ itemTimed False + let senderVRange = memberChatVRange' sender + xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} + fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of + (Just fileDescrText, Just msgId) -> do + parts <- splitFileDescr fileDescrText + pure . toList $ L.map (XMsgFileDescr msgId) parts + _ -> pure [] + let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents + GroupMember {memberId} = sender + msgForwardEvents = map (\cm -> XGrpMsgForward memberId cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) + pure msgForwardEvents _ -> do - -- TODO notify member who forwarded introduction - question - where it is stored? There is via_contact but probably there should be via_member in group_members table let memCategory = memberCategory m withStore' (\db -> getViaGroupContact db user m) >>= \case Nothing -> do @@ -3610,41 +3712,27 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do void $ sendDirectMessage imConn (XGrpMemCon m.memberId) (GroupId groupId) _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do + checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () cmdId <- createAckCmd conn - tryChatError (processChatMessage cmdId) >>= \case - Right (ACMsg _ chatMsg, withRcpt) -> do - ackMsg agentConnId cmdId msgMeta $ if withRcpt then Just "" else Nothing - when (membership.memberRole >= GRAdmin) $ forwardMsg_ chatMsg - Left e -> ackMsg agentConnId cmdId msgMeta Nothing >> throwError e + let aChatMsgs = parseChatMessages msgBody + withAckMessage agentConnId cmdId msgMeta $ do + forM_ aChatMsgs $ \case + Right (ACMsg _ chatMsg) -> + processEvent cmdId chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e + Left e -> toView $ CRChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) + checkSendRcpt $ rights aChatMsgs + -- currently only a single message is forwarded + when (membership.memberRole >= GRAdmin) $ case aChatMsgs of + [Right (ACMsg _ chatMsg)] -> forwardMsg_ chatMsg + _ -> pure () where - processChatMessage :: Int64 -> m (AChatMessage, Bool) - processChatMessage cmdId = do - msg@(ACMsg _ chatMsg) <- parseAChatMessage conn msgMeta msgBody - checkIntegrity chatMsg `catchChatError` \_ -> pure () - (msg,) <$> processEvent cmdId chatMsg brokerTs = metaBrokerTs msgMeta - checkIntegrity :: ChatMessage e -> m () - checkIntegrity ChatMessage {chatMsgEvent} = do - when checkForEvent $ checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta - where - checkForEvent = case chatMsgEvent of - XMsgNew _ -> True - XFileCancel _ -> True - XFileAcptInv {} -> True - XGrpMemNew _ -> True - XGrpMemRole {} -> True - XGrpMemDel _ -> True - XGrpLeave -> True - XGrpDel -> True - XGrpInfo _ -> True - XGrpDirectInv {} -> True - _ -> False - processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m Bool + processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m () processEvent cmdId chatMsg = do (m', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m conn msgMeta cmdId msgBody chatMsg updateChatLock "groupMessage" event case event of - XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs + XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs False XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg brokerTs ttl live XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg brokerTs @@ -3672,15 +3760,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - checkSendRcpt event - checkSendRcpt :: ChatMsgEvent e -> m Bool - checkSendRcpt event = do + checkSendRcpt :: [AChatMessage] -> m Bool + checkSendRcpt aChatMsgs = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo pure $ fromMaybe (sendRcptsSmallGroups user) sendRcpts - && hasDeliveryReceipt (toCMEventTag event) + && any aChatMsgHasReceipt aChatMsgs && currentMemCount <= smallGroupsRcptsMemLimit + where + aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = + hasDeliveryReceipt (toCMEventTag chatMsgEvent) forwardMsg_ :: MsgEncodingI e => ChatMessage e -> m () forwardMsg_ chatMsg = forM_ (forwardedGroupMsg chatMsg) $ \chatMsg' -> do @@ -4017,15 +4107,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ackMsgDeliveryEvent :: Connection -> CommandId -> m () ackMsgDeliveryEvent Connection {connId} ackCmdId = - withStoreCtx' - (Just $ "createRcvMsgDeliveryEvent, connId: " <> show connId <> ", ackCmdId: " <> show ackCmdId <> ", msgDeliveryStatus: MDSRcvAcknowledged") - $ \db -> createRcvMsgDeliveryEvent db connId ackCmdId MDSRcvAcknowledged + withStore' $ \db -> updateRcvMsgDeliveryStatus db connId ackCmdId MDSRcvAcknowledged sentMsgDeliveryEvent :: Connection -> AgentMsgId -> m () sentMsgDeliveryEvent Connection {connId} msgId = - withStoreCtx - (Just $ "createSndMsgDeliveryEvent, connId: " <> show connId <> ", msgId: " <> show msgId <> ", msgDeliveryStatus: MDSSndSent") - $ \db -> createSndMsgDeliveryEvent db connId msgId MDSSndSent + withStore' $ \db -> updateSndMsgDeliveryStatus db connId msgId MDSSndSent agentErrToItemStatus :: AgentErrorType -> CIStatus 'MDSnd agentErrToItemStatus (SMP AUTH) = CISSndErrorAuth @@ -4287,14 +4373,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> m () - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> m () + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = do - -- TODO integrity message check - -- check if message moderation event was received ahead of message - let timed_ = rcvGroupCITimed gInfo itemTTL + let timed_ = + if forwarded + then rcvCITimed_ (Just Nothing) itemTTL + else rcvGroupCITimed gInfo itemTTL live = fromMaybe False live_ withStore' (\db -> getCIModeration db user gInfo memberId sharedMsgId_) >>= \case Just ciModeration -> do @@ -5221,7 +5308,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let body = LB.toStrict $ J.encode msg rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg case event of - XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs + XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs True XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent rcvMsg msgTs ttl live XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs @@ -5240,14 +5327,19 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do - withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus + withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete + -- TODO [batch send] update status of all messages in batch + -- - this is for when we implement identifying inactive connections + -- - regular messages sent in batch would all be marked as delivered by a single receipt + -- - repeat for directMsgReceived if same logic is applied to direct messages + -- - getChatItemIdByAgentMsgId to return [ChatItemId] groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do - withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus + withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete updateDirectItemStatus :: Contact -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> m () @@ -5338,17 +5430,13 @@ sendFileInline_ FileTransferMeta {filePath, chunkSize} sharedMsgId sendMsg = chSize = fromIntegral chunkSize parseChatMessage :: ChatMonad m => Connection -> ByteString -> m (ChatMessage 'Json) -parseChatMessage conn = parseChatMessage_ conn Nothing -{-# INLINE parseChatMessage #-} - -parseAChatMessage :: ChatMonad m => Connection -> MsgMeta -> ByteString -> m AChatMessage -parseAChatMessage conn msgMeta = parseChatMessage_ conn (Just msgMeta) -{-# INLINE parseAChatMessage #-} - -parseChatMessage_ :: (ChatMonad m, StrEncoding s) => Connection -> Maybe MsgMeta -> ByteString -> m s -parseChatMessage_ conn msgMeta s = liftEither . first (ChatError . errType) $ strDecode s +parseChatMessage conn s = do + case parseChatMessages s of + [msg] -> liftEither . first (ChatError . errType) $ (\(ACMsg _ m) -> checkEncoding m) =<< msg + _ -> throwChatError $ CEException "parseChatMessage: single message is expected" where - errType = CEInvalidChatMessage conn (msgMetaToJson <$> msgMeta) (safeDecodeUtf8 s) + errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) +{-# INLINE parseChatMessage #-} sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = @@ -5525,40 +5613,77 @@ createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGro createSndMessage chatMsgEvent connOrGroupId = do gVar <- asks random ChatConfig {chatVRange} <- asks config - withStore $ \db -> createNewSndMessage db gVar connOrGroupId $ \sharedMsgId -> - let msgBody = strEncode ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent} - in NewMessage {chatMsgEvent, msgBody} + withStore $ \db -> createNewSndMessage db gVar connOrGroupId chatMsgEvent (encodeMessage chatVRange) + where + encodeMessage chatVRange sharedMsgId = + encodeChatMessage ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent} + +sendGroupMemberMessages :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> Connection -> NonEmpty (ChatMsgEvent e) -> GroupId -> m () +sendGroupMemberMessages user conn@Connection {connId} events groupId = do + when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) + (errs, msgs) <- partitionEithers <$> createSndMessages + unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null msgs) $ do + let (errs', msgBatches) = partitionEithers $ batchMessages maxChatMsgSize msgs + -- shouldn't happen, as large messages would have caused createNewSndMessage to throw SELargeMsg + unless (null errs') $ toView $ CRChatErrors (Just user) errs' + forM_ msgBatches $ \batch -> + processBatch batch `catchChatError` (toView . CRChatError (Just user)) + where + processBatch :: MsgBatch -> m () + processBatch (MsgBatch builder sndMsgs) = do + let batchBody = LB.toStrict $ toLazyByteString builder + agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) MsgFlags {notification = True} batchBody + let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} + void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs + createSndMessages :: m [Either ChatError SndMessage] + createSndMessages = do + gVar <- asks random + ChatConfig {chatVRange} <- asks config + withStoreBatch $ \db -> map (createMsg db gVar chatVRange) (toList events) + createMsg db gVar chatVRange evnt = do + r <- runExceptT $ createNewSndMessage db gVar (GroupId groupId) evnt (encodeMessage chatVRange evnt) + pure $ first ChatErrorStore r + encodeMessage chatVRange evnt sharedMsgId = + encodeChatMessage ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent = evnt} directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString directMessage chatMsgEvent = do ChatConfig {chatVRange} <- asks config - pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} + let r = encodeChatMessage ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} + case r of + ECMEncoded encodedBody -> pure . LB.toStrict $ encodedBody + ECMLarge -> throwChatError $ CEException "large message" -deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 -deliverMessage conn cmEventTag msgBody msgId = - deliverMessages [(conn, cmEventTag, msgBody, msgId)] >>= \case +deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> LazyMsgBody -> MessageId -> m Int64 +deliverMessage conn cmEventTag msgBody msgId = do + let msgFlags = MsgFlags {notification = hasNotification cmEventTag} + deliverMessage' conn msgFlags msgBody msgId + +deliverMessage' :: ChatMonad m => Connection -> MsgFlags -> LazyMsgBody -> MessageId -> m Int64 +deliverMessage' conn msgFlags msgBody msgId = + deliverMessages [(conn, msgFlags, msgBody, msgId)] >>= \case [r] -> liftEither r rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) -deliverMessages :: ChatMonad' m => [(Connection, CMEventTag e, MsgBody, MessageId)] -> m [Either ChatError Int64] +deliverMessages :: ChatMonad' m => [(Connection, MsgFlags, LazyMsgBody, MessageId)] -> m [Either ChatError Int64] deliverMessages msgReqs = do sent <- zipWith prepareBatch msgReqs <$> withAgent' (`sendMessages` aReqs) withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent where - aReqs = map (\(conn, cmEvTag, msgBody, _msgId) -> (aConnId conn, msgFlags cmEvTag, msgBody)) msgReqs - msgFlags cmEvTag = MsgFlags {notification = hasNotification cmEvTag} + aReqs = map (\(conn, msgFlags, msgBody, _msgId) -> (aConnId conn, msgFlags, LB.toStrict msgBody)) msgReqs prepareBatch req = bimap (`ChatErrorAgent` Nothing) (req,) - createDelivery :: DB.Connection -> ((Connection, CMEventTag e, MsgBody, MessageId), AgentMsgId) -> IO (Either ChatError Int64) + createDelivery :: DB.Connection -> ((Connection, MsgFlags, LazyMsgBody, MessageId), AgentMsgId) -> IO (Either ChatError Int64) createDelivery db ((Connection {connId}, _, _, msgId), agentMsgId) = Right <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = do msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) - recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) $ \GroupMember {memberRole} -> memberRole - let tag = toCMEventTag chatMsgEvent + recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) + let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} (toSend, pending) = foldr addMember ([], []) recipientMembers - msgReqs = map (\(_, conn) -> (conn, tag, msgBody, msgId)) toSend + msgReqs = map (\(_, conn) -> (conn, msgFlags, msgBody, msgId)) toSend delivered <- deliverMessages msgReqs let errors = lefts delivered unless (null errors) $ toView $ CRChatErrors (Just user) errors @@ -5566,6 +5691,12 @@ sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = do let sentToMembers = filterSent delivered toSend fst <> filterSent stored pending id pure (msg, sentToMembers) where + shuffleMembers :: [GroupMember] -> IO [GroupMember] + shuffleMembers ms = do + let (adminMs, otherMs) = partition isAdmin ms + liftM2 (<>) (shuffle adminMs) (shuffle otherMs) + where + isAdmin GroupMember {memberRole} = memberRole >= GRAdmin addMember m (toSend, pending) = case memberSendAction chatMsgEvent members m of Just (MSASend conn) -> ((m, conn) : toSend, pending) Just MSAPending -> (toSend, m : pending) @@ -5614,15 +5745,6 @@ sendGroupMemberMessage user m@GroupMember {groupMemberId} chatMsgEvent groupId i MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ -shuffleMembers :: [a] -> (a -> GroupMemberRole) -> IO [a] -shuffleMembers ms role = do - let (adminMs, otherMs) = partition ((GRAdmin <=) . role) ms - liftM2 (<>) (shuffle adminMs) (shuffle otherMs) - where - random :: IO Word16 - random = randomRIO (0, 65535) - shuffle xs = map snd . sortBy (comparing fst) <$> mapM (\x -> (,x) <$> random) xs - sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do pendingMessages <- withStore' $ \db -> getPendingGroupMessages db groupMemberId @@ -5639,21 +5761,25 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn _ -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName _ -> pure () +-- TODO [batch send] refactor direct message processing same as groups (e.g. checkIntegrity before processing) saveDirectRcvMSG :: ChatMonad m => Connection -> MsgMeta -> CommandId -> MsgBody -> m (Connection, RcvMessage) -saveDirectRcvMSG conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody = do - ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody - conn' <- updatePeerChatVRange conn chatVRange - let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewMessage {chatMsgEvent, msgBody} - rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} - msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing - pure (conn', msg) +saveDirectRcvMSG conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody = + case parseChatMessages msgBody of + [Right (ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent})] -> do + conn' <- updatePeerChatVRange conn chatVRange + let agentMsgId = fst $ recipient agentMsgMeta + newMsg = NewRcvMessage {chatMsgEvent, msgBody} + rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} + msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing + pure (conn', msg) + [Left e] -> error $ "saveDirectRcvMSG: error parsing chat message: " <> e + _ -> error "saveDirectRcvMSG: batching not supported" saveGroupRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> CommandId -> MsgBody -> ChatMessage e -> m (GroupMember, Connection, RcvMessage) saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do (am', conn') <- updateMemberChatVRange authorMember conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewMessage {chatMsgEvent, msgBody} + newMsg = NewRcvMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} amId = Just am'.groupMemberId msg <- @@ -5669,7 +5795,7 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta saveGroupFwdRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> m RcvMessage saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} = do - let newMsg = NewMessage {chatMsgEvent, msgBody} + let newMsg = NewRcvMessage {chatMsgEvent, msgBody} fwdMemberId = Just $ groupMemberId' forwardingMember refAuthorId = Just $ groupMemberId' refAuthorMember withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) @@ -6233,6 +6359,7 @@ chatCommandP = "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayName <*> optional (A.space *> strP)), "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), "/set files #" *> (SetGroupFeature (AGF SGFFiles) <$> displayName <*> (A.space *> strP)), + "/set history #" *> (SetGroupFeature (AGF SGFHistory) <$> displayName <*> (A.space *> strP)), "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayName <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), "/set delete #" *> (SetGroupFeature (AGF SGFFullDelete) <$> displayName <*> (A.space *> strP)), @@ -6320,7 +6447,12 @@ chatCommandP = jsonP = J.eitherDecodeStrict' <$?> A.takeByteString groupProfile = do (gName, fullName) <- profileNames - let groupPreferences = Just (emptyGroupPrefs :: GroupPreferences) {directMessages = Just DirectMessagesGroupPreference {enable = FEOn}} + let groupPreferences = + Just + (emptyGroupPrefs :: GroupPreferences) + { directMessages = Just DirectMessagesGroupPreference {enable = FEOn}, + history = Just HistoryGroupPreference {enable = FEOn} + } pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences} fullNameP = A.space *> textP <|> pure "" textP = safeDecodeUtf8 <$> A.takeByteString @@ -6358,6 +6490,7 @@ chatCommandP = <|> ("day" $> 86400) <|> ("week" $> (7 * 86400)) <|> ("month" $> (30 * 86400)) + <|> A.decimal timedTTLOnOffP = optional ("on" *> A.space) *> (Just <$> timedTTLP) <|> ("off" $> Nothing) diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 5d0548ca3f..ac93e05533 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -155,7 +155,8 @@ groupsHelpInfo = "", green "Group chat preferences:", indent <> highlight "/set voice # on/off " <> " - enable/disable voice messages", - -- indent <> highlight "/set files # on/off " <> " - enable/disable files and media (other than voice)", + indent <> highlight "/set files # on/off " <> " - enable/disable files and media (other than voice)", + indent <> highlight "/set history # on/off " <> " - enable/disable sending recent history to new members", indent <> highlight "/set delete # on/off " <> " - enable/disable full message deletion", indent <> highlight "/set direct # on/off " <> " - enable/disable direct messages to other members", indent <> highlight "/set disappear # on