From c544a636f6218506578830bcbef99e4355ff721f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 20 Feb 2024 13:56:31 +0400 Subject: [PATCH 01/64] core, ui: remove usage of inline files (send only xftp files) (#3823) --- apps/ios/Shared/Model/SimpleXAPI.swift | 7 - .../Views/UserSettings/DeveloperView.swift | 19 - .../ios/SimpleX NSE/NotificationService.swift | 8 - apps/ios/SimpleXChat/APITypes.swift | 11 - apps/ios/SimpleXChat/AppGroup.swift | 4 - .../chat/simplex/common/model/SimpleXAPI.kt | 16 - .../chat/simplex/common/platform/Core.kt | 1 - .../typescript/src/command.ts | 13 - src/Simplex/Chat.hs | 126 +-- src/Simplex/Chat/Controller.hs | 20 - src/Simplex/Chat/Store/Files.hs | 18 - tests/ChatClient.hs | 7 +- tests/ChatTests/Direct.hs | 22 +- tests/ChatTests/Files.hs | 1007 +++-------------- tests/ChatTests/Groups.hs | 28 +- tests/ChatTests/Local.hs | 27 +- tests/ChatTests/Profiles.hs | 15 +- tests/ChatTests/Utils.hs | 40 +- tests/RemoteTests.hs | 10 +- 19 files changed, 222 insertions(+), 1177 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a3c353a489..fd013f8339 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -252,12 +252,6 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } -func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { - let r = chatSendCmdSync(.apiSetXFTPConfig(config: cfg)) - if case .cmdOk = r { return } - throw r -} - func apiSetEncryptLocalFiles(_ enable: Bool) throws { let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } @@ -1249,7 +1243,6 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni } try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - try setXFTPConfig(getXFTPCfg()) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index e99c6e3301..3bbfbfe33e 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -42,25 +42,6 @@ struct DeveloperView: View { } footer: { (developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.") } - -// Section { -// settingsRow("arrow.up.doc") { -// Toggle("Send videos and files via XFTP", isOn: $xftpSendEnabled) -// .onChange(of: xftpSendEnabled) { _ in -// do { -// try setXFTPConfig(getXFTPCfg()) -// } catch { -// logger.error("setXFTPConfig: cannot set XFTP config \(responseError(error))") -// } -// } -// } -// } header: { -// Text("Experimental") -// } footer: { -// if xftpSendEnabled { -// Text("v4.6.1+ is required to receive via XFTP.") -// } -// } } } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 61c439fb33..67536d7b78 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -453,7 +453,6 @@ 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 @@ -499,7 +498,6 @@ func doStartChat() -> DBMigrationResult? { try setNetworkConfig(networkConfig) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) // prevent suspension while starting chat suspendLock.wait() @@ -733,12 +731,6 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } -func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { - let r = sendSimpleXCmd(.apiSetXFTPConfig(config: cfg)) - if case .cmdOk = r { return } - throw r -} - func apiSetEncryptLocalFiles(_ enable: Bool) throws { let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ae091f8415..9c5aa0da62 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -31,7 +31,6 @@ public enum ChatCommand { case apiSuspendChat(timeoutMicroseconds: Int) case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) - case apiSetXFTPConfig(config: XFTPFileConfig?) case apiSetEncryptLocalFiles(enable: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) @@ -162,11 +161,6 @@ public enum ChatCommand { case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" - case let .apiSetXFTPConfig(cfg): if let cfg = cfg { - return "/_xftp on \(encodeJSON(cfg))" - } else { - return "/_xftp off" - } case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" @@ -311,7 +305,6 @@ public enum ChatCommand { case .apiSuspendChat: return "apiSuspendChat" case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" - case .apiSetXFTPConfig: return "apiSetXFTPConfig" case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" @@ -1005,10 +998,6 @@ struct ComposedMessage: Encodable { var msgContent: MsgContent } -public struct XFTPFileConfig: Encodable { - var minFileSize: Int64 -} - public struct ArchiveConfig: Encodable { var archivePath: String var disableCompression: Bool? diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index f79c294e0c..ceb7d9d7db 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -265,10 +265,6 @@ public class Default { } } -public func getXFTPCfg() -> XFTPFileConfig { - return XFTPFileConfig(minFileSize: 0) -} - public func getNetCfg() -> NetCfg { let onionHosts = networkUseOnionHostsGroupDefault.get() let (hostMode, requiredHostMode) = onionHosts.hostMode 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 c061d340f2..d6cea330b1 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 @@ -631,12 +631,6 @@ object ChatController { throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") } - suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { - val r = sendCmd(null, CC.ApiSetXFTPConfig(cfg)) - if (r is CR.CmdOk) return - throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") - } - suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) suspend fun apiExportArchive(config: ArchiveConfig) { @@ -2173,10 +2167,6 @@ object ChatController { } } - fun getXFTPCfg(): XFTPFileConfig { - return XFTPFileConfig(minFileSize = 0) - } - fun getNetCfg(): NetCfg { val useSocksProxy = appPrefs.networkUseSocksProxy.get() val proxyHostPort = appPrefs.networkProxyHostPort.get() @@ -2285,7 +2275,6 @@ sealed class CC { class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class SetRemoteHostsFolder(val remoteHostsFolder: String): CC() - class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() @@ -2415,7 +2404,6 @@ sealed class CC { is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder" - is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" @@ -2550,7 +2538,6 @@ sealed class CC { is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" is SetRemoteHostsFolder -> "setRemoteHostsFolder" - is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" @@ -2716,9 +2703,6 @@ sealed class ChatPagination { @Serializable class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) -@Serializable -class XFTPFileConfig(val minFileSize: Long) - @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) 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 63fcb90bbe..7a7c2d7f24 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 @@ -91,7 +91,6 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (appPlatform.isDesktop) { controller.apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) } - controller.apiSetXFTPConfig(controller.getXFTPCfg()) controller.apiSetEncryptLocalFiles(controller.appPrefs.privacyEncryptLocalFiles.get()) // If we migrated successfully means previous re-encryption process on database level finished successfully too if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index b49a3605b6..bd17a55926 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -12,7 +12,6 @@ export type ChatCommand = | APIStopChat | SetTempFolder | SetFilesFolder - | APISetXFTPConfig | SetIncognito | APIExportArchive | APIImportArchive @@ -112,7 +111,6 @@ type ChatCommandTag = | "apiStopChat" | "setTempFolder" | "setFilesFolder" - | "apiSetXFTPConfig" | "setIncognito" | "apiExportArchive" | "apiImportArchive" @@ -242,15 +240,6 @@ export interface SetFilesFolder extends IChatCommand { filePath: string } -export interface APISetXFTPConfig extends IChatCommand { - type: "apiSetXFTPConfig" - config?: XFTPFileConfig -} - -export interface XFTPFileConfig { - minFileSize: number -} - export interface SetIncognito extends IChatCommand { type: "setIncognito" incognito: boolean @@ -707,8 +696,6 @@ export function cmdString(cmd: ChatCommand): string { return `/_temp_folder ${cmd.tempFolder}` case "setFilesFolder": return `/_files_folder ${cmd.filePath}` - case "apiSetXFTPConfig": - return `/_xftp ${onOff(cmd.config)}${maybeJSON(cmd.config)}` case "setIncognito": return `/incognito ${onOff(cmd.incognito)}` case "apiExportArchive": diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dcd392629c..9a0aaff0dc 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -81,7 +81,7 @@ import Simplex.Chat.Types.Util 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) +import Simplex.FileTransfer.Description (ValidFileDescription) import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentWorkersDetails, getAgentWorkersSummary, temporaryAgentError) @@ -142,8 +142,6 @@ defaultChatConfig = xftpDescrPartSize = 14000, inlineFiles = defaultInlineFilesConfig, autoAcceptFileSize = 0, - xftpFileConfig = Just defaultXFTPFileConfig, - tempDir = Nothing, showReactions = False, showReceipts = False, logLevel = CLLImportant, @@ -201,7 +199,7 @@ newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Boo newChatController ChatDatabase {chatStore, agentStore} user - cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} + cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, 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} @@ -236,8 +234,7 @@ newChatController chatActivated <- newTVarIO True showLiveItems <- newTVarIO False encryptLocalFiles <- newTVarIO False - userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg - tempDirectory <- newTVarIO tempDir + tempDirectory <- newTVarIO Nothing contactMergeEnabled <- newTVarIO True pure ChatController @@ -272,7 +269,6 @@ newChatController chatActivated, showLiveItems, encryptLocalFiles, - userXFTPFileConfig, tempDirectory, logFilePath = logFile, contactMergeEnabled @@ -582,9 +578,6 @@ processChatCommand' vr = \case createDirectoryIfMissing True rf chatWriteVar remoteHostsFolder $ Just rf ok_ - APISetXFTPConfig cfg -> do - asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) - ok_ APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ SetContactMergeEnabled onOff -> do asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) @@ -645,7 +638,7 @@ processChatCommand' vr = \case memStatuses -> pure $ Just $ map (uncurry MemberDeliveryStatus) memStatuses _ -> pure Nothing pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} - APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of + APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> withChatLock "sendMessage" $ case cType of CTDirect -> do ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgNew_ @@ -653,45 +646,19 @@ processChatCommand' vr = \case if isVoice mc && not (featureAllowed SCFVoice forUser ct) then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) else do - (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct + (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer ct timed_ <- sndContactCITimed live ct itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ - (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) + (msg, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live - case ft_ of - Just ft@FileTransferMeta {fileInline = Just IFMSent} -> - sendDirectFileInline ct ft sharedMsgId - _ -> pure () forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) where - setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) + setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd)) setupSndFileTransfer ct = forM file_ $ \file -> do - (fileSize, fileMode) <- checkSndFile mc file 1 - case fileMode of - SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline - SendFileXFTP -> xftpSndFileTransfer user file fileSize 1 $ CGContact ct - where - smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled - smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do - subMode <- chatReadVar subscriptionMode - (agentConnId_, fileConnReq) <- - if isJust fileInline - then pure (Nothing, Nothing) - else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode) - let fileName = takeFileName file - fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} - chSize <- asks $ fileChunkSize . config - withStore $ \db -> do - ft@FileTransferMeta {fileId} <- liftIO $ createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode - fileStatus <- case fileInline of - Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1 - _ -> pure CIFSSndStored - let fileSource = Just $ CF.plain file - ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} - pure (fileInvitation, ciFile, ft) + fileSize <- checkSndFile file + xftpSndFileTransfer user file fileSize 1 $ CGContact ct prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) prepareMsg fInv_ timed_ = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) @@ -718,53 +685,27 @@ processChatCommand' vr = \case | 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) + (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer g (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainer, quotedItem_) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ timed_ live - (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) + (msg, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live withStore' $ \db -> forM_ sentToMembers $ \GroupMember {groupMemberId} -> createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew - mapM_ (sendGroupFileInline ms sharedMsgId) ft_ forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) - setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) - setupSndFileTransfer g@(Group gInfo _) n = forM file_ $ \file -> do - (fileSize, fileMode) <- checkSndFile mc file $ fromIntegral n - case fileMode of - SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline - SendFileXFTP -> xftpSndFileTransfer user file fileSize n $ CGGroup g - where - smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) - smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled - smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do - let fileName = takeFileName file - fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline, fileDescr = Nothing} - fileStatus = if fileInline == Just IFMSent then CIFSSndTransfer 0 1 else CIFSSndStored - chSize <- asks $ fileChunkSize . config - withStore' $ \db -> do - ft@FileTransferMeta {fileId} <- createSndGroupFileTransfer db userId gInfo file fileInvitation chSize - let fileSource = Just $ CF.plain file - ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol = FPSMP} - pure (fileInvitation, ciFile, ft) - sendGroupFileInline :: [GroupMember] -> SharedMsgId -> FileTransferMeta -> m () - sendGroupFileInline ms sharedMsgId ft@FileTransferMeta {fileInline} = - when (fileInline == Just IFMSent) . forM_ ms $ \m -> - processMember m `catchChatError` (toView . CRChatError (Just user)) - where - processMember m@GroupMember {activeConn = Just conn@Connection {connStatus}} = - when (connStatus == ConnReady || connStatus == ConnSndReady) $ do - void . withStore' $ \db -> createSndGroupInlineFT db m conn ft - sendMemberFileInline m conn ft sharedMsgId - processMember _ = pure () + setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer g n = forM file_ $ \file -> do + fileSize <- checkSndFile file + xftpSndFileTransfer user file fileSize n $ CGGroup g CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where - xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) + xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd) xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do let fileName = takeFileName filePath fileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} @@ -788,10 +729,7 @@ processChatCommand' vr = \case withStore' $ \db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr saveMemberFD _ = pure () - pure (fInv, ciFile, ft) - unzipMaybe3 :: Maybe (a, b, c) -> (Maybe a, Maybe b, Maybe c) - unzipMaybe3 (Just (a, b, c)) = (Just a, Just b, Just c) - unzipMaybe3 _ = (Nothing, Nothing, Nothing) + pure (fInv, ciFile) APICreateChatItem folderId (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> do forM_ quotedItemId_ $ \_ -> throwError $ ChatError $ CECommandError "not supported" nf <- withStore $ \db -> getNoteFolder db user folderId @@ -2202,27 +2140,13 @@ processChatCommand' vr = \case contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: MsgContent -> CryptoFile -> Integer -> m (Integer, SendFileMode) - checkSndFile mc (CryptoFile f cfArgs) n = do + checkSndFile :: CryptoFile -> m Integer + checkSndFile (CryptoFile f cfArgs) = do fsFilePath <- toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f - ChatConfig {fileChunkSize, inlineFiles} <- asks config - xftpCfg <- readTVarIO =<< asks userXFTPFileConfig fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f - let chunks = -((-fileSize) `div` fileChunkSize) - fileInline = inlineFileMode mc inlineFiles chunks n - fileMode = case xftpCfg of - Just cfg - | isJust cfArgs -> SendFileXFTP - | fileInline == Just IFMSent || fileSize < minFileSize cfg || n <= 0 -> SendFileSMP fileInline - | otherwise -> SendFileXFTP - _ -> SendFileSMP fileInline - pure (fileSize, fileMode) - inlineFileMode mc InlineFilesConfig {offerChunks, sendChunks, totalSendChunks} chunks n - | chunks > offerChunks = Nothing - | chunks <= sendChunks && chunks * n <= totalSendChunks && isVoice mc = Just IFMSent - | otherwise = Just IFMOffer + pure fileSize updateProfile :: User -> Profile -> m ChatResponse updateProfile user p' = updateProfile_ user p' $ withStore $ \db -> updateUserProfile db user p' updateProfile_ :: User -> Profile -> m User -> m ChatResponse @@ -6495,8 +6419,6 @@ chatCommandP = "/_temp_folder " *> (SetTempFolder <$> filePath), ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), "/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath), - "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), - "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), @@ -6868,14 +6790,6 @@ chatCommandP = logErrors <- " log=" *> onOffP <|> pure False let tcpTimeout = 1000000 * fromMaybe (maybe 5 (const 10) socksProxy) t_ pure $ fullNetworkConfig socksProxy tcpTimeout logErrors - xftpCfgP = XFTPFileConfig <$> (" size=" *> fileSizeP <|> pure 0) - fileSizeP = - A.choice - [ gb <$> A.decimal <* "gb", - mb <$> A.decimal <* "mb", - kb <$> A.decimal <* "kb", - A.decimal - ] dbKeyP = nonEmptyKey <$?> strP nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d3c8698f94..36dbae8e47 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -128,8 +128,6 @@ data ChatConfig = ChatConfig xftpDescrPartSize :: Int, inlineFiles :: InlineFilesConfig, autoAcceptFileSize :: Integer, - xftpFileConfig :: Maybe XFTPFileConfig, -- Nothing - XFTP is disabled - tempDir :: Maybe FilePath, showReactions :: Bool, showReceipts :: Bool, subscriptionEvents :: Bool, @@ -204,7 +202,6 @@ data ChatController = ChatController timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool, encryptLocalFiles :: TVar Bool, - userXFTPFileConfig :: TVar (Maybe XFTPFileConfig), tempDirectory :: TVar (Maybe FilePath), logFilePath :: Maybe FilePath, contactMergeEnabled :: TVar Bool @@ -242,7 +239,6 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | SetRemoteHostsFolder FilePath - | APISetXFTPConfig (Maybe XFTPFileConfig) | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool | APIExportArchive ArchiveConfig @@ -473,7 +469,6 @@ allowRemoteCommand = \case SetTempFolder _ -> False SetFilesFolder _ -> False SetRemoteHostsFolder _ -> False - APISetXFTPConfig _ -> False APISetEncryptLocalFiles _ -> False APIExportArchive _ -> False APIImportArchive _ -> False @@ -934,14 +929,6 @@ instance FromJSON ComposedMessage where parseJSON invalid = JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) -data XFTPFileConfig = XFTPFileConfig - { minFileSize :: Integer - } - deriving (Show) - -defaultXFTPFileConfig :: XFTPFileConfig -defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} - data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) @@ -1001,11 +988,6 @@ data CoreVersionInfo = CoreVersionInfo } deriving (Show) -data SendFileMode - = SendFileSMP (Maybe InlineFileMode) - | SendFileXFTP - deriving (Show) - data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats @@ -1409,6 +1391,4 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) $(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) -$(JQ.deriveJSON defaultJSON ''XFTPFileConfig) - $(JQ.deriveToJSON defaultJSON ''ComposedMessage) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index bc5cec3332..d2351b4005 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -14,7 +14,6 @@ module Simplex.Chat.Store.Files ( getLiveSndFileTransfers, getLiveRcvFileTransfers, getPendingSndChunks, - createSndDirectFileTransfer, createSndDirectFTConnection, createSndGroupFileTransfer, createSndGroupFileTransferConnection, @@ -169,23 +168,6 @@ getPendingSndChunks db fileId connId = |] (fileId, connId) -createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> SubscriptionMode -> IO FileTransferMeta -createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize subMode = do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((userId, contactId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs)) - fileId <- insertedRowId db - forM_ acId_ $ \acId -> do - Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode - let fileStatus = FSNew - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, file_inline, connection_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, fileStatus, fileInline, connId, currentTs, currentTs) - pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} - createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do currentTs <- getCurrentTime diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index f7982c5fb4..0240648603 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 Control.Monad.Reader import Data.ByteArray (ScrubbedBytes) import Data.Functor (($>)) import Data.List (dropWhileEnd, find) @@ -22,7 +23,7 @@ import Data.Maybe (isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..)) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..)) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Store @@ -129,8 +130,7 @@ testCfg = { agentConfig = testAgentCfg, showReceipts = False, testView = True, - tbqSize = 16, - xftpFileConfig = Nothing + tbqSize = 16 } testAgentCfgVPrev :: AgentConfig @@ -209,6 +209,7 @@ startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts False + void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc 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 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 8e7f8536ee..d17a94dbdf 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1067,7 +1067,7 @@ testChatWorking alice bob = do alice <# "bob> hello too" testMaintenanceModeWithFiles :: HasCallStack => FilePath -> IO () -testMaintenanceModeWithFiles tmp = do +testMaintenanceModeWithFiles tmp = withXFTPServer $ do withNewTestChat tmp "bob" bobProfile $ \bob -> do withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" @@ -1075,12 +1075,26 @@ testMaintenanceModeWithFiles tmp = do alice ##> "/_files_folder ./tests/tmp/alice_files" alice <## "ok" connectUsers alice bob - startFileTransferWithDest' bob alice "test.jpg" "136.5 KiB / 139737 bytes" Nothing - bob <## "completed sending file 1 (test.jpg) to alice" + + bob #> "/f @alice ./tests/fixtures/test.jpg" + bob <## "use /fc 1 to cancel sending" + alice <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + alice <## "use /fr 1 [/ | ] to receive it" + bob <## "completed uploading file 1 (test.jpg) for alice" + + alice ##> "/fr 1" + alice + <### [ "saving file 1 from bob to test.jpg", + "started receiving file 1 (test.jpg) from bob" + ] alice <## "completed receiving file 1 (test.jpg) from bob" + src <- B.readFile "./tests/fixtures/test.jpg" - B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src + dest <- B.readFile "./tests/tmp/alice_files/test.jpg" + dest `shouldBe` src + threadDelay 500000 + alice ##> "/_stop" alice <## "chat stopped" alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index a6b0f56ba3..1b34f909f1 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -13,7 +13,7 @@ import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Simplex.Chat (roundedFDCount) -import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), XFTPFileConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Mobile.File import Simplex.Chat.Options (ChatOpts (..)) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) @@ -25,42 +25,16 @@ import Test.Hspec hiding (it) chatFileTests :: SpecWith FilePath chatFileTests = do - describe "sending and receiving files" $ do - describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer - describe "send file, receive and locally encrypt file" $ fileTestMatrix2 runTestFileTransferEncrypted - it "send and receive file inline (without accepting)" testInlineFileTransfer - it "send inline file, receive (without accepting) and locally encrypt" testInlineFileTransferEncrypted - xit'' "accept inline file transfer, sender cancels during transfer" testAcceptInlineFileSndCancelDuringTransfer - it "send and receive small file inline (default config)" testSmallInlineFileTransfer - it "small file sent without acceptance is ignored in terminal by default" testSmallInlineFileIgnored - it "receive file inline with inline=on option" testReceiveInline - describe "send and receive a small file" $ fileTestMatrix2 runTestSmallFileTransfer - describe "sender cancelled file transfer before transfer" $ fileTestMatrix2 runTestFileSndCancelBeforeTransfer - it "sender cancelled file transfer during transfer" testFileSndCancelDuringTransfer - it "recipient cancelled file transfer" testFileRcvCancel - describe "send and receive file to group" $ fileTestMatrix3 runTestGroupFileTransfer - it "send and receive file inline to group (without accepting)" testInlineGroupFileTransfer - it "send and receive small file inline to group (default config)" testSmallInlineGroupFileTransfer - it "small file sent without acceptance is ignored in terminal by default" testSmallInlineGroupFileIgnored - describe "sender cancelled group file transfer before transfer" $ fileTestMatrix3 runTestGroupFileSndCancelBeforeTransfer describe "messages with files" $ do - describe "send and receive message with file" $ fileTestMatrix2 runTestMessageWithFile + it "send and receive message with file" runTestMessageWithFile it "send and receive image" testSendImage - it "sender marking chat item deleted during file transfer cancels file" testSenderMarkItemDeletedTransfer + it "sender marking chat item deleted cancels file" testSenderMarkItemDeleted it "files folder: send and receive image" testFilesFoldersSendImage - it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete - it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete + it "files folder: sender deleted file" testFilesFoldersImageSndDelete -- TODO add test deleting during upload + it "files folder: recipient deleted file" testFilesFoldersImageRcvDelete -- TODO add test deleting during download it "send and receive image with text and quote" testSendImageWithTextAndQuote it "send and receive image to group" testGroupSendImage it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote - describe "async sending and receiving files" $ do - -- fails on CI - xit'' "send and receive file, sender restarts" testAsyncFileTransferSenderRestarts - xit'' "send and receive file, receiver restarts" testAsyncFileTransferReceiverRestarts - xdescribe "send and receive file, fully asynchronous" $ do - it "v2" testAsyncFileTransfer - it "v1" testAsyncFileTransferV1 - xit "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer describe "file transfer over XFTP" $ do it "round file description count" $ const testXFTPRoundFDCount it "send and receive file" testXFTPFileTransfer @@ -69,7 +43,6 @@ chatFileTests = do it "send and receive file in group" testXFTPGroupFileTransfer it "delete uploaded file" testXFTPDeleteUploadedFile it "delete uploaded file in group" testXFTPDeleteUploadedFileGroup - it "with changed XFTP config: send and receive file" testXFTPWithChangedConfig it "with relative paths: send and receive file" testXFTPWithRelativePaths xit' "continue receiving file after restart" testXFTPContinueRcv xit' "receive file marked to receive on chat start" testXFTPMarkToReceive @@ -78,481 +51,10 @@ chatFileTests = do it "should accept file automatically with CLI option" testAutoAcceptFile it "should prohibit file transfers in groups based on preference" testProhibitFiles -runTestFileTransfer :: HasCallStack => TestCC -> TestCC -> IO () -runTestFileTransfer alice bob = do +runTestMessageWithFile :: HasCallStack => FilePath -> IO () +runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob - startFileTransfer' alice bob "test.pdf" "266.0 KiB / 272376 bytes" - concurrentlyN_ - [ do - bob #> "@alice receiving here..." - bob <## "completed receiving file 1 (test.pdf) from alice", - alice - <### [ WithTime "bob> receiving here...", - "completed sending file 1 (test.pdf) to bob" - ] - ] - src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" - dest `shouldBe` src -runTestFileTransferEncrypted :: HasCallStack => TestCC -> TestCC -> IO () -runTestFileTransferEncrypted alice bob = do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.pdf" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 encrypt=on ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" - concurrently_ - (bob <## "started receiving file 1 (test.pdf) from alice") - (alice <## "started sending file 1 (test.pdf) to bob") - - concurrentlyN_ - [ do - bob #> "@alice receiving here..." - -- uncomment this and below to test encryption error in encryptFile - -- bob <## "cannot write file ./tests/tmp/test.pdf: test error, received file not encrypted" - bob <## "completed receiving file 1 (test.pdf) from alice", - alice - <### [ WithTime "bob> receiving here...", - "completed sending file 1 (test.pdf) to bob" - ] - ] - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob - src <- B.readFile "./tests/fixtures/test.pdf" - -- dest <- B.readFile "./tests/tmp/test.pdf" - -- dest `shouldBe` src - Right dest <- chatReadFile "./tests/tmp/test.pdf" (strEncode key) (strEncode nonce) - LB.toStrict dest `shouldBe` src - -testInlineFileTransfer :: HasCallStack => FilePath -> IO () -testInlineFileTransfer = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/test.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (test.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (test.jpg) to bob") - (bob <## "completed receiving file 1 (test.jpg) from alice") - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} - -testInlineFileTransferEncrypted :: HasCallStack => FilePath -> IO () -testInlineFileTransferEncrypted = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - bob ##> "/_files_encrypt on" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/test.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (test.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (test.jpg) to bob") - (bob <## "completed receiving file 1 (test.jpg) from alice") - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob - src <- B.readFile "./tests/fixtures/test.jpg" - Right dest <- chatReadFile "./tests/tmp/test.jpg" (strEncode key) (strEncode nonce) - LB.toStrict dest `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} - -testAcceptInlineFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO () -testAcceptInlineFileSndCancelDuringTransfer = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice #> "/f @bob ./tests/fixtures/test_1MB.pdf" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 inline=on" - bob <## "saving file 1 from alice to test_1MB.pdf" - alice <## "started sending file 1 (test_1MB.pdf) to bob" - bob <## "started receiving file 1 (test_1MB.pdf) from alice" - alice ##> "/fc 1" -- test that inline file cancel doesn't delete contact connection - concurrentlyN_ - [ do - alice <##. "cancelled sending file 1 (test_1MB.pdf)" - alice <## "completed sending file 1 (test_1MB.pdf) to bob", - bob <## "completed receiving file 1 (test_1MB.pdf) from alice" - ] - alice #> "@bob hi" - bob <# "alice> hi" - bob #> "@alice hey" - alice <# "bob> hey" - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, receiveChunks = 50}} - -testSmallInlineFileTransfer :: HasCallStack => FilePath -> IO () -testSmallInlineFileTransfer = - testChat2 aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (logo.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (logo.jpg) to bob") - (bob <## "completed receiving file 1 (logo.jpg) from alice") - src <- B.readFile "./tests/fixtures/logo.jpg" - dest <- B.readFile "./tests/tmp/logo.jpg" - dest `shouldBe` src - -testSmallInlineFileIgnored :: HasCallStack => FilePath -> IO () -testSmallInlineFileIgnored tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChatOpts tmp testOpts {allowInstantFiles = False} "bob" bobProfile $ \bob -> do - connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/" - bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob <## "A small file sent without acceptance - you can enable receiving such files with -f option." - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - alice <## "completed sending file 1 (logo.jpg) to bob" - bob ##> "/fr 1" - bob <## "file is already being received: logo.jpg" - -testReceiveInline :: HasCallStack => FilePath -> IO () -testReceiveInline = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 inline=on ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 10, receiveChunks = 5}} - -runTestSmallFileTransfer :: HasCallStack => TestCC -> TestCC -> IO () -runTestSmallFileTransfer alice bob = do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.txt" - concurrentlyN_ - [ do - bob <## "started receiving file 1 (test.txt) from alice" - bob <## "completed receiving file 1 (test.txt) from alice", - do - alice <## "started sending file 1 (test.txt) to bob" - alice <## "completed sending file 1 (test.txt) to bob" - ] - src <- B.readFile "./tests/fixtures/test.txt" - dest <- B.readFile "./tests/tmp/test.txt" - dest `shouldBe` src - -runTestFileSndCancelBeforeTransfer :: HasCallStack => TestCC -> TestCC -> IO () -runTestFileSndCancelBeforeTransfer alice bob = do - connectUsers alice bob - alice #> "/f @bob ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - alice ##> "/fc 1" - concurrentlyN_ - [ alice <##. "cancelled sending file 1 (test.txt)", - bob <## "alice cancelled sending file 1 (test.txt)" - ] - alice ##> "/fs 1" - alice - <##.. [ "sending file 1 (test.txt): no file transfers", - "sending file 1 (test.txt) cancelled: bob" - ] - alice <## "file transfer cancelled" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.txt) cancelled" - bob ##> "/fr 1 ./tests/tmp" - bob <## "file cancelled: test.txt" - -testFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO () -testFileSndCancelDuringTransfer = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" - alice ##> "/fc 1" - concurrentlyN_ - [ do - alice <## "cancelled sending file 1 (test_1MB.pdf) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test_1MB.pdf) cancelled: bob" - alice <## "file transfer cancelled", - do - bob <## "alice cancelled sending file 1 (test_1MB.pdf)" - bob ##> "/fs 1" - bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: ./tests/tmp/test_1MB.pdf" - ] - checkPartialTransfer "test_1MB.pdf" - -testFileRcvCancel :: HasCallStack => FilePath -> IO () -testFileRcvCancel = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransfer alice bob - bob ##> "/fs 1" - getTermLine bob >>= (`shouldStartWith` "receiving file 1 (test.jpg) progress") - waitFileExists "./tests/tmp/test.jpg" - bob ##> "/fc 1" - concurrentlyN_ - [ do - bob <## "cancelled receiving file 1 (test.jpg) from alice" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg", - do - alice <## "bob cancelled receiving file 1 (test.jpg)" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - alice <## "file transfer cancelled" - ] - checkPartialTransfer "test.jpg" - -runTestGroupFileTransfer :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () -runTestGroupFileTransfer alice bob cath = do - createGroup3 "team" alice bob cath - alice #> "/f #team ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it", - do - cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - ] - alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg): no file transfers") - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) complete: bob", - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath" - alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) complete"), - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] - src <- B.readFile "./tests/fixtures/test.jpg" - dest1 <- B.readFile "./tests/tmp/test.jpg" - dest2 <- B.readFile "./tests/tmp/test_1.jpg" - dest1 `shouldBe` src - dest2 `shouldBe` src - -testInlineGroupFileTransfer :: HasCallStack => FilePath -> IO () -testInlineGroupFileTransfer = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - bob ##> "/_files_folder ./tests/tmp/bob/" - bob <## "ok" - cath ##> "/_files_folder ./tests/tmp/cath/" - cath <## "ok" - alice ##> "/_send #1 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "#team voice message (00:10)" - alice <# "/f #team ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - alice - <### [ "completed sending file 1 (logo.jpg) to bob", - "completed sending file 1 (logo.jpg) to cath" - ] - alice ##> "/fs 1" - alice <##. "sending file 1 (logo.jpg) complete", - do - bob <# "#team alice> voice message (00:10)" - bob <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "started receiving file 1 (logo.jpg) from alice" - bob <## "completed receiving file 1 (logo.jpg) from alice", - do - cath <# "#team alice> voice message (00:10)" - cath <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - cath <## "started receiving file 1 (logo.jpg) from alice" - cath <## "completed receiving file 1 (logo.jpg) from alice" - ] - src <- B.readFile "./tests/fixtures/logo.jpg" - dest1 <- B.readFile "./tests/tmp/bob/logo.jpg" - dest2 <- B.readFile "./tests/tmp/cath/logo.jpg" - dest1 `shouldBe` src - dest2 `shouldBe` src - where - cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, totalSendChunks = 100, receiveChunks = 100}} - -testSmallInlineGroupFileTransfer :: HasCallStack => FilePath -> IO () -testSmallInlineGroupFileTransfer = - testChatCfg3 testCfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - bob ##> "/_files_folder ./tests/tmp/bob/" - bob <## "ok" - cath ##> "/_files_folder ./tests/tmp/cath/" - cath <## "ok" - alice ##> "/_send #1 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "#team voice message (00:10)" - alice <# "/f #team ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - alice - <### [ "completed sending file 1 (logo.jpg) to bob", - "completed sending file 1 (logo.jpg) to cath" - ] - alice ##> "/fs 1" - alice <##. "sending file 1 (logo.jpg) complete", - do - bob <# "#team alice> voice message (00:10)" - bob <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "started receiving file 1 (logo.jpg) from alice" - bob <## "completed receiving file 1 (logo.jpg) from alice", - do - cath <# "#team alice> voice message (00:10)" - cath <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - cath <## "started receiving file 1 (logo.jpg) from alice" - cath <## "completed receiving file 1 (logo.jpg) from alice" - ] - src <- B.readFile "./tests/fixtures/logo.jpg" - dest1 <- B.readFile "./tests/tmp/bob/logo.jpg" - dest2 <- B.readFile "./tests/tmp/cath/logo.jpg" - dest1 `shouldBe` src - dest2 `shouldBe` src - -testSmallInlineGroupFileIgnored :: HasCallStack => FilePath -> IO () -testSmallInlineGroupFileIgnored tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChatOpts tmp testOpts {allowInstantFiles = False} "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {allowInstantFiles = False} "cath" cathProfile $ \cath -> do - createGroup3 "team" alice bob cath - bob ##> "/_files_folder ./tests/tmp/bob/" - bob <## "ok" - cath ##> "/_files_folder ./tests/tmp/cath/" - cath <## "ok" - alice ##> "/_send #1 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/logo.jpg\"}" - alice <# "#team voice message (00:10)" - alice <# "/f #team ./tests/fixtures/logo.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - alice - <### [ "completed sending file 1 (logo.jpg) to bob", - "completed sending file 1 (logo.jpg) to cath" - ] - alice ##> "/fs 1" - alice <##. "sending file 1 (logo.jpg) complete", - do - bob <# "#team alice> voice message (00:10)" - bob <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob <## "A small file sent without acceptance - you can enable receiving such files with -f option." - bob ##> "/fr 1" - bob <## "file is already being received: logo.jpg", - do - cath <# "#team alice> voice message (00:10)" - cath <# "#team alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - cath <## "A small file sent without acceptance - you can enable receiving such files with -f option." - cath ##> "/fr 1" - cath <## "file is already being received: logo.jpg" - ] - -runTestGroupFileSndCancelBeforeTransfer :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () -runTestGroupFileSndCancelBeforeTransfer alice bob cath = do - createGroup3 "team" alice bob cath - alice #> "/f #team ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - bob <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it", - do - cath <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - ] - alice ##> "/fc 1" - concurrentlyN_ - [ alice <## "cancelled sending file 1 (test.txt)", - bob <## "alice cancelled sending file 1 (test.txt)", - cath <## "alice cancelled sending file 1 (test.txt)" - ] - alice ##> "/fs 1" - alice <## "sending file 1 (test.txt): no file transfers" - alice <## "file transfer cancelled" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.txt) cancelled" - bob ##> "/fr 1 ./tests/tmp" - bob <## "file cancelled: test.txt" - -runTestMessageWithFile :: HasCallStack => TestCC -> TestCC -> IO () -runTestMessageWithFile alice bob = do - connectUsers alice bob alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" alice <# "/f @bob ./tests/fixtures/test.jpg" @@ -560,14 +62,15 @@ runTestMessageWithFile alice bob = do bob <# "alice> hi, sending a file" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -577,21 +80,22 @@ runTestMessageWithFile alice bob = do testSendImage :: HasCallStack => FilePath -> IO () testSendImage = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -604,10 +108,10 @@ testSendImage = fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True -testSenderMarkItemDeletedTransfer :: HasCallStack => FilePath -> IO () -testSenderMarkItemDeletedTransfer = +testSenderMarkItemDeleted :: HasCallStack => FilePath -> IO () +testSenderMarkItemDeleted = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test_1MB.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" @@ -616,28 +120,21 @@ testSenderMarkItemDeletedTransfer = bob <# "alice> hi, sending a file" bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test_1MB.pdf" - concurrently_ - (bob <## "started receiving file 1 (test_1MB.pdf) from alice") - (alice <## "started sending file 1 (test_1MB.pdf) to bob") + alice <## "completed uploading file 1 (test_1MB.pdf) for bob" alice #$> ("/_delete item @2 " <> itemId 1 <> " broadcast", id, "message marked deleted") - - alice ##> "/fs 1" - alice <## "sending file 1 (test_1MB.pdf) cancelled: bob" - alice <## "file transfer cancelled" - bob <# "alice> [marked deleted] hi, sending a file" - bob ##> "/fs 1" - bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: ./tests/tmp/test_1MB.pdf" - checkPartialTransfer "test_1MB.pdf" + bob ##> "/fr 1 ./tests/tmp" + bob <## "file cancelled: test_1MB.pdf" + + bob ##> "/fs 1" + bob <## "receiving file 1 (test_1MB.pdf) cancelled" testFilesFoldersSendImage :: HasCallStack => FilePath -> IO () testFilesFoldersSendImage = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") @@ -646,14 +143,15 @@ testFilesFoldersSendImage = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1" - bob <## "saving file 1 from alice to test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/app_files/test.jpg" dest `shouldBe` src @@ -668,7 +166,7 @@ testFilesFoldersSendImage = testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageSndDelete = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" @@ -678,19 +176,22 @@ testFilesFoldersImageSndDelete = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test_1MB.pdf) for bob" + bob ##> "/fr 1" - bob <## "saving file 1 from alice to test_1MB.pdf" - concurrently_ - (bob <## "started receiving file 1 (test_1MB.pdf) from alice") - (alice <## "started sending file 1 (test_1MB.pdf) to bob") - -- deleting contact should cancel and remove file + bob + <### [ "saving file 1 from alice to test_1MB.pdf", + "started receiving file 1 (test_1MB.pdf) from alice" + ] + bob <## "completed receiving file 1 (test_1MB.pdf) from alice" + + -- deleting contact should remove file checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" bob <## "alice (Alice) deleted contact with you" bob ##> "/fs 1" - bob <##. "receiving file 1 (test_1MB.pdf) progress" - -- deleting contact should remove cancelled file + bob <##. "receiving file 1 (test_1MB.pdf) complete" checkActionDeletesFile "./tests/tmp/bob_app_files/test_1MB.pdf" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" @@ -698,7 +199,7 @@ testFilesFoldersImageSndDelete = testFilesFoldersImageRcvDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageRcvDelete = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") @@ -707,28 +208,25 @@ testFilesFoldersImageRcvDelete = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1" - bob <## "saving file 1 from alice to test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - -- deleting contact should cancel and remove file - waitFileExists "./tests/tmp/app_files/test.jpg" + bob + <### [ "saving file 1 from alice to test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + + -- deleting contact should remove file checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" - alice - <### [ "bob (Bob) deleted contact with you", - "bob cancelled receiving file 1 (test.jpg)" - ] - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - alice <## "file transfer cancelled" + alice <## "bob (Bob) deleted contact with you" testSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () testSendImageWithTextAndQuote = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do connectUsers alice bob bob #> "@alice hi alice" alice <# "bob> hi alice" @@ -741,20 +239,22 @@ testSendImageWithTextAndQuote = bob <## " hey bob" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 1 (test.jpg) from alice") - (alice <## "completed sending file 1 (test.jpg) to bob") + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" B.readFile "./tests/tmp/test.jpg" `shouldReturn` src alice #$> ("/_get chat @2 count=100", chat'', chatFeatures'' <> [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) alice @@@ [("@bob", "hey bob")] bob #$> ("/_get chat @2 count=100", chat'', chatFeatures'' <> [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) bob @@@ [("@alice", "hey bob")] + -- quoting (file + text) with file uses quoted text bob ##> ("/_send @2 json {\"filePath\": \"./tests/fixtures/test.pdf\", \"quotedItemId\": " <> itemId 2 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"file\"}}") bob <# "@alice > hey bob" @@ -765,16 +265,18 @@ testSendImageWithTextAndQuote = alice <## " test.pdf" alice <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" alice <## "use /fr 2 [/ | ] to receive it" + bob <## "completed uploading file 2 (test.pdf) for alice" + alice ##> "/fr 2 ./tests/tmp" - alice <## "saving file 2 from bob to ./tests/tmp/test.pdf" - concurrently_ - (alice <## "started receiving file 2 (test.pdf) from bob") - (bob <## "started sending file 2 (test.pdf) to alice") - concurrently_ - (alice <## "completed receiving file 2 (test.pdf) from bob") - (bob <## "completed sending file 2 (test.pdf) to alice") + alice + <### [ "saving file 2 from bob to ./tests/tmp/test.pdf", + "started receiving file 2 (test.pdf) from bob" + ] + alice <## "completed receiving file 2 (test.pdf) from bob" + txtSrc <- B.readFile "./tests/fixtures/test.pdf" B.readFile "./tests/tmp/test.pdf" `shouldReturn` txtSrc + -- quoting (file without text) with file uses file name alice ##> ("/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> itemId 3 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}") alice <# "@bob > test.pdf" @@ -785,20 +287,21 @@ testSendImageWithTextAndQuote = bob <## " test.jpg" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 3 [/ | ] to receive it" + alice <## "completed uploading file 3 (test.jpg) for bob" + bob ##> "/fr 3 ./tests/tmp" - bob <## "saving file 3 from alice to ./tests/tmp/test_1.jpg" - concurrently_ - (bob <## "started receiving file 3 (test.jpg) from alice") - (alice <## "started sending file 3 (test.jpg) to bob") - concurrently_ - (bob <## "completed receiving file 3 (test.jpg) from alice") - (alice <## "completed sending file 3 (test.jpg) to bob") + bob + <### [ "saving file 3 from alice to ./tests/tmp/test_1.jpg", + "started receiving file 3 (test.jpg) from alice" + ] + bob <## "completed receiving file 3 (test.jpg) from alice" + B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src testGroupSendImage :: HasCallStack => FilePath -> IO () testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath threadDelay 1000000 alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" @@ -812,26 +315,22 @@ testGroupSendImage = cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob", - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath", - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] + alice <## "completed uploading file 1 (test.jpg) for #team" + + bob ##> "/fr 1 ./tests/tmp" + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + + cath ##> "/fr 1 ./tests/tmp" + cath + <### [ "saving file 1 from alice to ./tests/tmp/test_1.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + cath <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -844,7 +343,7 @@ testGroupSendImage = testGroupSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () testGroupSendImageWithTextAndQuote = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath threadDelay 1000000 bob #> "#team hi team" @@ -870,26 +369,22 @@ testGroupSendImageWithTextAndQuote = cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob", - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath", - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] + alice <## "completed uploading file 1 (test.jpg) for #team" + + bob ##> "/fr 1 ./tests/tmp" + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" + + cath ##> "/fr 1 ./tests/tmp" + cath + <### [ "saving file 1 from alice to ./tests/tmp/test_1.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + cath <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src @@ -902,142 +397,6 @@ testGroupSendImageWithTextAndQuote = cath #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) cath @@@ [("#team", "hey bob"), ("@alice", "received invitation to join group team as admin")] -testAsyncFileTransferSenderRestarts :: HasCallStack => FilePath -> IO () -testAsyncFileTransferSenderRestarts tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - connectUsers alice bob - startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" - threadDelay 100000 - withTestChatContactConnected tmp "alice" $ \alice -> do - alice <## "completed sending file 1 (test_1MB.pdf) to bob" - bob <## "completed receiving file 1 (test_1MB.pdf) from alice" - src <- B.readFile "./tests/fixtures/test_1MB.pdf" - dest <- B.readFile "./tests/tmp/test_1MB.pdf" - dest `shouldBe` src - -testAsyncFileTransferReceiverRestarts :: HasCallStack => FilePath -> IO () -testAsyncFileTransferReceiverRestarts tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - connectUsers alice bob - startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" - threadDelay 100000 - withTestChatContactConnected tmp "bob" $ \bob -> do - alice <## "completed sending file 1 (test_1MB.pdf) to bob" - bob <## "completed receiving file 1 (test_1MB.pdf) from alice" - src <- B.readFile "./tests/fixtures/test_1MB.pdf" - dest <- B.readFile "./tests/tmp/test_1MB.pdf" - dest `shouldBe` src - -testAsyncFileTransfer :: HasCallStack => FilePath -> IO () -testAsyncFileTransfer tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - connectUsers alice bob - withTestChatContactConnected tmp "alice" $ \alice -> do - alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\":\"text\", \"text\": \"hi, sending a file\"}}" - alice <# "@bob hi, sending a file" - alice <# "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - withTestChatContactConnected tmp "bob" $ \bob -> do - bob <# "alice> hi, sending a file" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - -- withTestChatContactConnected' tmp "alice" -- TODO not needed in v2 - -- withTestChatContactConnected' tmp "bob" -- TODO not needed in v2 - withTestChatContactConnected' tmp "alice" - withTestChatContactConnected' tmp "bob" - withTestChatContactConnected tmp "alice" $ \alice -> do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - withTestChatContactConnected tmp "bob" $ \bob -> do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - -testAsyncFileTransferV1 :: HasCallStack => FilePath -> IO () -testAsyncFileTransferV1 tmp = do - withNewTestChatV1 tmp "alice" aliceProfile $ \alice -> - withNewTestChatV1 tmp "bob" bobProfile $ \bob -> - connectUsers alice bob - withTestChatContactConnectedV1 tmp "alice" $ \alice -> do - alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\":\"text\", \"text\": \"hi, sending a file\"}}" - alice <# "@bob hi, sending a file" - alice <# "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - withTestChatContactConnectedV1 tmp "bob" $ \bob -> do - bob <# "alice> hi, sending a file" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - withTestChatContactConnectedV1' tmp "alice" -- TODO not needed in v2 - withTestChatContactConnectedV1' tmp "bob" -- TODO not needed in v2 - withTestChatContactConnectedV1' tmp "alice" - withTestChatContactConnectedV1' tmp "bob" - withTestChatContactConnectedV1 tmp "alice" $ \alice -> do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - withTestChatContactConnectedV1 tmp "bob" $ \bob -> do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - -testAsyncGroupFileTransfer :: HasCallStack => FilePath -> IO () -testAsyncGroupFileTransfer tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> - createGroup3 "team" alice bob cath - withTestChatGroup3Connected tmp "alice" $ \alice -> do - alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"text\"}}" - alice <# "/f #team ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - withTestChatGroup3Connected tmp "bob" $ \bob -> do - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - withTestChatGroup3Connected tmp "cath" $ \cath -> do - cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - withTestChatGroup3Connected' tmp "alice" - withTestChatGroup3Connected' tmp "bob" - withTestChatGroup3Connected' tmp "cath" - -- withTestChatGroup3Connected' tmp "alice" -- TODO not needed in v2 - -- withTestChatGroup3Connected' tmp "bob" -- TODO not needed in v2 - -- withTestChatGroup3Connected' tmp "cath" -- TODO not needed in v2 - withTestChatGroup3Connected' tmp "alice" - withTestChatGroup3Connected tmp "bob" $ \bob -> do - bob <## "started receiving file 1 (test.jpg) from alice" - withTestChatGroup3Connected tmp "cath" $ \cath -> do - cath <## "started receiving file 1 (test.jpg) from alice" - withTestChatGroup3Connected tmp "alice" $ \alice -> do - alice - <### [ "started sending file 1 (test.jpg) to bob", - "completed sending file 1 (test.jpg) to bob", - "started sending file 1 (test.jpg) to cath", - "completed sending file 1 (test.jpg) to cath" - ] - withTestChatGroup3Connected tmp "bob" $ \bob -> do - bob <## "completed receiving file 1 (test.jpg) from alice" - withTestChatGroup3Connected tmp "cath" $ \cath -> do - cath <## "completed receiving file 1 (test.jpg) from alice" - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - dest2 <- B.readFile "./tests/tmp/test_1.jpg" - dest2 `shouldBe` src - testXFTPRoundFDCount :: Expectation testXFTPRoundFDCount = do roundedFDCount (-100) `shouldBe` 4 @@ -1053,13 +412,12 @@ testXFTPRoundFDCount = do testXFTPFileTransfer :: HasCallStack => FilePath -> IO () testXFTPFileTransfer = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 ./tests/tmp" @@ -1080,12 +438,10 @@ testXFTPFileTransfer = src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPFileTransferEncrypted :: HasCallStack => FilePath -> IO () testXFTPFileTransferEncrypted = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do src <- B.readFile "./tests/fixtures/test.pdf" srcLen <- getFileSize "./tests/fixtures/test.pdf" let srcPath = "./tests/tmp/alice/test.pdf" @@ -1109,12 +465,10 @@ testXFTPFileTransferEncrypted = Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPAcceptAfterUpload :: HasCallStack => FilePath -> IO () testXFTPAcceptAfterUpload = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob @@ -1122,7 +476,6 @@ testXFTPAcceptAfterUpload = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" threadDelay 100000 @@ -1137,12 +490,10 @@ testXFTPAcceptAfterUpload = src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPGroupFileTransfer :: HasCallStack => FilePath -> IO () testXFTPGroupFileTransfer = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do createGroup3 "team" alice bob cath @@ -1156,7 +507,6 @@ testXFTPGroupFileTransfer = cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - -- alice <## "started sending file 1 (test.pdf) to #team" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for #team" bob ##> "/fr 1 ./tests/tmp" @@ -1178,12 +528,10 @@ testXFTPGroupFileTransfer = dest2 <- B.readFile "./tests/tmp/test_1.pdf" dest1 `shouldBe` src dest2 `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPDeleteUploadedFile :: HasCallStack => FilePath -> IO () testXFTPDeleteUploadedFile = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob @@ -1191,7 +539,6 @@ testXFTPDeleteUploadedFile = alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" alice ##> "/fc 1" @@ -1202,12 +549,10 @@ testXFTPDeleteUploadedFile = bob ##> "/fr 1 ./tests/tmp" bob <## "file cancelled: test.pdf" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPDeleteUploadedFileGroup :: HasCallStack => FilePath -> IO () testXFTPDeleteUploadedFileGroup = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do createGroup3 "team" alice bob cath @@ -1221,7 +566,6 @@ testXFTPDeleteUploadedFileGroup = cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" cath <## "use /fr 1 [/ | ] to receive it" ] - -- alice <## "started sending file 1 (test.pdf) to #team" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for #team" bob ##> "/fr 1 ./tests/tmp" @@ -1257,45 +601,10 @@ testXFTPDeleteUploadedFileGroup = cath ##> "/fr 1 ./tests/tmp" cath <## "file cancelled: test.pdf" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} - -testXFTPWithChangedConfig :: HasCallStack => FilePath -> IO () -testXFTPWithChangedConfig = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do - alice #$> ("/_xftp off", id, "ok") - alice #$> ("/_xftp on {\"minFileSize\":1024}", id, "ok") - - bob #$> ("/xftp off", id, "ok") - bob #$> ("/xftp on size=1kb", id, "ok") - - connectUsers alice bob - - alice #> "/f @bob ./tests/fixtures/test.pdf" - alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? - bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - concurrentlyN_ - [ alice <## "completed uploading file 1 (test.pdf) for bob", - bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", - "started receiving file 1 (test.pdf) from alice" - ] - ] - bob <## "completed receiving file 1 (test.pdf) from alice" - - src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" - dest `shouldBe` src - where - cfg = testCfg {tempDir = Just "./tests/tmp"} testXFTPWithRelativePaths :: HasCallStack => FilePath -> IO () testXFTPWithRelativePaths = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do -- agent is passed xftp work directory only on chat start, -- so for test we work around by stopping and starting chat @@ -1317,7 +626,6 @@ testXFTPWithRelativePaths = alice #> "/f @bob test.pdf" alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1" @@ -1333,25 +641,22 @@ testXFTPWithRelativePaths = src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}} testXFTPContinueRcv :: HasCallStack => FilePath -> IO () testXFTPContinueRcv tmp = do withXFTPServer $ do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" -- server is down - file is not received - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -1366,18 +671,16 @@ testXFTPContinueRcv tmp = do withXFTPServer $ do -- server is up - file reception is continued - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPMarkToReceive :: HasCallStack => FilePath -> IO () testXFTPMarkToReceive = do - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do connectUsers alice bob @@ -1385,7 +688,6 @@ testXFTPMarkToReceive = do alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" bob #$> ("/_set_file_to_receive 1", id, "ok") @@ -1412,26 +714,23 @@ testXFTPMarkToReceive = do src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}} testXFTPRcvError :: HasCallStack => FilePath -> IO () testXFTPRcvError tmp = do withXFTPServer $ do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" -- server is up w/t store log - file reception should fail withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -1443,8 +742,6 @@ testXFTPRcvError tmp = do bob ##> "/fs 1" bob <## "receiving file 1 (test.pdf) error" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testXFTPCancelRcvRepeat :: HasCallStack => FilePath -> IO () testXFTPCancelRcvRepeat = @@ -1456,7 +753,6 @@ testXFTPCancelRcvRepeat = alice #> "/f @bob ./tests/tmp/testfile" alice <## "use /fc 1 to cancel sending" - -- alice <## "started sending file 1 (testfile) to bob" -- TODO "started uploading" ? bob <# "alice> sends file testfile (17.0 MiB / 17825792 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 ./tests/tmp" @@ -1493,11 +789,11 @@ testXFTPCancelRcvRepeat = dest <- B.readFile "./tests/tmp/testfile_1" dest `shouldBe` src where - cfg = testCfg {xftpDescrPartSize = 200, xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + cfg = testCfg {xftpDescrPartSize = 200} testAutoAcceptFile :: HasCallStack => FilePath -> IO () testAutoAcceptFile = - testChatCfgOpts2 cfg opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob bob ##> "/_files_folder ./tests/tmp/bob_files" bob <## "ok" @@ -1518,12 +814,11 @@ testAutoAcceptFile = -- no auto accept for large files (bob FilePath -> IO () testProhibitFiles = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath alice ##> "/set files #team off" alice <## "updated group preferences:" @@ -1542,22 +837,6 @@ testProhibitFiles = alice <## "bad chat command: feature not allowed Files and media" (bob TestCC -> TestCC -> IO () -startFileTransfer alice bob = - startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes" - -startFileTransfer' :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -startFileTransfer' cc1 cc2 fName fSize = startFileTransferWithDest' cc1 cc2 fName fSize $ Just "./tests/tmp" - -checkPartialTransfer :: HasCallStack => String -> IO () -checkPartialTransfer fileName = do - src <- B.readFile $ "./tests/fixtures/" <> fileName - dest <- B.readFile $ "./tests/tmp/" <> fileName - B.unpack src `shouldStartWith` B.unpack dest - B.length src > B.length dest `shouldBe` True waitFileExists :: HasCallStack => FilePath -> IO () waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 51a5b352a7..3057fa7b70 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -11,7 +11,7 @@ import Control.Monad (void, when) import qualified Data.ByteString as B import Data.List (isInfixOf) import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..)) @@ -4321,7 +4321,7 @@ testGroupMsgForwardDeletion = testGroupMsgForwardFile :: HasCallStack => FilePath -> IO () testGroupMsgForwardFile = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do setupGroupForwarding3 "team" alice bob cath @@ -4343,8 +4343,6 @@ testGroupMsgForwardFile = src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () testGroupMsgForwardChangeRole = @@ -4577,7 +4575,7 @@ testGroupHistoryPreferenceOff = testGroupHistoryHostFile :: HasCallStack => FilePath -> IO () testGroupHistoryHostFile = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup2 "team" alice bob @@ -4613,12 +4611,10 @@ testGroupHistoryHostFile = src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryMemberFile :: HasCallStack => FilePath -> IO () testGroupHistoryMemberFile = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup2 "team" alice bob @@ -4654,8 +4650,6 @@ testGroupHistoryMemberFile = src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryLargeFile :: HasCallStack => FilePath -> IO () testGroupHistoryLargeFile = @@ -4713,11 +4707,11 @@ testGroupHistoryLargeFile = destCath <- B.readFile "./tests/tmp/testfile_2" destCath `shouldBe` src where - cfg = testCfg {xftpDescrPartSize = 200, xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + cfg = testCfg {xftpDescrPartSize = 200} testGroupHistoryMultipleFiles :: HasCallStack => FilePath -> IO () testGroupHistoryMultipleFiles = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] @@ -4794,12 +4788,10 @@ testGroupHistoryMultipleFiles = `shouldContain` [ ((0, "hi alice"), Just "./tests/tmp/testfile_bob_1"), ((0, "hey bob"), Just "./tests/tmp/testfile_alice_1") ] - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryFileCancel :: HasCallStack => FilePath -> IO () testGroupHistoryFileCancel = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] @@ -4851,12 +4843,10 @@ testGroupHistoryFileCancel = bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" ] - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryFileCancelNoText :: HasCallStack => FilePath -> IO () testGroupHistoryFileCancelNoText = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] @@ -4912,8 +4902,6 @@ testGroupHistoryFileCancelNoText = bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" ] - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} testGroupHistoryQuotes :: HasCallStack => FilePath -> IO () testGroupHistoryQuotes = diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index 1d0c540d76..6ea41a2387 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -12,7 +12,6 @@ import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), default import System.Directory (copyFile, doesFileExist) import System.FilePath (()) import Test.Hspec hiding (it) -import UnliftIO.Async (concurrently_) chatLocalChatsTests :: SpecWith FilePath chatLocalChatsTests = do @@ -158,24 +157,24 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do testOtherFiles :: FilePath -> IO () testOtherFiles = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob createCCNoteFolder bob bob ##> "/_files_folder ./tests/tmp/" bob <## "ok" - alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" - alice <# "@bob voice message (00:10)" - alice <# "/f @bob ./tests/fixtures/test.jpg" - -- below is not shown in "sent" mode - -- alice <## "use /fc 1 to cancel sending" - bob <# "alice> voice message (00:10)" + + alice #> "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - -- below is not shown in "sent" mode - -- bob <## "use /fr 1 [/ | ] to receive it" - bob <## "started receiving file 1 (test.jpg) from alice" - concurrently_ - (alice <## "completed sending file 1 (test.jpg) to bob") - (bob <## "completed receiving file 1 (test.jpg) from alice") + bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" + + bob ##> "/fr 1" + bob + <### [ "saving file 1 from alice to test.jpg", + "started receiving file 1 (test.jpg) from alice" + ] + bob <## "completed receiving file 1 (test.jpg) from alice" bob /* "test" bob ##> "/tail *" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 80cdc34c76..30c78138ad 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1493,7 +1493,7 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ testSetContactPrefs :: HasCallStack => FilePath -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ - \alice bob -> do + \alice bob -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") bob #$> ("/_files_folder ./tests/tmp/bob", id, "ok") createDirectoryIfMissing True "./tests/tmp/alice" @@ -1528,15 +1528,24 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you")]) alice ##> sendVoice alice <## voiceNotAllowed + + -- sending voice message allowed bob ##> sendVoice bob <# "@alice voice message (00:10)" bob <# "/f @alice test.txt" - bob <## "completed sending file 1 (test.txt) to alice" + bob <## "use /fc 1 to cancel sending" alice <# "bob> voice message (00:10)" alice <# "bob> sends file test.txt (11 bytes / 11 bytes)" - alice <## "started receiving file 1 (test.txt) from bob" + alice <## "use /fr 1 [/ | ] to receive it" + bob <## "completed uploading file 1 (test.txt) for alice" + alice ##> "/fr 1" + alice + <### [ "saving file 1 from bob to test_1.txt", + "started receiving file 1 (test.txt) from bob" + ] alice <## "completed receiving file 1 (test.txt) from bob" (bob "/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"Alice\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" alice ##> "/set voice no" alice <## "updated preferences:" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 433bf46036..9ce84be18e 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -19,7 +19,7 @@ import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T import Database.SQLite.Simple (Only (..)) -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) import Simplex.Chat.Protocol import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) @@ -32,7 +32,6 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) import System.Environment (lookupEnv, withArgs) -import System.FilePath (()) import System.IO.Silently (capture_) import System.Info (os) import Test.Hspec hiding (it) @@ -96,29 +95,6 @@ versionTestMatrix3 runTest = do it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest -inlineCfg :: Integer -> ChatConfig -inlineCfg n = testCfg {inlineFiles = defaultInlineFilesConfig {sendChunks = 0, offerChunks = n, receiveChunks = n}} - -fileTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> IO ()) -> SpecWith FilePath -fileTestMatrix2 runTest = do - it "via connection" $ runTestCfg2 viaConn viaConn runTest - it "inline (accepting)" $ runTestCfg2 inline inline runTest - it "via connection (inline offered)" $ runTestCfg2 inline viaConn runTest - it "via connection (inline supported)" $ runTestCfg2 viaConn inline runTest - where - inline = inlineCfg 100 - viaConn = inlineCfg 0 - -fileTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath -fileTestMatrix3 runTest = do - it "via connection" $ runTestCfg3 viaConn viaConn viaConn runTest - it "inline" $ runTestCfg3 inline inline inline runTest - it "via connection (inline offered)" $ runTestCfg3 inline viaConn viaConn runTest - it "via connection (inline supported)" $ runTestCfg3 viaConn inline inline runTest - where - inline = inlineCfg 100 - viaConn = inlineCfg 0 - runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () runTestCfg2 aliceCfg bobCfg runTest tmp = withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> @@ -595,20 +571,6 @@ checkActionDeletesFile file action = do fileExistsAfter <- doesFileExist file fileExistsAfter `shouldBe` False -startFileTransferWithDest' :: HasCallStack => TestCC -> TestCC -> String -> String -> Maybe String -> IO () -startFileTransferWithDest' cc1 cc2 fileName fileSize fileDest_ = do - name1 <- userName cc1 - name2 <- userName cc2 - cc1 #> ("/f @" <> name2 <> " ./tests/fixtures/" <> fileName) - cc1 <## "use /fc 1 to cancel sending" - cc2 <# (name1 <> "> sends file " <> fileName <> " (" <> fileSize <> ")") - cc2 <## "use /fr 1 [/ | ] to receive it" - cc2 ##> ("/fr 1" <> maybe "" (" " <>) fileDest_) - cc2 <## ("saving file 1 from " <> name1 <> " to " <> maybe id () fileDest_ fileName) - concurrently_ - (cc2 <## ("started receiving file 1 (" <> fileName <> ") from " <> name1)) - (cc1 <## ("started sending file 1 (" <> fileName <> ") to " <> name2)) - currentChatVRangeInfo :: String currentChatVRangeInfo = "peer chat protocol version range: " <> vRangeStr supportedChatVRange diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 25c3514e4a..ac6fa7b23a 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -13,7 +13,7 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.Map.Strict as M import Simplex.Chat.Archive (archiveFilesFolder) -import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..), versionNumber) +import Simplex.Chat.Controller (versionNumber) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types @@ -194,7 +194,7 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob remoteStoreFileTest :: HasCallStack => FilePath -> IO () remoteStoreFileTest = - testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> + testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) @@ -317,15 +317,13 @@ remoteStoreFileTest = stopMobile mobile desktop where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} hostError cc err = do r <- getTermLine cc r `shouldStartWith` "remote host 1 error" r `shouldContain` err remoteCLIFileTest :: HasCallStack => FilePath -> IO () -remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do - createDirectoryIfMissing True "./tests/tmp/tmp/" +remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" @@ -392,8 +390,6 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile B.readFile (bobFiles "test.jpg") `shouldReturn` src' stopMobile mobile desktop - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} switchRemoteHostTest :: FilePath -> IO () switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do From f7d7f5461fb85e7582e6fb85c9077c5e9aa43cb7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 21 Feb 2024 18:24:24 +0400 Subject: [PATCH 02/64] core: check user record when deleting contact and display name (#3826) * filter out on merge * checl contact, ldn * fix * corrections * fix * refactor * diff * refactor2 * remove contact id from error * Revert "remove contact id from error" This reverts commit f58af3dcacdd3a869c077b57ea3235c8ffb8bc6a. * remove Maybe from error --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat.hs | 8 ++-- src/Simplex/Chat/Store/Direct.hs | 77 +++++++++++++++++------------- src/Simplex/Chat/Store/Groups.hs | 27 ++++++----- src/Simplex/Chat/Store/Profiles.hs | 3 +- src/Simplex/Chat/Store/Shared.hs | 31 ++++++++++++ 5 files changed, 95 insertions(+), 51 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9a0aaff0dc..957b641eb3 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -947,7 +947,7 @@ processChatCommand' vr = \case -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct - withStore' $ \db -> deleteContact db user ct + withStore $ \db -> deleteContact db user ct pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId @@ -988,7 +988,7 @@ processChatCommand' vr = \case Just _ -> pure [] Nothing -> do conns <- withStore' $ \db -> getContactConnections db userId ct - withStore' (\db -> setContactDeleted db user ct) + withStore (\db -> setContactDeleted db user ct) `catchChatError` (toView . CRChatError (Just user)) pure $ map aConnId conns CTLocal -> pure $ chatCmdError (Just user) "not supported" @@ -3056,7 +3056,7 @@ cleanupManager = do cleanupDeletedContacts user = do contacts <- withStore' (`getDeletedContacts` user) forM_ contacts $ \ct -> - withStore' (\db -> deleteContactWithoutGroups db user ct) + withStore (\db -> deleteContactWithoutGroups db user ct) `catchChatError` (toView . CRChatError (Just user)) cleanupMessages = do ts <- liftIO getCurrentTime @@ -4836,7 +4836,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else do contactConns <- withStore' $ \db -> getContactConnections db userId c deleteAgentConnectionsAsync user $ map aConnId contactConns - withStore' $ \db -> deleteContact db user c + withStore $ \db -> deleteContact db user c where brokerTs = metaBrokerTs msgMeta diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 43d58d3ffa..b844317593 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -229,37 +229,45 @@ deleteContactConnectionsAndFiles db userId Contact {contactId} = do (userId, contactId) DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) -deleteContact :: DB.Connection -> User -> Contact -> IO () -deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do - DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) - if isNothing ctMember - then do - deleteContactProfile_ db userId contactId - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - else do - currentTs <- getCurrentTime - DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) - DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - forM_ activeConn $ \Connection {customUserProfileId} -> - forM_ customUserProfileId $ \profileId -> - deleteUnusedIncognitoProfileById_ db user profileId +deleteContact :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +deleteContact db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do + assertNotUser db user ct + liftIO $ do + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) + ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) + if isNothing ctMember + then do + deleteContactProfile_ db userId contactId + -- user's local display name already checked in assertNotUser + DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + else do + currentTs <- getCurrentTime + DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) + DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) + forM_ activeConn $ \Connection {customUserProfileId} -> + forM_ customUserProfileId $ \profileId -> + deleteUnusedIncognitoProfileById_ db user profileId -- should only be used if contact is not member of any groups -deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO () -deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do - DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - deleteContactProfile_ db userId contactId - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - forM_ activeConn $ \Connection {customUserProfileId} -> - forM_ customUserProfileId $ \profileId -> - deleteUnusedIncognitoProfileById_ db user profileId +deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDisplayName, activeConn} = do + assertNotUser db user ct + liftIO $ do + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) + deleteContactProfile_ db userId contactId + -- user's local display name already checked in assertNotUser + DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) + forM_ activeConn $ \Connection {customUserProfileId} -> + forM_ customUserProfileId $ \profileId -> + deleteUnusedIncognitoProfileById_ db user profileId -setContactDeleted :: DB.Connection -> User -> Contact -> IO () -setContactDeleted db User {userId} Contact {contactId} = do - currentTs <- getCurrentTime - DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) +setContactDeleted :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +setContactDeleted db user@User {userId} ct@Contact {contactId} = do + assertNotUser db user ct + liftIO $ do + currentTs <- getCurrentTime + DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) getDeletedContacts :: DB.Connection -> User -> IO [Contact] getDeletedContacts db user@User {userId} = do @@ -320,7 +328,7 @@ updateContactProfile db user@User {userId} c p' ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db userId contactId localDisplayName ldn currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} where Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c @@ -491,8 +499,8 @@ updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, |] (displayName, fullName, image, updatedAt, userId, profileId) -updateContactLDN_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () -updateContactLDN_ db userId contactId displayName newName updatedAt = do +updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () +updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do DB.execute db "UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" @@ -501,7 +509,7 @@ updateContactLDN_ db userId contactId displayName newName updatedAt = do db "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (newName, updatedAt, userId, contactId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) + safeDeleteLDN db user displayName getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact getContactByName db user localDisplayName = do @@ -614,7 +622,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers WHERE user_id = ? AND contact_request_id = ? |] (invId, minV, maxV, ldn, currentTs, userId, cReqId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId) + safeDeleteLDN db user oldLdn where updateProfile currentTs = DB.execute @@ -684,8 +692,9 @@ deleteContactRequest db User {userId} contactRequestId = do SELECT local_display_name FROM contact_requests WHERE user_id = ? AND contact_request_id = ? ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) |] - (userId, userId, contactRequestId) + (userId, userId, contactRequestId, userId) 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 -> Bool -> IO Contact diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 189f95fdf0..d82cc7570f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -225,8 +225,9 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do JOIN user_contact_links uc USING (user_contact_link_id) WHERE uc.user_id = ? AND uc.group_id = ? ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) |] - (userId, userId, groupId) + (userId, userId, groupId, userId) DB.execute db [sql| @@ -586,7 +587,7 @@ deleteGroup :: DB.Connection -> User -> GroupInfo -> IO () deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do deleteGroupProfile_ db userId groupId DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId) - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + safeDeleteLDN db user localDisplayName forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId deleteGroupProfile_ :: DB.Connection -> UserId -> GroupId -> IO () @@ -1044,14 +1045,14 @@ deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId, groupId, m when (memberIncognito m) $ deleteUnusedIncognitoProfileById_ db user $ localProfileId memberProfile cleanupMemberProfileAndName_ :: DB.Connection -> User -> GroupMember -> IO () -cleanupMemberProfileAndName_ db User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} = +cleanupMemberProfileAndName_ db user@User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} = -- check record has no memberContactId (contact_id) - it means contact has been deleted and doesn't use profile & ldn when (isNothing memberContactId) $ do -- check other group member records don't use profile & ldn sameProfileMember :: (Maybe GroupMemberId) <- maybeFirstRow fromOnly $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1" (userId, memberContactProfileId, groupMemberId) when (isNothing sameProfileMember) $ do DB.execute db "DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ?" (userId, memberContactProfileId) - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + safeDeleteLDN db user localDisplayName deleteGroupMemberConnection :: DB.Connection -> User -> GroupMember -> IO () deleteGroupMemberConnection db User {userId} GroupMember {groupMemberId} = @@ -1330,7 +1331,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs @@ -1361,7 +1362,7 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou db "UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (ldn, currentTs, userId, groupId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + safeDeleteLDN db user localDisplayName getGroupInfo :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db vr User {userId, userContactId} groupId = @@ -1464,7 +1465,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.contact_status = ? AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 AND p.display_name = ? AND p.full_name = ? |] @@ -1502,7 +1503,7 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? - AND ct.contact_status = ? AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 AND p.display_name = ? AND p.full_name = ? |] @@ -1615,6 +1616,8 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN let (toCt, fromCt) = toFromContacts to from Contact {contactId = toContactId, localDisplayName = toLDN} = toCt Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt + assertNotUser db user toCt + assertNotUser db user fromCt liftIO $ do currentTs <- getCurrentTime -- next query fixes incorrect unused contacts deletion @@ -2018,7 +2021,7 @@ createMemberContactConn_ pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db User {userId} m p' +updateMemberProfile db user@User {userId} m p' | displayName == newName = do liftIO $ updateMemberContactProfileReset_ db userId profileId p' pure m {memberProfile = profile} @@ -2030,7 +2033,7 @@ updateMemberProfile db User {userId} m p' db "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" (ldn, currentTs, userId, groupMemberId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + safeDeleteLDN db user localDisplayName pure $ Right m {localDisplayName = ldn, memberProfile = profile} where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m @@ -2038,7 +2041,7 @@ updateMemberProfile db User {userId} m p' profile = toLocalProfile profileId p' localAlias updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db User {userId} m ct@Contact {contactId} p' +updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' | displayName == newName = do liftIO $ updateMemberContactProfile_ db userId profileId p' pure (m {memberProfile = profile}, ct {profile} :: Contact) @@ -2046,7 +2049,7 @@ updateContactMemberProfile db User {userId} m ct@Contact {contactId} p' ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime updateMemberContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db userId contactId localDisplayName ldn currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index bffdb2a6d3..eceb19ba34 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -267,7 +267,7 @@ updateUserProfile db user p' "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db userId userContactId localDisplayName newName currentTs + updateContactLDN_ db user userContactId localDisplayName newName currentTs pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs @@ -388,6 +388,7 @@ deleteUserAddress db user@User {userId} = do JOIN user_contact_links uc USING (user_contact_link_id) WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = :user_id) |] [":user_id" := userId] DB.executeNamed diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e4d47b32cc..e97ff9fe58 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -110,6 +110,7 @@ data StoreError | SERemoteHostDuplicateCA | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} | SERemoteCtrlDuplicateCA + | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} deriving (Show, Exception) $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) @@ -401,3 +402,33 @@ createWithRandomBytes' size gVar create = tryCreate 3 encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString encodedRandomBytes gVar n = atomically $ B64.encode <$> C.randomBytes n gVar + +assertNotUser :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () +assertNotUser db User {userId} Contact {contactId, localDisplayName} = do + r :: (Maybe Int64) <- + -- This query checks that the foreign keys in the users table + -- are not referencing the contact about to be deleted. + -- With the current schema it would cause cascade delete of user, + -- with mofified schema (in v5.6.0-beta.0) it would cause foreign key violation error. + liftIO . maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT 1 FROM users + WHERE (user_id = ? AND local_display_name = ?) + OR contact_id = ? + LIMIT 1 + |] + (userId, localDisplayName, contactId) + when (isJust r) $ throwError $ SEProhibitedDeleteUser userId contactId + +safeDeleteLDN :: DB.Connection -> User -> ContactName -> IO () +safeDeleteLDN db User {userId} localDisplayName = do + DB.execute + db + [sql| + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + |] + (userId, localDisplayName, userId) From b629c22ee059b4fcb804b56b18a4a280fdda161e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 21 Feb 2024 14:26:46 +0000 Subject: [PATCH 03/64] 5.5.5.0, update simplexmq to 5.5.2.1 (fix performance degradation) --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index 5cca86b68f..23a3d61233 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78 + tag: 32c94df040b7921584a4685a814818daec3bf209 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 1d44ae8a0c..881e9d038f 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.5.3.0 +version: 5.5.5.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 67e6d21977..cd456a269c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e64b6cba4b7e4107f78ae596ab2a6a28ef24ff78" = "0fxgklq65bh2f4kx36vjicdxqmi88m91xs601hm81v5pn6kk0ppd"; + "https://github.com/simplex-chat/simplexmq.git"."32c94df040b7921584a4685a814818daec3bf209" = "0bfyzra8x67zwqr7g8hkglxpy503qwn0xni0sjnbjmvh7wlh6pyz"; "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 8035892414..6db1d3b138 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.5.3.0 +version: 5.5.5.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From e6e27db2433a334f93fc1c91fd205c013e15de48 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 21 Feb 2024 18:32:53 +0000 Subject: [PATCH 04/64] 5.5.5: ios 200, android 185, desktop 31 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 64 +++++++++++----------- apps/multiplatform/gradle.properties | 8 +-- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5aa796955d..63250b09dc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -29,11 +29,6 @@ 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; - 5C29C3AF2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */; }; - 5C29C3B02B783F82003DF84C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AB2B783F82003DF84C /* libgmpxx.a */; }; - 5C29C3B12B783F82003DF84C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AC2B783F82003DF84C /* libffi.a */; }; - 5C29C3B22B783F82003DF84C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AD2B783F82003DF84C /* libgmp.a */; }; - 5C29C3B32B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; @@ -95,6 +90,11 @@ 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; }; 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; + 5CB1CE922B86660100963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8D2B86660100963938 /* libgmp.a */; }; + 5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8E2B86660100963938 /* libgmpxx.a */; }; + 5CB1CE942B86660100963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8F2B86660100963938 /* libffi.a */; }; + 5CB1CE952B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */; }; + 5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */; }; 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; }; 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; @@ -278,11 +278,6 @@ 5C245F3C2B501E98001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; 5C245F3D2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = "tr.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C245F3E2B501F13001CC39F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; - 5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a"; sourceTree = ""; }; - 5C29C3AB2B783F82003DF84C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C29C3AC2B783F82003DF84C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C29C3AD2B783F82003DF84C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a"; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -377,6 +372,11 @@ 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = ""; }; 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = ""; }; 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; + 5CB1CE8D2B86660100963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CB1CE8E2B86660100963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CB1CE8F2B86660100963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a"; sourceTree = ""; }; + 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a"; sourceTree = ""; }; 5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = ""; }; 5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = ""; }; @@ -514,13 +514,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C29C3B02B783F82003DF84C /* libgmpxx.a in Frameworks */, - 5C29C3B32B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a in Frameworks */, + 5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */, + 5CB1CE922B86660100963938 /* libgmp.a in Frameworks */, + 5CB1CE952B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a in Frameworks */, + 5CB1CE942B86660100963938 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C29C3B12B783F82003DF84C /* libffi.a in Frameworks */, - 5C29C3B22B783F82003DF84C /* libgmp.a in Frameworks */, - 5C29C3AF2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -582,11 +582,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C29C3AC2B783F82003DF84C /* libffi.a */, - 5C29C3AD2B783F82003DF84C /* libgmp.a */, - 5C29C3AB2B783F82003DF84C /* libgmpxx.a */, - 5C29C3AA2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk-ghc9.6.3.a */, - 5C29C3AE2B783F82003DF84C /* libHSsimplex-chat-5.5.3.0-AUrnxTuqxo1yzY63w39Bk.a */, + 5CB1CE8F2B86660100963938 /* libffi.a */, + 5CB1CE8D2B86660100963938 /* libgmp.a */, + 5CB1CE8E2B86660100963938 /* libgmpxx.a */, + 5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */, + 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */, ); path = Libraries; sourceTree = ""; @@ -1509,7 +1509,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 199; + CURRENT_PROJECT_VERSION = 200; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1531,7 +1531,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.4; + MARKETING_VERSION = 5.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1552,7 +1552,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 199; + CURRENT_PROJECT_VERSION = 200; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1574,7 +1574,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.4; + MARKETING_VERSION = 5.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1633,7 +1633,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 199; + CURRENT_PROJECT_VERSION = 200; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1646,7 +1646,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.4; + MARKETING_VERSION = 5.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1665,7 +1665,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 199; + CURRENT_PROJECT_VERSION = 200; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1678,7 +1678,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.4; + MARKETING_VERSION = 5.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1697,7 +1697,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 199; + CURRENT_PROJECT_VERSION = 200; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1721,7 +1721,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.4; + MARKETING_VERSION = 5.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1743,7 +1743,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 199; + CURRENT_PROJECT_VERSION = 200; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1767,7 +1767,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.4; + MARKETING_VERSION = 5.5.5; 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 054a614ed5..cbf2e467ed 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.5.4 -android.version_code=183 +android.version_name=5.5.5 +android.version_code=185 -desktop.version_name=5.5.4 -desktop.version_code=30 +desktop.version_name=5.5.5 +desktop.version_code=31 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From d54b453b491140ecbdc340c5315415dd8ac03ff3 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 21 Feb 2024 23:54:03 +0200 Subject: [PATCH 05/64] controller: fix standalone using relative paths (#3831) --- src/Simplex/Chat.hs | 5 +++-- tests/ChatTests/Files.hs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3b0fe70753..c4431ed924 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2053,8 +2053,9 @@ processChatCommand' vr = \case StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ - APIUploadStandaloneFile userId file -> withUserId userId $ \user -> do - fileSize <- liftIO $ CF.getFileContentsSize file + APIUploadStandaloneFile userId file@CryptoFile {filePath} -> withUserId userId $ \user -> do + fsFilePath <- toFSFilePath filePath + fileSize <- liftIO $ CF.getFileContentsSize file {filePath = fsFilePath} (_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing pure CRSndStandaloneFileCreated {user, fileTransferMeta} APIDownloadStandaloneFile userId uri file -> withUserId userId $ \user -> do diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 7a3536b1ee..755e2eb370 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -81,6 +81,7 @@ chatFileTests = do describe "file transfer over XFTP without chat items" $ do it "send and receive small standalone file" testXFTPStandaloneSmall it "send and receive large standalone file" testXFTPStandaloneLarge + it "send and receive large standalone file using relative paths" testXFTPStandaloneRelativePaths xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests it "removes received temporary files" testXFTPStandaloneCancelRcv @@ -1633,6 +1634,37 @@ testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst <## "error receiving file 1 (should.not.extist)" dst <## "INTERNAL {internalErr = \"XFTP {xftpErr = AUTH}\"}" +testXFTPStandaloneRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + logNote "sending" + src #$> ("/_files_folder ./tests/tmp/src_files", id, "ok") + src #$> ("/_temp_folder ./tests/tmp/src_xftp_temp", id, "ok") + + xftpCLI ["rand", "./tests/tmp/src_files/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/src_files/testfile.in"] + + src ##> "/_upload 1 testfile.in" + src <## "started standalone uploading file 1 (testfile.in)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (testfile.in) uploaded, preparing redirect file 2" + src <## "file 1 (testfile.in) upload complete. download with:" + uri <- getTermLine src + _uri2 <- getTermLine src + _uri3 <- getTermLine src + _uri4 <- getTermLine src + + logNote "receiving" + dst #$> ("/_files_folder ./tests/tmp/dst_files", id, "ok") + dst #$> ("/_temp_folder ./tests/tmp/dst_xftp_temp", id, "ok") + dst ##> ("/_download 1 " <> uri <> " testfile.out") + dst <## "started standalone receiving file 1 (testfile.out)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (testfile.out)" + srcBody <- B.readFile "./tests/tmp/src_files/testfile.in" + B.readFile "./tests/tmp/dst_files/testfile.out" `shouldReturn` srcBody + testXFTPStandaloneCancelRcv :: HasCallStack => FilePath -> IO () testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do From 92c89632d4d470dddf7fb95c6a476146c1a36448 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 22 Feb 2024 01:54:52 +0400 Subject: [PATCH 06/64] core, ui: don't mark profile updated chat item as unread (#3830) * core, ui: don't mark profile updated chat item as unread * android --- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- src/Simplex/Chat/Messages/CIContent.hs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 198a777f8b..997f6e3537 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2268,7 +2268,7 @@ public struct ChatItem: Identifiable, Decodable { case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent): switch rcvDirectEvent { case .contactDeleted: return false - case .profileUpdated: return true + case .profileUpdated: return false } case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): switch rcvGroupEvent { 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 a87e7c45bb..21bfb1daa3 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 @@ -1822,7 +1822,7 @@ data class ChatItem ( is CIContent.SndGroupInvitation -> false is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) { is RcvDirectEvent.ContactDeleted -> false - is RcvDirectEvent.ProfileUpdated -> true + is RcvDirectEvent.ProfileUpdated -> false } is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 188a5293c9..a79eb0d952 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -172,7 +172,7 @@ ciRequiresAttention content = case msgDirection @d of CIRcvGroupInvitation {} -> True CIRcvDirectEvent rde -> case rde of RDEContactDeleted -> False - RDEProfileUpdated {} -> True + RDEProfileUpdated {} -> False CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False From 4e9703f0ff1cdc03914aa7852b5230426a4459e7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 22 Feb 2024 12:19:42 +0000 Subject: [PATCH 07/64] 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 d582727457..7d9308a64c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -90,11 +90,11 @@ 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; }; 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; - 5CB1CE882B8259EB00963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE832B8259EB00963938 /* libgmpxx.a */; }; - 5CB1CE892B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */; }; - 5CB1CE8A2B8259EB00963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE852B8259EB00963938 /* libffi.a */; }; - 5CB1CE8B2B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */; }; - 5CB1CE8C2B8259EB00963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE872B8259EB00963938 /* libgmp.a */; }; + 5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE972B8771DB00963938 /* libffi.a */; }; + 5CB1CE9D2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */; }; + 5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE992B8771DB00963938 /* libgmpxx.a */; }; + 5CB1CE9F2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */; }; + 5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE9B2B8771DB00963938 /* libgmp.a */; }; 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; }; 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; @@ -372,11 +372,11 @@ 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = ""; }; 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = ""; }; 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; - 5CB1CE832B8259EB00963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a"; sourceTree = ""; }; - 5CB1CE852B8259EB00963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a"; sourceTree = ""; }; - 5CB1CE872B8259EB00963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CB1CE972B8771DB00963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a"; sourceTree = ""; }; + 5CB1CE992B8771DB00963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a"; sourceTree = ""; }; + 5CB1CE9B2B8771DB00963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = ""; }; 5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = ""; }; @@ -514,13 +514,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CB1CE882B8259EB00963938 /* libgmpxx.a in Frameworks */, - 5CB1CE8C2B8259EB00963938 /* libgmp.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5CB1CEA02B8771DB00963938 /* libgmp.a in Frameworks */, + 5CB1CE9E2B8771DB00963938 /* libgmpxx.a in Frameworks */, + 5CB1CE9C2B8771DB00963938 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CB1CE8A2B8259EB00963938 /* libffi.a in Frameworks */, - 5CB1CE892B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a in Frameworks */, - 5CB1CE8B2B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a in Frameworks */, + 5CB1CE9D2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a in Frameworks */, + 5CB1CE9F2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -582,11 +582,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CB1CE852B8259EB00963938 /* libffi.a */, - 5CB1CE872B8259EB00963938 /* libgmp.a */, - 5CB1CE832B8259EB00963938 /* libgmpxx.a */, - 5CB1CE862B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE-ghc9.6.3.a */, - 5CB1CE842B8259EB00963938 /* libHSsimplex-chat-5.5.3.0-1R6yZC1upSP6aXGrPWvhZE.a */, + 5CB1CE972B8771DB00963938 /* libffi.a */, + 5CB1CE9B2B8771DB00963938 /* libgmp.a */, + 5CB1CE992B8771DB00963938 /* libgmpxx.a */, + 5CB1CE9A2B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI-ghc9.6.3.a */, + 5CB1CE982B8771DB00963938 /* libHSsimplex-chat-5.5.5.0-4Xh8aGOq2uNGBj7gMJTaKI.a */, ); path = Libraries; sourceTree = ""; From 2d643e8d29e573851833f10d593df12edcad8f65 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 24 Feb 2024 09:27:55 +0000 Subject: [PATCH 08/64] rfc: amend PQ double ratchet RFC --- docs/rfcs/2023-09-30-pq-double-ratchet.md | 28 ++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/2023-09-30-pq-double-ratchet.md b/docs/rfcs/2023-09-30-pq-double-ratchet.md index 255051320d..95e7aa3d1d 100644 --- a/docs/rfcs/2023-09-30-pq-double-ratchet.md +++ b/docs/rfcs/2023-09-30-pq-double-ratchet.md @@ -82,7 +82,7 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, state.PQRs = GENERATE_PQKEM() state.PQRr = bob_pq_kem_encapsulation_key state.PQRss = random // shared secret for KEM - state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret + state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret // above added for KEM // below augments DH key agreement with PQ shared secret state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss) @@ -103,7 +103,7 @@ def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob state.PQRs = bob_pq_kem_key_pair state.PQRr = None state.PQRss = None - state.PQRenc_ss = None + state.PQRct = None // above added for KEM state.RK = SK state.CKs = None @@ -132,10 +132,10 @@ def RatchetEncryptPQ2HE(state, plaintext, AD): // encapsulation key from PQRs and encapsulated shared secret is added to header header = HEADER_PQ2( dh = state.DHRs.public, + kem = state.PQRs.public, // added for KEM #2 + ct = state.PQRct // added for KEM #1 pn = state.PN, n = state.Ns, - encaps = state.PQRs.encaps, // added for KEM #1 - enc_ss = state.PQRenc_ss // added for KEM #2 ) enc_header = HENCRYPT(state.HKs, header) state.Ns += 1 @@ -162,6 +162,16 @@ def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD): state.Nr += 1 return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header)) +// DecryptHeader is the same as in double ratchet specification +def DecryptHeader(state, enc_header): + header = HDECRYPT(state.HKr, enc_header) + if header != None: + return header, False + header = HDECRYPT(state.NHKr, enc_header) + if header != None: + return header, True + raise Error() + def DHRatchetPQ2HE(state, header): state.PN = state.Ns state.Ns = 0 @@ -170,16 +180,16 @@ def DHRatchetPQ2HE(state, header): state.HKr = state.NHKr state.DHRr = header.dh // save new encapsulation key from header - state.PQRr = header.encaps + state.PQRr = header.kem // decapsulate shared secret from header - KEM #2 - ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss) + ss = PQKEM-DEC(state.PQRs.private, header.ct) // use decapsulated shared secret with receiving ratchet state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss) state.DHRs = GENERATE_DH() // below is added for KEM state.PQRs = GENERATE_PQKEM() // generate new PQ key pair state.PQRss = random // shared secret for KEM - state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret KEM #1 + state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1 // above is added for KEM // use new shared secret with sending ratchet state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss) @@ -201,7 +211,7 @@ The main downside is the absense of performance-efficient implementation for aar ## Implementation considerations for SimpleX Chat -As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality) or sending additional messages. +As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality), or sending additional messages, or compressing the current JSON encoding, e.g. with zstd algorithm. That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth – to the best of our knowledge, Signal messages are not padded to a fixed size. @@ -209,6 +219,8 @@ Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. To mitigate it, the hadnshake protocol can be modified to postpone sending the user profile until the second message from Alice (HELLO message in SMP protocol). +Another consideration is pairwise ratchets in groups. Key generation in sntrup761 is quite slow - on slow devices it can probably be as slow as 10 keys per second, so using this primitive in groups larger than 10 members would result in slow performance. An option could be not to use ratchets in groups at all, but that would result in the lack of protection in small groups that simply combine multiple devices of 1-3 people. So a better option would be to support dynamically adding and removing sntrup761 keys for pairwise ratchets in groups, which means that when sending each message a boolean flag needs to be passed whether to use PQ KEM or not. + ## Summary If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided KEM is secure. From 395654098c6c4ca14ce6e97aea2add043f41060e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 24 Feb 2024 13:37:09 +0000 Subject: [PATCH 09/64] core: do not mark store as changed after passphrase test (#3833) * core: do not mark store as changed after passphrase test * fix --- 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 9b0741613d..9d5c23d7a6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -599,7 +599,7 @@ processChatCommand' vr = \case pure $ CRArchiveImported fileErrs APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg - TestStorageEncryption key -> withStoreChanged $ sqlCipherTestKey key + TestStorageEncryption key -> sqlCipherTestKey key >> ok_ ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) SlowSQLQueries -> do From 5807a0a2fa4d3d0dc9e00bfdde2e100f2aab9fbe Mon Sep 17 00:00:00 2001 From: kimg45 <138676274+kimg45@users.noreply.github.com> Date: Sat, 24 Feb 2024 07:40:28 -0600 Subject: [PATCH 10/64] website: fix links (#3828) * Fix broken link on website * Fix Sybil attack link * fix MITM link * fix supernovas.space link * fix supernovas.space links * remove broken github link * remove dead github link * fix link to readme --- blog/20210512-simplex-chat-terminal-ui.md | 2 +- blog/20220928-simplex-chat-v4-encrypted-database.md | 2 +- blog/20221206-simplex-chat-v4.3-voice-messages.md | 2 +- blog/README.md | 2 +- blog/lang/fr-fr/README_fr.md | 2 +- docs/JOIN_TEAM.md | 2 +- website/langs/fr.json | 2 +- website/langs/ja.json | 4 ++-- website/src/_includes/blog_previews/20221206.html | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/blog/20210512-simplex-chat-terminal-ui.md b/blog/20210512-simplex-chat-terminal-ui.md index a6a5aea5a4..1357f6c9b3 100644 --- a/blog/20210512-simplex-chat-terminal-ui.md +++ b/blog/20210512-simplex-chat-terminal-ui.md @@ -10,7 +10,7 @@ permalink: "/blog/20210512-simplex-chat-terminal-ui.html" **Published:** May 12, 2021 -For the last six months [me](https://github.com/epoberezkin) and my son [Efim](https://github.com/efim-poberezkin) have been working to bring you a working prototype of SimpleX Chat. We're excited to announce SimpleX Chat terminal client is now available [here](https://github.com/simplex-chat/simplex-chat) on Linux, Windows and Mac (you can either build from source or download the binary for Linux, Windows or Mac from the latest release). +For the last six months [me](https://github.com/epoberezkin) and my son Efim have been working to bring you a working prototype of SimpleX Chat. We're excited to announce SimpleX Chat terminal client is now available [here](https://github.com/simplex-chat/simplex-chat) on Linux, Windows and Mac (you can either build from source or download the binary for Linux, Windows or Mac from the latest release). We’ve been using the terminal client between us and a few other people for a couple of months now, eating our own “dog food”, and have developed up to version 0.3.1, with most of the messaging protocol features we originally planned diff --git a/blog/20220928-simplex-chat-v4-encrypted-database.md b/blog/20220928-simplex-chat-v4-encrypted-database.md index bdf7e9790c..6f8064454f 100644 --- a/blog/20220928-simplex-chat-v4-encrypted-database.md +++ b/blog/20220928-simplex-chat-v4-encrypted-database.md @@ -78,7 +78,7 @@ You can run SimpleX Chat CLI as a local WebSockets server on any port, we use 52 simplex-chat -p 5225 ``` -Then you can create a JavaScript or TypeScript application that would connect to it and control it via a simple WebSocket API. TypeScript SDK defines all necessary types and convenience functions to use in your applications. See this [sample bot](https://github.com/simplex-chat/simplex-chat/blob/stable/packages/simplex-chat-client/typescript/examples/squaring-bot.js) and [README page](https://github.com/simplex-chat/simplex-chat/tree/ep/blog-v4/packages/simplex-chat-client/typescript). +Then you can create a JavaScript or TypeScript application that would connect to it and control it via a simple WebSocket API. TypeScript SDK defines all necessary types and convenience functions to use in your applications. See this [sample bot](https://github.com/simplex-chat/simplex-chat/blob/stable/packages/simplex-chat-client/typescript/examples/squaring-bot.js) and README page. SimpleX Chat API allows you to: diff --git a/blog/20221206-simplex-chat-v4.3-voice-messages.md b/blog/20221206-simplex-chat-v4.3-voice-messages.md index 32bbe058e5..1ca25ce5d0 100644 --- a/blog/20221206-simplex-chat-v4.3-voice-messages.md +++ b/blog/20221206-simplex-chat-v4.3-voice-messages.md @@ -18,7 +18,7 @@ Since we published [the security assessment of SimpleX Chat](https://simplex.cha - Privacy Guides added SimpleX Chat to [the recommended private and secure messengers](https://www.privacyguides.org/real-time-communication/#simplex-chat). - Mike Kuketz – a well-known security expert – published [the review of SimpleX Chat](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) and added it to [the messenger matrix](https://www.messenger-matrix.de). -- Supernova published [the review](https://supernova.tilde.team/detailed_reviews.html#simplex) and increased [SimpleX Chat recommendation ratings](https://supernova.tilde.team/messengers.html). +- Supernova published [the review](https://supernovas.space/detailed_reviews.html#simplex) and increased [SimpleX Chat recommendation ratings](https://supernovas.space/messengers.html). ## What's new in v4.3 diff --git a/blog/README.md b/blog/README.md index 3afa61eeff..8066f0592a 100644 --- a/blog/README.md +++ b/blog/README.md @@ -146,7 +146,7 @@ November reviews: - [Privacy Guides](https://www.privacyguides.org/real-time-communication/#simplex-chat) recommendations. - [Review by Mike Kuketz](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/). - [The messenger matrix](https://www.messenger-matrix.de). -- [Supernova review](https://supernova.tilde.team/detailed_reviews.html#simplex) and [messenger ratings](https://supernova.tilde.team/messengers.html). +- [Supernova review](https://supernovas.space/detailed_reviews.html#simplex) and [messenger ratings](https://supernovas.space/messengers.html). --- diff --git a/blog/lang/fr-fr/README_fr.md b/blog/lang/fr-fr/README_fr.md index d3deefe3f0..bebc42d7bc 100644 --- a/blog/lang/fr-fr/README_fr.md +++ b/blog/lang/fr-fr/README_fr.md @@ -26,7 +26,7 @@ Critiques de novembre : - Recommandations de [Privacy Guides](https://www.privacyguides.org/real-time-communication/#simplex-chat). - [Revue par Mike Kuketz](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/). - [La matrice des messageries](https://www.messenger-matrix.de). -- [Revue de Supernova](https://supernova.tilde.team/detailed_reviews.html#simplex) et [évaluations des messageries](https://supernova.tilde.team/messengers.html). +- [Revue de Supernova](https://supernovas.space/detailed_reviews.html#simplex) et [évaluations des messageries](https://supernovas.space/messengers.html). Sortie de la v4.3 : diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index cf33df1ee7..3379f9ae04 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -70,7 +70,7 @@ Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin J ## How to join the team -1. [Install the app](../README.md#install-the-app), try using it with the friends and [join some user groups](https://github.com/simplex-chat/simplex-chat#join-user-groups) – you will discover a lot of things that need improvements. +1. [Install the app](https://github.com/simplex-chat/simplex-chat#install-the-app), try using it with the friends and [join some user groups](https://github.com/simplex-chat/simplex-chat#join-user-groups) – you will discover a lot of things that need improvements. 2. Also look through [GitHub issues](https://github.com/simplex-chat/simplex-chat/issues) submitted by the users to see what would you want to contribute as a test. diff --git a/website/langs/fr.json b/website/langs/fr.json index 304630a091..2cdf1b9ae9 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -105,7 +105,7 @@ "simplex-network-overlay-card-1-li-3": "Le P2P ne résout pas l'attaque MITM et la plupart des implémentations existantes n'utilisent pas de messages hors bande pour l'échange de clé initial. SimpleX utilise des messages hors bande ou, dans certains cas, des connexions sécurisées et approuvées préexistantes pour l'échange de clé initial .", "simplex-network-overlay-card-1-li-4": "Les réseaux P2P peuvent être bloquées par certains fournisseurs Internet (comme BitTorrent). SimpleX est indépendant du transport - il peut fonctionner sur des protocoles Web standard, par exemple WebSockets.", "simplex-network-overlay-card-1-li-5": "Tous les réseaux P2P connus sont susceptibles d'être vulnérables à une attaque Sybil, car chaque nœud peut être découvert et le réseau fonctionne comme un tout. Les mesures connues pour réduire la probabilité d'une attaque Sybil nécessitent soit un composant centralisé, soit des preuves de travail coûteuses. Le réseau SimpleX ne permet pas de découvrir les serveurs, il est fragmenté et fonctionne comme de multiples sous-réseaux isolées, ce qui rend impossible les attaques à l'échelle du réseau.", - "simplex-network-overlay-card-1-li-6": "Les réseaux P2P sont susceptibles d'être vulnérables aux attaques DRDoS, lorsque les clients peuvent rediffuser et amplifier le trafic, entraînant un déni de service à l'échelle du réseau. Les clients SimpleX relaient uniquement le trafic à partir d'une connexion connue et ne peuvent pas être utilisés par un attaquant pour amplifier le trafic sur l'ensemble du réseau.", + "simplex-network-overlay-card-1-li-6": "Les réseaux P2P sont susceptibles d'être vulnérables aux attaques DRDoS, lorsque les clients peuvent rediffuser et amplifier le trafic, entraînant un déni de service à l'échelle du réseau. Les clients SimpleX relaient uniquement le trafic à partir d'une connexion connue et ne peuvent pas être utilisés par un attaquant pour amplifier le trafic sur l'ensemble du réseau.", "privacy-matters-overlay-card-1-p-1": "De nombreuses grandes entreprises utilisent les informations sur les personnes avec lesquelles vous êtes connecté pour estimer vos revenus, vous vendre des produits dont vous n'avez pas vraiment besoin et déterminer les prix.", "privacy-matters-overlay-card-1-p-2": "Les vendeurs en ligne savent que les personnes à faible revenu sont plus susceptibles d'effectuer des achats urgents. Ils peuvent donc pratiquer des prix plus élevés ou supprimer des remises.", "privacy-matters-overlay-card-1-p-3": "Certaines sociétés financières et d'assurance utilisent des graphiques sociaux pour déterminer les taux d'intérêt et les primes. Cela fait souvent payer plus les personnes à faible revenu - c'est connu sous le nom de 'prime à la pauvreté'.", diff --git a/website/langs/ja.json b/website/langs/ja.json index 967d6c27f3..2b6d657608 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -93,7 +93,7 @@ "docs-dropdown-1": "SimpleXプラットフォーム", "hero-overlay-card-1-p-5": "クライアント デバイスのみがユーザー プロファイル、連絡先、およびグループを保存します。 メッセージは 2 レイヤーのエンドツーエンド暗号化を使用して送信されます。", "simplex-chat-for-the-terminal": "ターミナル用 SimpleX チャット", - "simplex-network-overlay-card-1-li-3": "P2P は MITM 攻撃 問題を解決せず、既存の実装のほとんどは最初の鍵交換に帯域外メッセージを使用していません 。 SimpleX は、最初のキー交換に帯域外メッセージを使用するか、場合によっては既存の安全で信頼できる接続を使用します。", + "simplex-network-overlay-card-1-li-3": "P2P は MITM 攻撃 問題を解決せず、既存の実装のほとんどは最初の鍵交換に帯域外メッセージを使用していません 。 SimpleX は、最初のキー交換に帯域外メッセージを使用するか、場合によっては既存の安全で信頼できる接続を使用します。", "the-instructions--source-code": "ソース コードからダウンロードまたはコンパイルする方法を説明します。", "simplex-network-section-desc": "Simplex Chat は、P2P とフェデレーション ネットワークの利点を組み合わせて最高のプライバシーを提供します。", "privacy-matters-section-subheader": "メタデータのプライバシーを保護する — 話す相手 — 以下のことからあなたを守ります:", @@ -150,7 +150,7 @@ "privacy-matters-2-overlay-1-title": "プライバシーはあなたに力を与えます", "simplex-unique-overlay-card-2-p-2": "オプションのユーザー アドレスを使用しても、スパムの連絡先リクエストの送信に使用される可能性がありますが、接続を失うことなく変更または完全に削除できます。", "simplex-unique-4-overlay-1-title": "完全に分散化されています — ユーザーは SimpleX ネットワークを所有します", - "simplex-network-overlay-card-1-li-5": "すべての既知の P2P ネットワークは、各ノードが検出可能であり、ネットワーク全体が動作するため、Sybil 攻撃に対して脆弱である可能性があります。 この問題を軽減する既知の対策には、一元化されたコンポーネントか、高価な作業証明が必要です。 SimpleX ネットワークにはサーバーの検出機能がなく、断片化されており、複数の分離されたサブネットワークとして動作するため、ネットワーク全体への攻撃は不可能です。", + "simplex-network-overlay-card-1-li-5": "すべての既知の P2P ネットワークは、各ノードが検出可能であり、ネットワーク全体が動作するため、Sybil 攻撃に対して脆弱である可能性があります。 この問題を軽減する既知の対策には、一元化されたコンポーネントか、高価な作業証明が必要です。 SimpleX ネットワークにはサーバーの検出機能がなく、断片化されており、複数の分離されたサブネットワークとして動作するため、ネットワーク全体への攻撃は不可能です。", "simplex-private-2-title": "追加レイヤーの
サーバー暗号化", "hero-overlay-card-1-p-4": "この設計により、ユーザーの情報の漏洩が防止されます' アプリケーションレベルのメタデータ。 プライバシーをさらに向上させ、IP アドレスを保護するために、Tor 経由でメッセージング サーバーに接続できます。", "f-droid-org-repo": "F-Droid.org リポジトリ", diff --git a/website/src/_includes/blog_previews/20221206.html b/website/src/_includes/blog_previews/20221206.html index 0b54f5c32a..55530cd301 100644 --- a/website/src/_includes/blog_previews/20221206.html +++ b/website/src/_includes/blog_previews/20221206.html @@ -3,7 +3,7 @@

Privacy Guides recommendations.

Review by Mike Kuketz.

The messenger matrix.

-

Supernova review and messenger ratings.

+

Supernova review and messenger ratings.

v4.3 is released:

From b7709c59d34cca986f7bd286eea331bc73acc247 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 24 Feb 2024 13:42:11 +0000 Subject: [PATCH 11/64] docs: include update instructions (#3825) --- docs/SERVER.md | 61 ++++++++++++++++++++++++++++++++++++++++++-- docs/XFTP-SERVER.md | 62 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 00e3e0f6ee..61d2a981d2 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -24,7 +24,7 @@ _Please note_: when you change the servers in the app configuration, it only aff - Semi-automatic deployment: - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode) + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) Manual installation requires some preliminary actions: @@ -33,7 +33,7 @@ Manual installation requires some preliminary actions: - Using offical binaries: ```sh - curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server ``` - Compiling from source: @@ -417,6 +417,63 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) +# Updating your SMP server + +To update your smp-server to latest version, choose your installation method and follow the steps: + + - Manual deployment + 1. Stop the server: + ```sh + sudo systemctl stop smp-server + ``` + 2. Update the binary: + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server + ``` + 3. Start the server: + ```sh + sudo systemctl start smp-server + ``` + + - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the followin command: + ```sh + sudo simplex-servers-update + ``` + 2. Done! + + - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + 1. Stop and remove the container: + ```sh + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="{{.ID}}")) + ``` + 2. Pull latest image: + ```sh + docker pull simplexchat/smp-server:latest + ``` + 3. Start new container: + ```sh + docker run -d \ + -p 5223:5223 \ + -v $HOME/simplex/smp/config:/etc/opt/simplex:z \ + -v $HOME/simplex/smp/logs:/var/opt/simplex:z \ + simplexchat/smp-server:latest + ``` + + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + 1. Pull latest images: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex pull + ``` + 2. Restart the containers: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans + ``` + 3. Remove obsolete images: + ```sh + docker image prune + ``` + ### Configuring the app to use the server To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 2977ff15da..8e2e03c19d 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -24,6 +24,7 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba - Semi-automatic deployment: - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) Manual installation requires some preliminary actions: @@ -32,7 +33,7 @@ Manual installation requires some preliminary actions: - Using offical binaries: ```sh - curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server ``` - Compiling from source: @@ -418,6 +419,65 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) + +# Updating your XFTP server + +To update your XFTP server to latest version, choose your installation method and follow the steps: + + - Manual deployment + 1. Stop the server: + ```sh + sudo systemctl stop xftp-server + ``` + 2. Update the binary: + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server + ``` + 3. Start the server: + ```sh + sudo systemctl start xftp-server + ``` + + - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the followin command: + ```sh + sudo simplex-servers-update + ``` + 2. Done! + + - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + 1. Stop and remove the container: + ```sh + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/xftp-server --format="{{.ID}}")) + ``` + 2. Pull latest image: + ```sh + docker pull simplexchat/xftp-server:latest + ``` + 3. Start new container: + ```sh + docker run -d \ + -p 443:443 \ + -v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \ + -v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \ + -v $HOME/simplex/xftp/files:/srv/xftp:z \ + simplexchat/xftp-server:latest + ``` + + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + 1. Pull latest images: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex pull + ``` + 2. Restart the containers: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans + ``` + 3. Remove obsolete images: + ```sh + docker image prune + ``` + ### Configuring the app to use the server Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server). From e37654772f868375d404b667257dea6b2c1cc6e9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 24 Feb 2024 15:00:16 +0000 Subject: [PATCH 12/64] core: api to save/get app settings to migrate them as part of the database (#3824) * rfc: migrate app settings as part of export/import/migration * export/import app settings * test, fix * chat: store app settings in db (#3834) * chat: store app settings in db * add combining with app-defaults * commit schema * test with tweaked settings * remove unused error --------- Co-authored-by: Evgeny Poberezkin * remove app settings from export/import * test, more settings --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- docs/rfcs/2024-02-19-settings.md | 60 ++++++ simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 5 + src/Simplex/Chat/AppSettings.hs | 190 ++++++++++++++++++ src/Simplex/Chat/Controller.hs | 4 + .../Chat/Migrations/M20240222_app_settings.hs | 20 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Store/AppSettings.hs | 22 ++ src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Direct.hs | 27 ++- 11 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 docs/rfcs/2024-02-19-settings.md create mode 100644 src/Simplex/Chat/AppSettings.hs create mode 100644 src/Simplex/Chat/Migrations/M20240222_app_settings.hs create mode 100644 src/Simplex/Chat/Store/AppSettings.hs diff --git a/docs/rfcs/2024-02-19-settings.md b/docs/rfcs/2024-02-19-settings.md new file mode 100644 index 0000000000..002e381ce2 --- /dev/null +++ b/docs/rfcs/2024-02-19-settings.md @@ -0,0 +1,60 @@ +# Migrating app settings to another device + +## Problem + +This is related to simplified database migration UX in the [previous RFC](./2024-02-12-database-migration.md). + +Currently, when database is imported after the onboarding is complete, users can configure the app prior to the import. + +Some of the settings are particularly important for privacy and security: +- SOCKS proxy settings +- Automatic image etc. downloads +- Link previews + +With the new UX, the chat will start automatically, without giving users a chance to configure the app. That means that we have to migrate settings to a new device as well, as part of the archive. + +## Solution + +There are several possible approaches: +- put settings to the database via the API +- save settings as some file with cross-platform format (e.g. JSON or YAML or properties used on desktop). + +The second approach seems much simpler than maintaining the settings in the database. + +If we save a file, then there are two options: +- native apps maintain cross-platform schemas for this file, support any JSON and parse it in a safe way (so that even invalid or incorrect JSON - e.g., array instead of object - or invalid types in some properties do not cause the failure of properties that are correct). +- this schema and type will be maintained in the core library, that will be responsible for storing and reading the settings and passing to native UI as correct record of a given type. + +The downside of the second approach is that addition of any property that needs to be migrated will have to be done on any change in either of the platforms. The downside of the first approach is that neither app platform will be self-sufficient any more, and not only iOS/Android would have to take into account code, but also each other code. + +If we go with the second approach, there will be these types: + +```haskell +data AppSettings = AppSettings + { networkConfig :: NetworkConfig, -- existing type in Haskell and all UIs + privacyConfig :: PrivacyConfig -- new type, etc. + -- ... additional properties after the initial release should be added as Maybe, as all extensions + } + +data ArchiveConfig = ArchiveConfig + { -- existing properties + archivePath :: FilePath, + disableCompression :: Maybe Bool, + parentTempDirectory :: Maybe FilePath, + -- new property + appSettings :: AppSettings + -- for export, these settings will contain the settings passed from the UI and will be saved to JSON file as simplex_v1_settings.json in the archive + -- for import, these settings will contain the defaults that will be used if some property or subproperty is missing in JSON + } + +-- importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] -- current type +importArchive :: ChatMonad m => ArchiveConfig -> m ArchiveImportResult -- new type + +-- | CRArchiveImported {archiveErrors :: [ArchiveError]} -- current type + | CRArchiveImported {importResult :: ArchiveImportResult} -- new type + +data ArchiveImportResult = ArchiveImportResult + { archiveErrors :: [ArchiveError], + appSettings :: Maybe AppSettings + } +``` \ No newline at end of file diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9613282080..cc98e1a8f4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -26,6 +26,7 @@ flag swift library exposed-modules: Simplex.Chat + Simplex.Chat.AppSettings Simplex.Chat.Archive Simplex.Chat.Bot Simplex.Chat.Bot.KnownContacts @@ -134,6 +135,7 @@ library Simplex.Chat.Migrations.M20240115_block_member_for_all Simplex.Chat.Migrations.M20240122_indexes Simplex.Chat.Migrations.M20240214_redirect_file_id + Simplex.Chat.Migrations.M20240222_app_settings Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -149,6 +151,7 @@ library Simplex.Chat.Remote.Transport Simplex.Chat.Remote.Types Simplex.Chat.Store + Simplex.Chat.Store.AppSettings Simplex.Chat.Store.Connections Simplex.Chat.Store.Direct Simplex.Chat.Store.Files diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9d5c23d7a6..e5b4af670a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -68,6 +68,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote import Simplex.Chat.Remote.Types import Simplex.Chat.Store +import Simplex.Chat.Store.AppSettings import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files @@ -597,6 +598,8 @@ processChatCommand' vr = \case fileErrs <- importArchive cfg setStoreChanged pure $ CRArchiveImported fileErrs + APISaveAppSettings as -> withStore' (`saveAppSettings` as) >> ok_ + APIGetAppSettings platformDefaults -> CRAppSettings <$> withStore' (`getAppSettings` platformDefaults) APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg TestStorageEncryption key -> sqlCipherTestKey key >> ok_ @@ -6469,6 +6472,8 @@ chatCommandP = "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), "/db test key " *> (TestStorageEncryption <$> dbKeyP), + "/_save app settings" *> (APISaveAppSettings <$> jsonP), + "/_get app settings" *> (APIGetAppSettings <$> optional (A.space *> jsonP)), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs new file mode 100644 index 0000000000..572ce0c67b --- /dev/null +++ b/src/Simplex/Chat/AppSettings.hs @@ -0,0 +1,190 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.AppSettings where + +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), (.:?)) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Simplex.Messaging.Client (NetworkConfig, defaultNetworkConfig) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +import Simplex.Messaging.Util (catchAll_) + +data AppPlatform = APIOS | APAndroid | APDesktop deriving (Show) + +data NotificationMode = NMOff | NMPeriodic | NMInstant deriving (Show) + +data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Show) + +data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show) + +data AppSettings = AppSettings + { appPlatform :: Maybe AppPlatform, + networkConfig :: Maybe NetworkConfig, + privacyEncryptLocalFiles :: Maybe Bool, + privacyAcceptImages :: Maybe Bool, + privacyLinkPreviews :: Maybe Bool, + privacyShowChatPreviews :: Maybe Bool, + privacySaveLastDraft :: Maybe Bool, + privacyProtectScreen :: Maybe Bool, + notificationMode :: Maybe NotificationMode, + notificationPreviewMode :: Maybe NotificationPreviewMode, + webrtcPolicyRelay :: Maybe Bool, + webrtcICEServers :: Maybe [Text], + confirmRemoteSessions :: Maybe Bool, + connectRemoteViaMulticast :: Maybe Bool, + connectRemoteViaMulticastAuto :: Maybe Bool, + developerTools :: Maybe Bool, + confirmDBUpgrades :: Maybe Bool, + androidCallOnLockScreen :: Maybe LockScreenCalls, + iosCallKitEnabled :: Maybe Bool, + iosCallKitCallsInRecents :: Maybe Bool + } + deriving (Show) + +defaultAppSettings :: AppSettings +defaultAppSettings = + AppSettings + { appPlatform = Nothing, + networkConfig = Just defaultNetworkConfig, + privacyEncryptLocalFiles = Just True, + privacyAcceptImages = Just True, + privacyLinkPreviews = Just True, + privacyShowChatPreviews = Just True, + privacySaveLastDraft = Just True, + privacyProtectScreen = Just False, + notificationMode = Just NMInstant, + notificationPreviewMode = Just NPMMessage, + webrtcPolicyRelay = Just True, + webrtcICEServers = Just [], + confirmRemoteSessions = Just False, + connectRemoteViaMulticast = Just True, + connectRemoteViaMulticastAuto = Just True, + developerTools = Just False, + confirmDBUpgrades = Just False, + androidCallOnLockScreen = Just LSCShow, + iosCallKitEnabled = Just True, + iosCallKitCallsInRecents = Just False + } + +defaultParseAppSettings :: AppSettings +defaultParseAppSettings = + AppSettings + { appPlatform = Nothing, + networkConfig = Nothing, + privacyEncryptLocalFiles = Nothing, + privacyAcceptImages = Nothing, + privacyLinkPreviews = Nothing, + privacyShowChatPreviews = Nothing, + privacySaveLastDraft = Nothing, + privacyProtectScreen = Nothing, + notificationMode = Nothing, + notificationPreviewMode = Nothing, + webrtcPolicyRelay = Nothing, + webrtcICEServers = Nothing, + confirmRemoteSessions = Nothing, + connectRemoteViaMulticast = Nothing, + connectRemoteViaMulticastAuto = Nothing, + developerTools = Nothing, + confirmDBUpgrades = Nothing, + androidCallOnLockScreen = Nothing, + iosCallKitEnabled = Nothing, + iosCallKitCallsInRecents = Nothing + } + +combineAppSettings :: AppSettings -> AppSettings -> AppSettings +combineAppSettings platformDefaults storedSettings = + AppSettings + { appPlatform = p appPlatform, + networkConfig = p networkConfig, + privacyEncryptLocalFiles = p privacyEncryptLocalFiles, + privacyAcceptImages = p privacyAcceptImages, + privacyLinkPreviews = p privacyLinkPreviews, + privacyShowChatPreviews = p privacyShowChatPreviews, + privacySaveLastDraft = p privacySaveLastDraft, + privacyProtectScreen = p privacyProtectScreen, + notificationMode = p notificationMode, + notificationPreviewMode = p notificationPreviewMode, + webrtcPolicyRelay = p webrtcPolicyRelay, + webrtcICEServers = p webrtcICEServers, + confirmRemoteSessions = p confirmRemoteSessions, + connectRemoteViaMulticast = p connectRemoteViaMulticast, + connectRemoteViaMulticastAuto = p connectRemoteViaMulticastAuto, + developerTools = p developerTools, + confirmDBUpgrades = p confirmDBUpgrades, + iosCallKitEnabled = p iosCallKitEnabled, + iosCallKitCallsInRecents = p iosCallKitCallsInRecents, + androidCallOnLockScreen = p androidCallOnLockScreen + } + where + p :: (AppSettings -> Maybe a) -> Maybe a + p sel = sel storedSettings <|> sel platformDefaults <|> sel defaultAppSettings + +$(JQ.deriveJSON (enumJSON $ dropPrefix "AP") ''AppPlatform) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "NM") ''NotificationMode) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "NPM") ''NotificationPreviewMode) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls) + +$(JQ.deriveToJSON defaultJSON ''AppSettings) + +instance FromJSON AppSettings where + parseJSON (J.Object v) = do + appPlatform <- p "appPlatform" + networkConfig <- p "networkConfig" + privacyEncryptLocalFiles <- p "privacyEncryptLocalFiles" + privacyAcceptImages <- p "privacyAcceptImages" + privacyLinkPreviews <- p "privacyLinkPreviews" + privacyShowChatPreviews <- p "privacyShowChatPreviews" + privacySaveLastDraft <- p "privacySaveLastDraft" + privacyProtectScreen <- p "privacyProtectScreen" + notificationMode <- p "notificationMode" + notificationPreviewMode <- p "notificationPreviewMode" + webrtcPolicyRelay <- p "webrtcPolicyRelay" + webrtcICEServers <- p "webrtcICEServers" + confirmRemoteSessions <- p "confirmRemoteSessions" + connectRemoteViaMulticast <- p "connectRemoteViaMulticast" + connectRemoteViaMulticastAuto <- p "connectRemoteViaMulticastAuto" + developerTools <- p "developerTools" + confirmDBUpgrades <- p "confirmDBUpgrades" + iosCallKitEnabled <- p "iosCallKitEnabled" + iosCallKitCallsInRecents <- p "iosCallKitCallsInRecents" + androidCallOnLockScreen <- p "androidCallOnLockScreen" + pure + AppSettings + { appPlatform, + networkConfig, + privacyEncryptLocalFiles, + privacyAcceptImages, + privacyLinkPreviews, + privacyShowChatPreviews, + privacySaveLastDraft, + privacyProtectScreen, + notificationMode, + notificationPreviewMode, + webrtcPolicyRelay, + webrtcICEServers, + confirmRemoteSessions, + connectRemoteViaMulticast, + connectRemoteViaMulticastAuto, + developerTools, + confirmDBUpgrades, + iosCallKitEnabled, + iosCallKitCallsInRecents, + androidCallOnLockScreen + } + where + p key = v .:? key <|> pure Nothing + parseJSON _ = pure defaultParseAppSettings + +readAppSettings :: FilePath -> Maybe AppSettings -> IO AppSettings +readAppSettings f platformDefaults = + combineAppSettings (fromMaybe defaultAppSettings platformDefaults) . fromMaybe defaultParseAppSettings + <$> (J.decodeFileStrict f `catchAll_` pure Nothing) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7d030a49f5..cdecfa3159 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -49,6 +49,7 @@ import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC +import Simplex.Chat.AppSettings import Simplex.Chat.Call import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages @@ -245,6 +246,8 @@ data ChatCommand | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig + | APISaveAppSettings AppSettings + | APIGetAppSettings (Maybe AppSettings) | APIDeleteStorage | APIStorageEncryption DBEncryptionConfig | TestStorageEncryption DBEncryptionKey @@ -711,6 +714,7 @@ data ChatResponse | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} | CRArchiveImported {archiveErrors :: [ArchiveError]} + | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) diff --git a/src/Simplex/Chat/Migrations/M20240222_app_settings.hs b/src/Simplex/Chat/Migrations/M20240222_app_settings.hs new file mode 100644 index 0000000000..e7fda06a2e --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240222_app_settings.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240222_app_settings where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240222_app_settings :: Query +m20240222_app_settings = + [sql| +CREATE TABLE app_settings ( + app_settings TEXT NOT NULL +); +|] + +down_m20240222_app_settings :: Query +down_m20240222_app_settings = + [sql| +DROP TABLE app_settings; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index b5726cae2d..36f01d06b1 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -562,6 +562,7 @@ CREATE TABLE note_folders( favorite INTEGER NOT NULL DEFAULT 0, unread_chat INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE app_settings(app_settings TEXT NOT NULL); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name diff --git a/src/Simplex/Chat/Store/AppSettings.hs b/src/Simplex/Chat/Store/AppSettings.hs new file mode 100644 index 0000000000..ee0dd30183 --- /dev/null +++ b/src/Simplex/Chat/Store/AppSettings.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Store.AppSettings where + +import Control.Monad (join) +import Control.Monad.IO.Class (liftIO) +import qualified Data.Aeson as J +import Data.Maybe (fromMaybe) +import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB + +saveAppSettings :: DB.Connection -> AppSettings -> IO () +saveAppSettings db appSettings = do + DB.execute_ db "DELETE FROM app_settings" + DB.execute db "INSERT INTO app_settings (app_settings) VALUES (?)" (Only $ J.encode appSettings) + +getAppSettings :: DB.Connection -> Maybe AppSettings -> IO AppSettings +getAppSettings db platformDefaults = do + stored_ <- join <$> liftIO (maybeFirstRow (J.decodeStrict . fromOnly) $ DB.query_ db "SELECT app_settings FROM app_settings") + pure $ combineAppSettings (fromMaybe defaultAppSettings platformDefaults) (fromMaybe defaultParseAppSettings stored_) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 832f07dcb9..32b003afd6 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -99,6 +99,7 @@ import Simplex.Chat.Migrations.M20240104_members_profile_update import Simplex.Chat.Migrations.M20240115_block_member_for_all import Simplex.Chat.Migrations.M20240122_indexes import Simplex.Chat.Migrations.M20240214_redirect_file_id +import Simplex.Chat.Migrations.M20240222_app_settings import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -197,7 +198,8 @@ schemaMigrations = ("20240104_members_profile_update", m20240104_members_profile_update, Just down_m20240104_members_profile_update), ("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all), ("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes), - ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id) + ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id), + ("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b6bb7807cb..667613ba6a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -385,6 +385,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe 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)] + CRAppSettings as -> ["app settings: " <> plain (LB.unpack $ J.encode as)] CRTimedAction _ _ -> [] where ttyUser :: User -> [StyledString] -> [StyledString] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 4ad4e862da..44bfb543f6 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -14,6 +14,9 @@ import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.Text as T +import Simplex.Chat.AppSettings (defaultAppSettings) +import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) @@ -21,6 +24,7 @@ import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.FilePath (()) @@ -84,8 +88,9 @@ chatDirectTests = do it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages it "user profile privacy: hide profiles and notificaitons" testUserPrivacy - describe "chat item expiration" $ do - it "set chat item TTL" testSetChatItemTTL + describe "settings" $ do + it "set chat item expiration TTL" testSetChatItemTTL + it "save/get app settings" testAppSettings describe "connection switch" $ do it "switch contact to a different queue" testSwitchContact it "stop switching contact to a different queue" testAbortSwitchContact @@ -2195,6 +2200,24 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") +testAppSettings :: HasCallStack => FilePath -> IO () +testAppSettings tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings + settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]} + -- app-provided defaults + alice ##> ("/_get app settings " <> settingsApp) + alice <## ("app settings: " <> settingsApp) + -- parser defaults fallback + alice ##> "/_get app settings" + alice <## ("app settings: " <> settings) + -- store + alice ##> ("/_save app settings " <> settingsApp) + alice <## "ok" + -- read back + alice ##> "/_get app settings" + alice <## ("app settings: " <> settingsApp) + testSwitchContact :: HasCallStack => FilePath -> IO () testSwitchContact = testChat2 aliceProfile bobProfile $ From 7213913d5123c6faa759899c77c4ce592599fb02 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 24 Feb 2024 21:28:18 +0000 Subject: [PATCH 13/64] docs: update privacy policy (#3796) * docs: update privacy policy * update glossary * update * links * amend * update --- PRIVACY.md | 150 ++++++++++++++++++++------------ docs/GLOSSARY.md | 12 +++ website/src/_data/glossary.json | 8 ++ 3 files changed, 112 insertions(+), 58 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index dbd48940f6..3204fa1e53 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,134 +1,168 @@ -# SimpleX Chat Terms & Privacy Policy +# SimpleX Chat Privacy Policy and Conditions of Use -SimpleX Chat is the first communication platform that has no user profile IDs of any kind, not even random numbers. Not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we cannot observe your connections graph. +SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability. -If you believe that some of the clauses in this document are not aligned with our mission or principles, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). +SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts. + +Double ratchet algorithm has such important properties as [forward secrecy](./docs/GLOSSARY.md#forward-secrecy), sender [repudiation](./docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](./docs/GLOSSARY.md#post-compromise-security)). + +If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). ## Privacy Policy -SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack). +SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks. -SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users. +SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications. -SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). +SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers. -### Information you provide +While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers. + +We see users and data sovereignty, and device and provider portability as critically important properties for any communication system. + +SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). + +### Your information #### User profiles -We do not store user profiles. The profile you create in the app is local to your device. +Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app. -When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users. +When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users. + +You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption. #### Messages and Files -SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band. +SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band. -Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users. +Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages – you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too. -The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers). +You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well. -The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers). +The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers). -If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week). +The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers). + +If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers). + +As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers. + +In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications. #### Connections with other users -When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default. +When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default. -At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. +SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. + +#### Connection links privacy + +When you create a connection with another user, the app generates a link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app). + +While the connection "links" contain SimpleX Chat Ltd domain name `simplex.chat`, this site is never accessed by the app, and is only used for these purposes: +- to direct the new users to the app download instructions, +- to show connection QR code that can be scanned via the app, +- to "namespace" these links, +- to open links directly in the installed app when it is clicked outside of the app. + +You can always safely replace the initial part of the link `https://simplex.chat/` either with `simplex:/` (which is a URI scheme provisionally registered with IANA) or with any other domain name where you can self-host the app download instructions and show the connection QR code (but in case it is your domain, it will not open in the app). Also, while the page renders QR code, all the information needed to render it is only available to the browser, as the part of the "link" after `#` symbol is not sent to the website server. #### iOS Push Notifications When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue. -Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. +Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers. -It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off). +You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off). #### Another information stored on the servers -Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services. +Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively. -#### SimpleX Directory Service +#### SimpleX Directory -[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). +[SimpleX Directory](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). -#### User Support. +#### User Support -If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible. +If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information. ### Information we may share -We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers. +SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs. -We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service. +We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat). -The cases when SimpleX Chat may need to share the data we temporarily store on the servers: +The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers: -- To meet any applicable law, regulation, legal process or enforceable governmental request. -- To enforce applicable Terms, including investigation of potential violations. +- To meet any applicable law, or enforceable governmental request or court order. +- To enforce applicable terms, including investigation of potential violations. - To detect, prevent, or otherwise address fraud, security, or technical issues. -- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law. +- To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law. -At the time of updating this document, we have never provided or have been requested the access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process. +At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law. ### Updates -We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy. +We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy. -Please also read our Terms of Service below. +Please also read our Conditions of Use of Software and Infrastructure below. If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). -## Terms of Service +## Conditions of Use of Software and Infrastructure -You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services"). +You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not. -**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country. +**Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country. -**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks. +**Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated. -**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data. +**Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it. -**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners. +**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks. -**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way. +**Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way. -**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes. +**Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future. -**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam. +**Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way. -**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services. +**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes. -**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up. +**Legal and acceptable usage**. You agree to use our Applications only for legal and acceptable purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal or impermissible communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team. -**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration. +**Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. -**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files. +**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. -**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service. +**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings. -**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services. +**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted. -**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app. +**No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service. -**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE) +**Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services. -**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat. +**Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design. -**Disclaimers**. YOU USE OUR SERVICES AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR SERVICES ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR SERVICES WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR SERVICES WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES. +**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE). -**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR TERMS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW. +**SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat. -**Availability**. Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time. +**Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES. -**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Terms, us, or our Services in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Terms, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat and you, without regard to conflict of law provisions. +**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW. -**Changes to the terms**. SimpleX Chat may update the Terms from time to time. Your continued use of our Services confirms your acceptance of our updated Terms and supersedes any prior Terms. You will comply with all applicable export control and trade sanctions laws. Our Terms cover the entire agreement between you and SimpleX Chat regarding our Services. If you do not agree with our Terms, you should stop using our Services. +**Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time. -**Enforcing the terms**. If we fail to enforce any of our Terms, that does not mean we waive the right to enforce them. If any provision of the Terms is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Terms and shall not affect the enforceability of the remaining provisions. Our Services are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Services in any country. If you have specific questions about these Terms, please contact us at chat@simplex.chat. +**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions. -**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat. +**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications. -Updated August 17, 2023 +**Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat. + +**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd. + +Updated February 24, 2024 diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index e68508ccc3..0cb855d729 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -143,6 +143,12 @@ SimpleX Clients also form a network using SMP relays and IP or some other overla [Wikipedia](https://en.wikipedia.org/wiki/Overlay_network) +# Non-repudiation + +The property of the cryptographic or communication system that allows the recipient of the message to prove to any third party that the sender identified by some cryptographic key sent the message. It is the opposite to [repudiation](#repudiation). While in some context non-repudiation may be desirable (e.g., for contractually binding messages), in the context of private communications it may be undesirable. + +[Wikipedia](https://en.wikipedia.org/wiki/Non-repudiation) + ## Pairwise pseudonymous identifier Generalizing [the definition](https://csrc.nist.gov/glossary/term/pairwise_pseudonymous_identifier) from NIST Digital Identity Guidelines, it is an opaque unguessable identifier generated by a service used to access a resource by only one party. @@ -185,6 +191,12 @@ Network topology of the communication system when peers communicate via proxies [Post-compromise security](#post-compromise-security). +## Repudiation + +The property of the cryptographic or communication system that allows the sender of the message to plausibly deny having sent the message, because while the recipient can verify that the message was sent by the sender, they cannot prove it to any third party - the recipient has a technical ability to forge the same encrypted message. This is an important quality of private communications, as it allows to have the conversation that can later be denied, similarly to having a private face-to-face conversation. + +See also [non-repudiation](#non-repudiation). + ## User identity In a communication system it refers to anything that uniquely identifies the users to the network. Depending on the communication network, it can be a phone number, email address, username, public key or a random opaque identifier. Most messaging networks rely on some form of user identity. SimpleX appears to be the only messaging network that does not rely on any kind of user identity - see [this comparison](https://en.wikipedia.org/wiki/Comparison_of_instant_messaging_protocols). diff --git a/website/src/_data/glossary.json b/website/src/_data/glossary.json index fd420ccaa6..3420ba3700 100644 --- a/website/src/_data/glossary.json +++ b/website/src/_data/glossary.json @@ -67,6 +67,10 @@ "term": "Message padding", "definition": "Message padding" }, + { + "term": "Non-repudiation", + "definition": "Non-repudiation" + }, { "term": "Onion routing", "definition": "Onion routing" @@ -103,6 +107,10 @@ "term": "Recovery from compromise", "definition": "Post-compromise security" }, + { + "term": "Repudiation", + "definition": "Repudiation" + }, { "term": "User identity", "definition": "User identity" From ec8ae9febe6e6b5dc24dfca57ab064df9918381f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:05:25 +0400 Subject: [PATCH 14/64] docs: inactive group members rfc (simplified) (#3803) --- .../2024-02-13-inactive-group-members-2.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/rfcs/2024-02-13-inactive-group-members-2.md diff --git a/docs/rfcs/2024-02-13-inactive-group-members-2.md b/docs/rfcs/2024-02-13-inactive-group-members-2.md new file mode 100644 index 0000000000..6f7fc2f377 --- /dev/null +++ b/docs/rfcs/2024-02-13-inactive-group-members-2.md @@ -0,0 +1,38 @@ +# Inactive group members (simplified) + +[Original doc](./2023-11-21-inactive-group-members.md) + +## Problem + +Groups traffic is higher than necessary due to sending messages to inactive group members. + +## Solution + +### Improve connection deletion + +- When leaving or deleting group, batch db operations to optimize performance. +- In agent - fix race where connection can be deleted while it has remaining pending messages. + - Current agent logic is to immediately delete connection if it has no rcv queues left. + - Simplest should be to make a smart version of `deleteConn` for this improvement, checking `snd_messages` table for remaining messages, and keep connection around in case there are. + - While this may improve delivery of group leave and delete messages, it may as well have undesirable side effects for other use cases, as any pending messages will be sent prior to deleting connection. For example, user sends several messages on bad network, decides to delete contact, messages are still delivered when user is on good network before deletion, even though this contradicts user's intent and messages hadn't left user's device at the time of deletion. Considering this race when it happens is identical to simply leaving groups by deleting app, or deleting user profile only locally, it may be a bad idea to affect regular contact deletion for this use case. + +### Track member inactivity + +- Mark members as inactive on QUOTA errors, reset as active on QCONT + - track `group_members.inactive` flag per group member + - on SMP.QUOTA error agent to notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA) + - on receiving QCONT agent to notify client (new event) + - apart from QCONT, reset on any message or receipt +- Don't send to member if inactive + - don't send only content messages (x.msg.new, etc.) and always send messages altering group state? + - or don't send any messages? +- Track number of skipped messages per member and first skipped message + - count `group_members.skipped_msg_cnt` + - only count messages of same types/criteria that are included into history + - track `group_members.skipped_first_shared_msg_id` (only content or including service messages?) +- Send XGrpMsgSkipped before next message + - check `skipped_msg_cnt` > 0 and `skipped_first_shared_msg_id` is not null to only send once, reset after sending + +```haskell +XGrpMsgSkipped :: SharedMsgId -> Int64 -> ChatMsgEvent 'Json -- from, count +``` From 51a2e097148282597a863f865b0f92e539c3fd8a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:36:42 +0400 Subject: [PATCH 15/64] core: batch db operations for group leave and delete (#3807) * core: batch db operations for group leave and delete * remove comment * batch delete files * cleanup * rename * use new agent api * refactor * refactor, catch error * refactor * update simplexmq --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 224 ++++++++++++++++++++------------- src/Simplex/Chat/Controller.hs | 8 ++ src/Simplex/Chat/Types.hs | 6 +- tests/ChatTests/Files.hs | 1 - 6 files changed, 153 insertions(+), 90 deletions(-) diff --git a/cabal.project b/cabal.project index de525ee718..609618858c 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0d843ea4ce1b26a25b55756bf86d1007629896c5 + tag: 050a921fbbdf21690cab7765bf6237fdc5a419cb source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6321740ae9..2262c38a6d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."0d843ea4ce1b26a25b55756bf86d1007629896c5" = "0p3mw5kpqhxsjhairx7qaacv33hm11wmbax6jzv2w49nwkcpnbal"; + "https://github.com/simplex-chat/simplexmq.git"."050a921fbbdf21690cab7765bf6237fdc5a419cb" = "0bc8x3pv3l6wjcfx06yhyydf2amaw5jjax2wcbgbxzrhqz10xf1v"; "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 e5b4af670a..1f096b310f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -939,7 +939,8 @@ processChatCommand' vr = \case ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo when (contactReady ct && contactActive ct && notify) $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct) @@ -962,7 +963,8 @@ processChatCommand' vr = \case unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo withChatLock "deleteChat group" . procCmd $ do - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections user members @@ -973,37 +975,40 @@ processChatCommand' vr = \case withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members withStore' $ \db -> deleteGroup db user gInfo let contactIds = mapMaybe memberContactId members - deleteAgentConnectionsAsync user . concat =<< mapM deleteUnusedContact contactIds + (errs1, (errs2, connIds)) <- second unzip . partitionEithers <$> withStoreBatch (\db -> map (deleteUnusedContact db) contactIds) + let errs = errs1 <> mapMaybe (fmap ChatErrorStore) errs2 + unless (null errs) $ toView $ CRChatErrors (Just user) errs + deleteAgentConnectionsAsync user $ concat connIds pure $ CRGroupDeletedUser user gInfo where - deleteUnusedContact :: ContactId -> m [ConnId] - deleteUnusedContact contactId = - (withStore (\db -> getContact db user contactId) >>= delete) - `catchChatError` (\e -> toView (CRChatError (Just user) e) $> []) + deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId])) + deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do + ct <- getContact db user contactId + ifM + ((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct)) + (pure (Nothing, [])) + (getConnections ct) where - delete ct - | directOrUsed ct = pure [] - | otherwise = - withStore' (\db -> checkContactHasGroups db user ct) >>= \case - Just _ -> pure [] - Nothing -> do - conns <- withStore' $ \db -> getContactConnections db userId ct - withStore (\db -> setContactDeleted db user ct) - `catchChatError` (toView . CRChatError (Just user)) - pure $ map aConnId conns + getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId]) + getConnections ct = do + conns <- liftIO $ getContactConnections db userId ct + e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just) + pure (e_, map aConnId conns) CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do gInfo <- withStore $ \db -> getGroupInfo db vr user chatId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStore' $ \db -> deleteGroupCIs db user gInfo membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m @@ -1012,7 +1017,7 @@ processChatCommand' vr = \case nf <- withStore $ \db -> getNoteFolder db user chatId filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf withChatLock "clearChat local" . procCmd $ do - mapM_ (deleteFile user) filesInfo + deleteFilesLocally filesInfo withStore' $ \db -> deleteNoteFolderFiles db userId nf withStore' $ \db -> deleteNoteFolderCIs db user nf pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) @@ -1697,7 +1702,9 @@ processChatCommand' vr = \case pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId + filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo withChatLock "leaveGroup" . procCmd $ do + cancelFilesInProgress user filesInfo (msg, _) <- sendGroupMessage' user gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) @@ -2351,7 +2358,8 @@ processChatCommand' vr = \case deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues withStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing @@ -2559,50 +2567,72 @@ setAllExpireCIFlags b = do keys <- M.keys <$> readTVar expireFlags forM_ keys $ \k -> TM.insert k b expireFlags -deleteFilesAndConns :: ChatMonad m => User -> [CIFileInfo] -> m () -deleteFilesAndConns user filesInfo = do - connIds <- mapM (deleteFile user) filesInfo - deleteAgentConnectionsAsync user $ concat connIds - -deleteFile :: ChatMonad m => User -> CIFileInfo -> m [ConnId] -deleteFile user fileInfo = deleteFile' user fileInfo False - -deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] -deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do - aConnIds <- cancelFile' user ciFileInfo sendCancel - forM_ filePath $ \fPath -> - deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user)) - pure aConnIds - -deleteFileLocally :: forall m. ChatMonad m => FilePath -> m () -deleteFileLocally fPath = - withFilesFolder $ \filesFolder -> liftIO $ do - let fsFilePath = filesFolder fPath - removeFile fsFilePath `catchAll` \_ -> - removePathForcibly fsFilePath `catchAll_` pure () +cancelFilesInProgress :: forall m. ChatMonad m => User -> [CIFileInfo] -> m () +cancelFilesInProgress user filesInfo = do + let filesInfo' = filter (not . fileEnded) filesInfo + (sfs, rfs) <- splitFTTypes <$> withStoreBatch (\db -> map (getFT db) filesInfo') + forM_ rfs $ \RcvFileTransfer {fileId} -> closeFileHandle fileId rcvFiles `catchChatError` \_ -> pure () + void . withStoreBatch' $ \db -> map (updateSndFileCancelled db) sfs + void . withStoreBatch' $ \db -> map (updateRcvFileCancelled db) rfs + let xsfIds = mapMaybe (\(FileTransferMeta {fileId, xftpSndFile}, _) -> (,fileId) <$> xftpSndFile) sfs + xrfIds = mapMaybe (\RcvFileTransfer {fileId, xftpRcvFile} -> (,fileId) <$> xftpRcvFile) rfs + agentXFTPDeleteSndFilesRemote user xsfIds + agentXFTPDeleteRcvFiles xrfIds + let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs + smpRFConnIds = mapMaybe smpRcvFileConnId rfs + deleteAgentConnectionsAsync user smpSFConnIds + deleteAgentConnectionsAsync user smpRFConnIds where + fileEnded CIFileInfo {fileStatus} = case fileStatus of + Just (AFS _ status) -> ciFileEnded status + Nothing -> True + getFT :: DB.Connection -> CIFileInfo -> IO (Either ChatError FileTransfer) + getFT db CIFileInfo {fileId} = runExceptT . withExceptT ChatErrorStore $ getFileTransfer db user fileId + updateSndFileCancelled :: DB.Connection -> (FileTransferMeta, [SndFileTransfer]) -> IO () + updateSndFileCancelled db (FileTransferMeta {fileId}, sfts) = do + updateFileCancelled db user fileId CIFSSndCancelled + forM_ sfts updateSndFTCancelled + where + updateSndFTCancelled :: SndFileTransfer -> IO () + updateSndFTCancelled ft = unless (sndFTEnded ft) $ do + updateSndFileStatus db ft FSCancelled + deleteSndFileChunks db ft + updateRcvFileCancelled :: DB.Connection -> RcvFileTransfer -> IO () + updateRcvFileCancelled db ft@RcvFileTransfer {fileId} = do + updateFileCancelled db user fileId CIFSRcvCancelled + updateRcvFileStatus db fileId FSCancelled + deleteRcvFileChunks db ft + splitFTTypes :: [Either ChatError FileTransfer] -> ([(FileTransferMeta, [SndFileTransfer])], [RcvFileTransfer]) + splitFTTypes = foldr addFT ([], []) . rights + where + addFT f (sfs, rfs) = case f of + FTSnd ft@FileTransferMeta {cancelled} sfts | not cancelled -> ((ft, sfts) : sfs, rfs) + FTRcv ft@RcvFileTransfer {cancelled} | not cancelled -> (sfs, ft : rfs) + _ -> (sfs, rfs) + smpSndFileConnId :: FileTransferMeta -> SndFileTransfer -> Maybe ConnId + smpSndFileConnId FileTransferMeta {xftpSndFile} sft@SndFileTransfer {agentConnId = AgentConnId acId, fileInline} + | isNothing xftpSndFile && isNothing fileInline && not (sndFTEnded sft) = Just acId + | otherwise = Nothing + smpRcvFileConnId :: RcvFileTransfer -> Maybe ConnId + smpRcvFileConnId ft@RcvFileTransfer {xftpRcvFile, rcvFileInline} + | isNothing xftpRcvFile && isNothing rcvFileInline = liveRcvFileTransferConnId ft + | otherwise = Nothing + sndFTEnded SndFileTransfer {fileStatus} = fileStatus == FSCancelled || fileStatus == FSComplete + +deleteFilesLocally :: forall m. ChatMonad m => [CIFileInfo] -> m () +deleteFilesLocally files = + withFilesFolder $ \filesFolder -> + liftIO . forM_ files $ \CIFileInfo {filePath} -> + mapM_ (delete . (filesFolder )) filePath + where + delete :: FilePath -> IO () + delete fPath = + removeFile fPath `catchAll` \_ -> + removePathForcibly fPath `catchAll_` pure () -- perform an action only if filesFolder is set (i.e. on mobile devices) withFilesFolder :: (FilePath -> m ()) -> m () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action -cancelFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] -cancelFile' user CIFileInfo {fileId, fileStatus} sendCancel = - case fileStatus of - Just fStatus -> cancel' fStatus `catchChatError` (\e -> toView (CRChatError (Just user) e) $> []) - Nothing -> pure [] - where - cancel' :: ACIFileStatus -> m [ConnId] - cancel' (AFS dir status) = - if ciFileEnded status - then pure [] - else case dir of - SMDSnd -> do - (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId) - if cancelled then pure [] else cancelSndFile user ftm fts sendCancel - SMDRcv -> do - ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) - if cancelled then pure [] else maybeToList <$> cancelRcvFileTransfer user ft - updateCallItemStatus :: ChatMonad m => User -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m () updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do aciContent_ <- callStatusItemContent user ct chatItemId receivedStatus @@ -3166,13 +3196,15 @@ expireChatItems user@User {userId} ttl sync = do processContact expirationDate ct = do waitChatStartedAndActivated filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate processGroup :: UTCTime -> UTCTime -> GroupInfo -> m () processGroup expirationDate createdAtCutoff gInfo = do waitChatStartedAndActivated filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - deleteFilesAndConns user filesInfo + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m @@ -5838,7 +5870,7 @@ deleteMembersConnections user members = do filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ mapMaybe (\GroupMember {activeConn} -> activeConn) members deleteAgentConnectionsAsync user $ map aConnId memberConns - forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m () deleteMemberConnection user GroupMember {activeConn} = do @@ -6153,18 +6185,19 @@ deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedT gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) deleteLocalCI :: (ChatMonad m, MsgDirectionI d) => User -> NoteFolder -> ChatItem 'CTLocal d -> Bool -> Bool -> m ChatResponse -deleteLocalCI user nf ci@ChatItem {file} byUser timed = do - forM_ file $ \CIFile {fileSource} -> do - forM_ (CF.filePath <$> fileSource) $ \fPath -> - deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user)) +deleteLocalCI user nf ci@ChatItem {file = file_} byUser timed = do + forM_ file_ $ \file -> do + let filesInfo = [mkCIFileInfo file] + deleteFilesLocally filesInfo withStore' $ \db -> deleteLocalChatItem db user nf ci pure $ CRChatItemDeleted user (AChatItem SCTLocal msgDirection (LocalChat nf) ci) Nothing byUser timed deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile user file_ = forM_ file_ $ \file -> do - fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True - deleteAgentConnectionsAsync user fileAgentConnIds + let filesInfo = [mkCIFileInfo file] + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do @@ -6185,8 +6218,8 @@ markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ del cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () cancelCIFile user file_ = forM_ file_ $ \file -> do - fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True - deleteAgentConnectionsAsync user fileAgentConnIds + let filesInfo = [mkCIFileInfo file] + cancelFilesInProgress user filesInfo createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do @@ -6228,20 +6261,43 @@ agentXFTPDeleteRcvFile aFileId fileId = do withAgent (`xftpDeleteRcvFile` aFileId) withStore' $ \db -> setRcvFTAgentDeleted db fileId -agentXFTPDeleteSndFileRemote :: ChatMonad m => User -> XFTPSndFile -> FileTransferId -> m () -agentXFTPDeleteSndFileRemote user sndFile fileId = do - -- the agent doesn't know about redirect, delete explicitly - redirect_ <- withStore' $ \db -> lookupFileTransferRedirectMeta db user fileId - forM_ redirect_ $ \FileTransferMeta {fileId = fileIdRedirect, xftpSndFile = sndFileRedirect_} -> - mapM_ (handleError (const $ pure ()) . remove fileIdRedirect) sndFileRedirect_ - remove fileId sndFile +agentXFTPDeleteRcvFiles :: ChatMonad m => [(XFTPRcvFile, FileTransferId)] -> m () +agentXFTPDeleteRcvFiles rcvFiles = do + let rcvFiles' = filter (not . agentRcvFileDeleted . fst) rcvFiles + rfIds = mapMaybe fileIds rcvFiles' + withAgent $ \a -> xftpDeleteRcvFiles a (map fst rfIds) + void . withStoreBatch' $ \db -> map (setRcvFTAgentDeleted db . snd) rfIds where - remove fId XFTPSndFile {agentSndFileId = AgentSndFileId aFileId, privateSndFileDescr, agentSndFileDeleted} = - unless agentSndFileDeleted $ do - forM_ privateSndFileDescr $ \sfdText -> do - sd <- parseFileDescription sfdText - withAgent $ \a -> xftpDeleteSndFileRemote a (aUserId user) aFileId sd - withStore' $ \db -> setSndFTAgentDeleted db user fId + fileIds :: (XFTPRcvFile, FileTransferId) -> Maybe (RcvFileId, FileTransferId) + fileIds (XFTPRcvFile {agentRcvFileId = Just (AgentRcvFileId aFileId)}, fileId) = Just (aFileId, fileId) + fileIds _ = Nothing + +agentXFTPDeleteSndFileRemote :: ChatMonad m => User -> XFTPSndFile -> FileTransferId -> m () +agentXFTPDeleteSndFileRemote user xsf fileId = + agentXFTPDeleteSndFilesRemote user [(xsf, fileId)] + +agentXFTPDeleteSndFilesRemote :: forall m. ChatMonad m => User -> [(XFTPSndFile, FileTransferId)] -> m () +agentXFTPDeleteSndFilesRemote user sndFiles = do + (_errs, redirects) <- partitionEithers <$> withStoreBatch' (\db -> map (lookupFileTransferRedirectMeta db user . snd) sndFiles) + let redirects' = mapMaybe mapRedirectMeta $ concat redirects + sndFilesAll = redirects' <> sndFiles + sndFilesAll' = filter (not . agentSndFileDeleted . fst) sndFilesAll + sndFilesAll'' <- catMaybes <$> mapM sndFileDescr sndFilesAll' + let sfs = map (\(XFTPSndFile {agentSndFileId = AgentSndFileId aFileId}, sfd, _) -> (aFileId, sfd)) sndFilesAll'' + withAgent $ \a -> xftpDeleteSndFilesRemote a (aUserId user) sfs + void . withStoreBatch' $ \db -> map (setSndFTAgentDeleted db user . (\(_, _, fId) -> fId)) sndFilesAll'' + where + mapRedirectMeta :: FileTransferMeta -> Maybe (XFTPSndFile, FileTransferId) + mapRedirectMeta FileTransferMeta {fileId = fileId, xftpSndFile = Just sndFileRedirect} = Just (sndFileRedirect, fileId) + mapRedirectMeta _ = Nothing + sndFileDescr :: (XFTPSndFile, FileTransferId) -> m (Maybe (XFTPSndFile, ValidFileDescription 'FSender, FileTransferId)) + sndFileDescr (xsf@XFTPSndFile {privateSndFileDescr}, fileId) = + join <$> forM privateSndFileDescr parseSndDescr + where + parseSndDescr sfdText = + tryChatError (parseFileDescription sfdText) >>= \case + Left _ -> pure Nothing + Right sd -> pure $ Just (xsf, sd, fileId) userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile userProfileToSend user@User {profile = p} incognitoProfile ct inGroup = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index cdecfa3159..c482825e18 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1252,6 +1252,14 @@ mkChatError :: SomeException -> ChatError mkChatError = ChatError . CEException . show {-# INLINE mkChatError #-} +catchStoreError :: ExceptT StoreError IO a -> (StoreError -> ExceptT StoreError IO a) -> ExceptT StoreError IO a +catchStoreError = catchAllErrors mkStoreError +{-# INLINE catchStoreError #-} + +mkStoreError :: SomeException -> StoreError +mkStoreError = SEInternalError . show +{-# INLINE mkStoreError #-} + chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index c340130f8a..0a35a83edd 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -46,7 +46,7 @@ import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) -import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) @@ -1142,7 +1142,7 @@ instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f instance ToField AgentConnId where toField (AgentConnId m) = toField m -newtype AgentSndFileId = AgentSndFileId ConnId +newtype AgentSndFileId = AgentSndFileId SndFileId deriving (Eq, Show) instance StrEncoding AgentSndFileId where @@ -1161,7 +1161,7 @@ instance FromField AgentSndFileId where fromField f = AgentSndFileId <$> fromFie instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m -newtype AgentRcvFileId = AgentRcvFileId ConnId +newtype AgentRcvFileId = AgentRcvFileId RcvFileId deriving (Eq, Show) instance StrEncoding AgentRcvFileId where diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 725717436d..3aa345773e 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -20,7 +20,6 @@ import Simplex.Chat.Options (ChatOpts (..)) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Util (unlessM) import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import Test.Hspec hiding (it) From c27973d202382f5fa5055f65f464d81c9e954bf7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 26 Feb 2024 17:10:21 +0400 Subject: [PATCH 16/64] core: restrict to delete user contact and display name (#3822) --- simplex-chat.cabal | 1 + .../Migrations/M20240226_users_restrict.hs | 30 +++++++++++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 4 +-- src/Simplex/Chat/Store/Migrations.hs | 4 ++- 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20240226_users_restrict.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index cc98e1a8f4..916cceb589 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -136,6 +136,7 @@ library Simplex.Chat.Migrations.M20240122_indexes Simplex.Chat.Migrations.M20240214_redirect_file_id Simplex.Chat.Migrations.M20240222_app_settings + Simplex.Chat.Migrations.M20240226_users_restrict Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs b/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs new file mode 100644 index 0000000000..a68923142c --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240226_users_restrict where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240226_users_restrict :: Query +m20240226_users_restrict = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'ON DELETE CASCADE', 'ON DELETE RESTRICT') +WHERE name = 'users' AND type = 'table'; + +PRAGMA writable_schema=0; +|] + +down_m20240226_users_restrict :: Query +down_m20240226_users_restrict = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'ON DELETE RESTRICT', 'ON DELETE CASCADE') +WHERE name = 'users' AND type = 'table'; + +PRAGMA writable_schema=0; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 36f01d06b1..98b9cfcc12 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -22,7 +22,7 @@ CREATE TABLE contact_profiles( ); CREATE TABLE users( user_id INTEGER PRIMARY KEY, - contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE + contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, local_display_name TEXT NOT NULL UNIQUE, active_user INTEGER NOT NULL DEFAULT 0, @@ -37,7 +37,7 @@ CREATE TABLE users( user_member_profile_updated_at TEXT, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) - ON DELETE CASCADE + ON DELETE RESTRICT ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED ); diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 32b003afd6..6d3a7a9a4f 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -100,6 +100,7 @@ import Simplex.Chat.Migrations.M20240115_block_member_for_all import Simplex.Chat.Migrations.M20240122_indexes import Simplex.Chat.Migrations.M20240214_redirect_file_id import Simplex.Chat.Migrations.M20240222_app_settings +import Simplex.Chat.Migrations.M20240226_users_restrict import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -199,7 +200,8 @@ schemaMigrations = ("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all), ("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes), ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id), - ("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings) + ("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings), + ("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict) ] -- | The list of migrations in ascending order by date From 8ac767764844e26e3e9fc83c812912697c7572ca Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko Date: Tue, 27 Feb 2024 04:24:07 +0700 Subject: [PATCH 17/64] ios: remove passcodes if app was reinstalled (#3841) * ios: remove passwords if app was reinstalled * change instead of delete * Revert "change instead of delete" This reverts commit 1195ee5b3088537b368e4547cb0ec1bdfe0a11da. * update name and comments --------- Co-authored-by: Avently Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/AppDelegate.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 24c0eeb605..7204625ad4 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -16,6 +16,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { application.registerForRemoteNotifications() if #available(iOS 17.0, *) { trackKeyboard() } NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil) + removePasscodesIfReinstalled() return true } @@ -127,6 +128,19 @@ class AppDelegate: NSObject, UIApplicationDelegate { BGManager.shared.receiveMessages(complete) } + private func removePasscodesIfReinstalled() { + // Check for the database existence, because app and self destruct passcodes + // will be saved and restored by iOS when a user deletes and re-installs the app. + // In this case the database and settings will be deleted, but the passcodes won't be. + // Deleting passcodes ensures that the user will not get stuck on "Opening app..." screen. + if (kcAppPassword.get() != nil || kcSelfDestructPassword.get() != nil) && + !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) && !hasDatabase() { + _ = kcAppPassword.remove() + _ = kcSelfDestructPassword.remove() + _ = kcDatabasePassword.remove() + } + } + static func keepScreenOn(_ on: Bool) { UIApplication.shared.isIdleTimerDisabled = on } From b66a3d059581405194cdff0f9d8eaefbb5b7bc6f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 27 Feb 2024 00:16:41 +0000 Subject: [PATCH 18/64] 5.5.6.0: update simplexmq to 5.5.2.2 (performance improvements) --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index 23a3d61233..a9c4e33d3b 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 32c94df040b7921584a4685a814818daec3bf209 + tag: 09878959264014f676a47f986a00c0c9fe34bcf1 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 881e9d038f..850f337fbc 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.5.5.0 +version: 5.5.6.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index cd456a269c..d82e6fd7d7 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."32c94df040b7921584a4685a814818daec3bf209" = "0bfyzra8x67zwqr7g8hkglxpy503qwn0xni0sjnbjmvh7wlh6pyz"; + "https://github.com/simplex-chat/simplexmq.git"."09878959264014f676a47f986a00c0c9fe34bcf1" = "1w64mh1hjpjxfna48z5cg65cwwqp0w027g7d4wvzlqkf0ny4r6ib"; "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 6db1d3b138..58542b8a31 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.5.5.0 +version: 5.5.6.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 0c4848ad9e500a9d0fed6c1197ebc0808d7e065b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 27 Feb 2024 14:12:01 +0000 Subject: [PATCH 19/64] 5.5.6: ios 201, android 187, desktop 32 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 64 +++++++++++----------- apps/multiplatform/gradle.properties | 8 +-- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 63250b09dc..c3851802b7 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -90,11 +90,6 @@ 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; }; 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; - 5CB1CE922B86660100963938 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8D2B86660100963938 /* libgmp.a */; }; - 5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8E2B86660100963938 /* libgmpxx.a */; }; - 5CB1CE942B86660100963938 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE8F2B86660100963938 /* libffi.a */; }; - 5CB1CE952B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */; }; - 5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */; }; 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB2084E28DA4B4800D024EC /* RTCServers.swift */; }; 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; @@ -144,6 +139,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 */; }; + 5CF4416D2B8E14EF00C52786 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF441682B8E14EF00C52786 /* libgmpxx.a */; }; + 5CF4416E2B8E14EF00C52786 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF441692B8E14EF00C52786 /* libffi.a */; }; + 5CF4416F2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */; }; + 5CF441702B8E14EF00C52786 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416B2B8E14EF00C52786 /* libgmp.a */; }; + 5CF441712B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */; }; 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; }; 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; }; @@ -372,11 +372,6 @@ 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = ""; }; 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = ""; }; 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; - 5CB1CE8D2B86660100963938 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CB1CE8E2B86660100963938 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CB1CE8F2B86660100963938 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a"; sourceTree = ""; }; - 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a"; sourceTree = ""; }; 5CB2084E28DA4B4800D024EC /* RTCServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCServers.swift; sourceTree = ""; }; 5CB2085428DE647400D024EC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = ""; }; @@ -431,6 +426,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 = ""; }; + 5CF441682B8E14EF00C52786 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF441692B8E14EF00C52786 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a"; sourceTree = ""; }; + 5CF4416B2B8E14EF00C52786 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a"; sourceTree = ""; }; 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = ""; }; 5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = ""; }; @@ -514,13 +514,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CB1CE932B86660100963938 /* libgmpxx.a in Frameworks */, + 5CF4416D2B8E14EF00C52786 /* libgmpxx.a in Frameworks */, + 5CF441712B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a in Frameworks */, + 5CF4416F2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CB1CE962B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a in Frameworks */, - 5CB1CE922B86660100963938 /* libgmp.a in Frameworks */, - 5CB1CE952B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a in Frameworks */, - 5CB1CE942B86660100963938 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5CF441702B8E14EF00C52786 /* libgmp.a in Frameworks */, + 5CF4416E2B8E14EF00C52786 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -582,11 +582,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CB1CE8F2B86660100963938 /* libffi.a */, - 5CB1CE8D2B86660100963938 /* libgmp.a */, - 5CB1CE8E2B86660100963938 /* libgmpxx.a */, - 5CB1CE902B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV-ghc9.6.3.a */, - 5CB1CE912B86660100963938 /* libHSsimplex-chat-5.5.5.0-7lQXtpK7ThcLvpQEItJUcV.a */, + 5CF441692B8E14EF00C52786 /* libffi.a */, + 5CF4416B2B8E14EF00C52786 /* libgmp.a */, + 5CF441682B8E14EF00C52786 /* libgmpxx.a */, + 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */, + 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */, ); path = Libraries; sourceTree = ""; @@ -1509,7 +1509,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1531,7 +1531,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.5; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1552,7 +1552,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1574,7 +1574,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.5; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1633,7 +1633,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1646,7 +1646,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.5; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1665,7 +1665,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1678,7 +1678,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.5; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1697,7 +1697,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1721,7 +1721,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.5; + MARKETING_VERSION = 5.5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1743,7 +1743,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1767,7 +1767,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.5; + MARKETING_VERSION = 5.5.6; 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 cbf2e467ed..5a556d5f82 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.5.5 -android.version_code=185 +android.version_name=5.5.6 +android.version_code=187 -desktop.version_name=5.5.5 -desktop.version_code=31 +desktop.version_name=5.5.6 +desktop.version_code=32 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 05383477d9d54a48a4f6c1e4e0039c869f719723 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 1 Mar 2024 11:26:54 +0400 Subject: [PATCH 20/64] core: wait for delivery to avoid race between connection deletion and sending service messages about entity deletion (#3849) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 45 +++++++++++++++++++++++++-------------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/cabal.project b/cabal.project index 609618858c..1eb2be851c 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 050a921fbbdf21690cab7765bf6237fdc5a419cb + tag: 294d7ec8dde9898b66188a346f6d9d17119763da source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2262c38a6d..d875a2f165 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."050a921fbbdf21690cab7765bf6237fdc5a419cb" = "0bc8x3pv3l6wjcfx06yhyydf2amaw5jjax2wcbgbxzrhqz10xf1v"; + "https://github.com/simplex-chat/simplexmq.git"."294d7ec8dde9898b66188a346f6d9d17119763da" = "06a4rzzc6ky11h6mw7ja5wb7ykq4dgvwa47wlns9wmpvbfqpmxrh"; "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 1f096b310f..c5d8bc8363 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -941,10 +941,10 @@ processChatCommand' vr = \case withChatLock "deleteChat direct" . procCmd $ do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo - when (contactReady ct && contactActive ct && notify) $ - void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) + let doSendDel = contactReady ct && contactActive ct && notify + when doSendDel $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct) - deleteAgentConnectionsAsync user contactConnIds + deleteAgentConnectionsAsync' user contactConnIds doSendDel -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct @@ -965,9 +965,10 @@ processChatCommand' vr = \case withChatLock "deleteChat group" . procCmd $ do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo - when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel + let doSendDel = memberActive membership && isOwner + when doSendDel . void $ sendGroupMessage' user gInfo members XGrpDel deleteGroupLinkIfExists user gInfo - deleteMembersConnections user members + deleteMembersConnections' user members doSendDel updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) @@ -1696,7 +1697,7 @@ processChatCommand' vr = \case (msg, _) <- sendGroupMessage user gInfo members $ XGrpMemDel mId ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) - deleteMemberConnection user m + deleteMemberConnection' user m True -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user m pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} @@ -1711,7 +1712,7 @@ processChatCommand' vr = \case -- TODO delete direct connections that were unused deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history - deleteMembersConnections user members + deleteMembersConnections' user members True withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> withUser $ \user -> @@ -5865,17 +5866,23 @@ closeFileHandle fileId files = do liftIO $ mapM_ hClose h_ `catchAll_` pure () deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () -deleteMembersConnections user members = do +deleteMembersConnections user members = deleteMembersConnections' user members False + +deleteMembersConnections' :: ChatMonad m => User -> [GroupMember] -> Bool -> m () +deleteMembersConnections' user members waitDelivery = do let memberConns = filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ mapMaybe (\GroupMember {activeConn} -> activeConn) members - deleteAgentConnectionsAsync user $ map aConnId memberConns + deleteAgentConnectionsAsync' user (map aConnId memberConns) waitDelivery void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m () -deleteMemberConnection user GroupMember {activeConn} = do +deleteMemberConnection user mem = deleteMemberConnection' user mem False + +deleteMemberConnection' :: ChatMonad m => User -> GroupMember -> Bool -> m () +deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do forM_ activeConn $ \conn -> do - deleteAgentConnectionAsync user $ aConnId conn + deleteAgentConnectionAsync' user (aConnId conn) waitDelivery withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: ChatMonad m => User -> GroupMember -> m () @@ -6248,13 +6255,19 @@ agentAcceptContactAsync user enableNtfs invId msg subMode = do pure (cmdId, connId) deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () -deleteAgentConnectionAsync user acId = - withAgent (`deleteConnectionAsync` acId) `catchChatError` (toView . CRChatError (Just user)) +deleteAgentConnectionAsync user acId = deleteAgentConnectionAsync' user acId False + +deleteAgentConnectionAsync' :: ChatMonad m => User -> ConnId -> Bool -> m () +deleteAgentConnectionAsync' user acId waitDelivery = do + withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` (toView . CRChatError (Just user)) deleteAgentConnectionsAsync :: ChatMonad m => User -> [ConnId] -> m () -deleteAgentConnectionsAsync _ [] = pure () -deleteAgentConnectionsAsync user acIds = - withAgent (`deleteConnectionsAsync` acIds) `catchChatError` (toView . CRChatError (Just user)) +deleteAgentConnectionsAsync user acIds = deleteAgentConnectionsAsync' user acIds False + +deleteAgentConnectionsAsync' :: ChatMonad m => User -> [ConnId] -> Bool -> m () +deleteAgentConnectionsAsync' _ [] _ = pure () +deleteAgentConnectionsAsync' user acIds waitDelivery = do + withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` (toView . CRChatError (Just user)) agentXFTPDeleteRcvFile :: ChatMonad m => RcvFileId -> FileTransferId -> m () agentXFTPDeleteRcvFile aFileId fileId = do From 4b7458b58fb7b6523f288b4491cc9075a4bb0567 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 1 Mar 2024 11:27:13 +0400 Subject: [PATCH 21/64] docs: PQ integration rfc (#3847) --- docs/rfcs/2024-02-28-pq-integration.md | 98 ++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/rfcs/2024-02-28-pq-integration.md diff --git a/docs/rfcs/2024-02-28-pq-integration.md b/docs/rfcs/2024-02-28-pq-integration.md new file mode 100644 index 0000000000..010f8105b7 --- /dev/null +++ b/docs/rfcs/2024-02-28-pq-integration.md @@ -0,0 +1,98 @@ +# PQ integration in chat + +## Problem + +- Group size not known when joining +- Communicate intent and current state of each conversation + +## Solution + +### Group size not known when joining + +- Add to XGrpInv GroupInvitation + - pros: easy + - cons: size can change before joining, but can ignore as it's still a good estimate + +or + +- Send before introductions + - new protocol message + - XGrpIntro :: GrpIntro -> ChatMsgEvent 'Json -- (GrpIntro is a box type with Int, for possible extension) + - or put into XGrpInfo + - XGrpInfo :: GroupProfile -> GroupStats -> ChatMsgEvent 'Json -- GroupData? + - can update profile between invitation if it happened before joining + - can later add logic to "verify" stats? + - may be over-complicated until since there "supposed" use cases are out-of-scope / not planned / not known + +- What should be default if it's not known? (e.g. admin has older version) + - On -> then off when member count reaches 20? + +### Communicate intent and current state of each conversation + +- Current state items + - RCEPQEnabled (see #3845) both for direct conversation and per member (regular event items, merged in UI) + - created when PQ changes for contact/member (e.g. received from agent on MsgMeta / SENT) + - experimental toggle is planned: it doesn't affect contacts/members with already enabled PQ + - contact enabled PQ always overrides toggle (can't downgrade) + - member enabled PQ also overrides, but can downgrade if group size increases past 20 + +- New items communicating state of e2e encryption in conversation + - should be well pronounced in UI, not merged + - should always say that conversation is e2e encrypted + - in direct chats: + - reflect actual state of PQ at the time of creation + - created during connection handshake when receiving first info about PQ in MsgMeta / some other event (TBC agent api) + - will not update if state changes (e.g. upgrades), as toggle is planned to be removed, PQ can't be downgraded, all will support soon + - flag in contacts table "e2e_info_created" to only create it once? + - should create for legacy contacts or not? + - in groups: + - reflect intent (should say "PQ will be used for members who support") based on number of members (see above) + toggle + - created at the same time as feature items? race with history may be possible, but we don't observe it? need to double check or ignore + - if based on XGrpInv GroupInvitation (first option above), can create item even before joining + - also will not update (as conversation progresses and it will scroll far up anyway) even if group size changes and it's disabled + - flag in groups table "e2e_info_created" to only create it once? and state is only reflected by RCEPQEnabled items? + - or create new such item if group size increases and PQ is off / decreases and PQ is on? + - "large group" thresholds have to different for group size increasing (e.g. 20) and decreases (e.g. 15), to avoid constant switching on the border. + +- Example texts for "e2e encryption info" chat items: + - for direct conversations: + - with PQ (and also forward a couple releases when more clients have upgraded): + ``` + Messages in this conversation are end-to-end encrypted. + Post-quantum encryption is enabled. + ``` + - no PQ (experimental toggle disabled): + ``` + -//- (e2ee) + Post-quantum encryption is not enabled. [Also possibly:] Enabling post-quantum encryption in experimental settings will enable it in this conversation if your contact supports it. + ``` + - no PQ (experimental toggle enabled): + ``` + -//- + Post-quantum encryption will be enabled when your contact upgrades. + ``` + "upgrades" / "supports it" / "starts to support it" + - can be of different color, but seems unnecessary + - created once at the start of conversation + - created once for old contacts when PQ is enabled? + - for groups: + - with PQ (small group; toggle enabled or later, as above): + ``` + -//- + Post-quantum encryption will be enabled for members who support it. + ``` + can remove qualification later when most clients have upgraded + - no PQ (large group): + ``` + -//- + Post-quantum encryption is not enabled (group is too large). + ``` + - created each time group changes between small/large, or once? + - created for old groups when experimental toggle is first turned on, and first message is received? + + +- Save PQ encryption on chat items (messages)? + - in meta for direct + group rcv + - in group_snd_item_statuses for group snd? + - display in chat item details (info) + - may be overkill if aggressive upgrade strategy is planned From db2ccaa45009e85dfca2730ec96b88de8eaba7ca Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sat, 2 Mar 2024 21:31:11 +0200 Subject: [PATCH 22/64] controller: add standalone upload limit (#3853) * controller: add standalone upload limit * use hard limit from simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 1eb2be851c..ede8f8be2b 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 294d7ec8dde9898b66188a346f6d9d17119763da + tag: 246a0d10c22ebe02af2eb34773b77cce10247459 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d875a2f165..394cf11260 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."294d7ec8dde9898b66188a346f6d9d17119763da" = "06a4rzzc6ky11h6mw7ja5wb7ykq4dgvwa47wlns9wmpvbfqpmxrh"; + "https://github.com/simplex-chat/simplexmq.git"."246a0d10c22ebe02af2eb34773b77cce10247459" = "0kx5swx1g9jimg7ks008nqzvkyx5x9irjkjwvgwrd3km5g0wnzf4"; "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 c5d8bc8363..2f6eb0c910 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -81,7 +81,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.Chat.Util (encryptFile, shuffle) -import Simplex.FileTransfer.Client.Main (maxFileSize) +import Simplex.FileTransfer.Client.Main (maxFileSize, maxFileSizeHard) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD @@ -2005,6 +2005,7 @@ processChatCommand' vr = \case APIUploadStandaloneFile userId file@CryptoFile {filePath} -> withUserId userId $ \user -> do fsFilePath <- toFSFilePath filePath fileSize <- liftIO $ CF.getFileContentsSize file {filePath = fsFilePath} + when (fileSize > toInteger maxFileSizeHard) $ throwChatError $ CEFileSize filePath (_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing pure CRSndStandaloneFileCreated {user, fileTransferMeta} APIDownloadStandaloneFile userId uri file -> withUserId userId $ \user -> do From 2155060ad05265f62e3b9f947ca01921bca73bd4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sun, 3 Mar 2024 17:51:42 +0400 Subject: [PATCH 23/64] core: groundwork for post-quantum encryption support (#3845) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 146 +++++++++++++----- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Messages/CIContent.hs | 61 ++++++++ src/Simplex/Chat/Messages/CIContent/Events.hs | 1 + src/Simplex/Chat/Migrations/M20240228_pq.hs | 18 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Store/Connections.hs | 3 +- src/Simplex/Chat/Store/Direct.hs | 12 +- src/Simplex/Chat/Store/Groups.hs | 56 ++++++- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 6 +- src/Simplex/Chat/Store/Shared.hs | 67 ++++++-- src/Simplex/Chat/Types.hs | 14 +- src/Simplex/Chat/View.hs | 2 + tests/ProtocolTests.hs | 4 +- 16 files changed, 333 insertions(+), 68 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20240228_pq.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 916cceb589..4b95a7600e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -137,6 +137,7 @@ library Simplex.Chat.Migrations.M20240214_redirect_file_id Simplex.Chat.Migrations.M20240222_app_settings Simplex.Chat.Migrations.M20240226_users_restrict + Simplex.Chat.Migrations.M20240228_pq Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2f6eb0c910..e59f465dac 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -243,6 +243,7 @@ newChatController encryptLocalFiles <- newTVarIO False tempDirectory <- newTVarIO Nothing contactMergeEnabled <- newTVarIO True + pqExperimentalEnabled <- newTVarIO False pure ChatController { firstTime, @@ -278,7 +279,8 @@ newChatController encryptLocalFiles, tempDirectory, logFilePath = logFile, - contactMergeEnabled + contactMergeEnabled, + pqExperimentalEnabled } where configServers :: DefaultAgentServers @@ -589,6 +591,9 @@ processChatCommand' vr = \case SetContactMergeEnabled onOff -> do asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) ok_ + APISetPQEnabled onOff -> do + asks pqExperimentalEnabled >>= atomically . (`writeTVar` onOff) + ok_ APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -1581,6 +1586,7 @@ processChatCommand' vr = \case -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing groupInfo <- withStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile + -- TODO [pq] create CISndGroupE2EEInfo (would affect tests) pure $ CRGroupCreated user groupInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand $ APINewGroup userId incognito gProfile @@ -1801,7 +1807,7 @@ processChatCommand' vr = \case case memberConn m of Just mConn -> do let msg = XGrpDirectInv cReq msgContent_ - (sndMsg, _) <- sendDirectMessage mConn msg $ GroupId groupId + (sndMsg, _) <- sendDirectMessage mConn pqDummyFlag msg $ GroupId groupId withStore' $ \db -> setContactGrpInvSent db ct True let ct' = ct {contactGrpInvSent = True} forM_ msgContent_ $ \mc -> do @@ -2208,7 +2214,7 @@ processChatCommand' vr = \case ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, XInfo mergedProfile') ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> - (conn, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) + (conn, pqDummyFlag, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -2299,9 +2305,18 @@ processChatCommand' vr = \case groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> m () - sendGrpInvitation user ct@Contact {localDisplayName} GroupInfo {groupId, groupProfile, membership} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + sendGrpInvitation user ct@Contact {localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile Nothing + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } (msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content @@ -2733,7 +2748,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db user groupId memId case activeConn of Just conn -> do - acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMessage conn msg $ GroupId groupId + acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMessage conn pqDummyFlag msg $ GroupId groupId _ -> throwChatError $ CEFileInternal "member connection not active" _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where @@ -2856,9 +2871,18 @@ acceptGroupJoinRequestAsync incognitoProfile = do gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let Profile {displayName} = profileToSendOnAccept user incognitoProfile True GroupMember {memberRole = userRole, memberId = userMemberId} = membership - msg = XGrpLinkInv $ GroupLinkInvitation (MemberIdRole userMemberId userRole) displayName (MemberIdRole memberId gLinkMemRole) groupProfile + msg = + XGrpLinkInv $ + GroupLinkInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberName = displayName, + invitedMember = MemberIdRole memberId gLinkMemRole, + groupProfile, + groupSize = Just currentMemCount + } subMode <- chatReadVar subscriptionMode connIds <- agentAcceptContactAsync user True invId msg subMode withStore $ \db -> do @@ -3313,7 +3337,7 @@ processAgentMsgSndFile _corrId aFileId msg = useMember _ = Nothing sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m () sendToMember (rfd, (conn, sft)) = - void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId + void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn pqDummyFlag msg' $ GroupId groupId _ -> pure () _ -> pure () -- TODO error? SFERR e @@ -3490,6 +3514,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do + -- TODO [pq] same for other direct connection events; + -- TODO use ct', conn' downstream; + -- TODO pqAgentDummy - would be returned in agent event + let pqAgentDummy = False + (_ct', _conn') <- updateContactPQ ct conn pqAgentDummy checkIntegrityCreateItem (CDDirectRcv ct) msgMeta cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do @@ -3674,8 +3703,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> m () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile groupLinkId + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + groupLinkId = groupLinkId, + groupSize = Just currentMemCount + } (_msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv -- we could link chat item with sent group invitation message (_msg) createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing @@ -3729,6 +3767,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberCategory m of GCHostMember -> do toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} + -- TODO [pq] create CIRcvGroupE2EEInfo (would affect tests) createGroupFeatureItems gInfo m let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m @@ -3750,7 +3789,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo profileToSend = profileToSendOnAccept user profileMode True - void $ sendDirectMessage conn (XGrpLinkMem profileToSend) (GroupId groupId) + void $ sendDirectMessage conn pqDummyFlag (XGrpLinkMem profileToSend) (GroupId groupId) sendIntroductions members = do intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m shuffledIntros <- liftIO $ shuffleIntros intros @@ -3776,7 +3815,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMessage conn (memberIntro $ reMember intro) (GroupId groupId) + void $ sendDirectMessage conn pqDummyFlag (memberIntro $ reMember intro) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent sendHistory = when (isCompatibleRange (memberChatVRange' m) batchSendVRange) $ do @@ -3875,12 +3914,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ (invitedByGroupMemberId membership) $ \hostId -> do host <- withStore $ \db -> getGroupMember db user groupId hostId forM_ (memberConn host) $ \hostConn -> - void $ sendDirectMessage hostConn (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMessage hostConn pqDummyFlag (XGrpMemCon memberId) (GroupId groupId) GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do im <- withStore $ \db -> getGroupMember db user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> - void $ sendDirectMessage imConn (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMessage imConn pqDummyFlag (XGrpMemCon memberId) (GroupId groupId) _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta @@ -4113,7 +4152,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case activeConn of Just gMemberConn -> do sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectMessage gMemberConn (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) $ GroupId groupId + void $ sendDirectMessage gMemberConn pqDummyFlag (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) $ GroupId groupId _ -> throwChatError $ CECommandError "no GroupMember activeConn" _ -> throwChatError $ CECommandError "no grpMemberId" _ -> throwChatError $ CECommandError "unexpected cmdFunction" @@ -4357,7 +4396,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () - sendProbe probe = void $ sendDirectMessage conn (XInfoProbe probe) (GroupId groupId) + sendProbe probe = void $ sendDirectMessage conn pqDummyFlag (XInfoProbe probe) (GroupId groupId) sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> m () sendProbeHashes cgms probe probeId = @@ -4371,7 +4410,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendProbeHash (COMGroupMember GroupMember {activeConn = Nothing}) = pure () sendProbeHash cgm@(COMGroupMember m@GroupMember {groupId, activeConn = Just conn}) = when (memberCurrent m) $ do - void $ sendDirectMessage conn (XInfoProbeCheck probeHash) (GroupId groupId) + void $ sendDirectMessage conn pqDummyFlag (XInfoProbeCheck probeHash) (GroupId groupId) withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> m () @@ -5056,7 +5095,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case cgm2 of COMContact c2@Contact {profile = p2} | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do - void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + void $ sendDirectMessage conn pqDummyFlag (XInfoProbeOk probe) (GroupId groupId) COMContact <$$> associateMemberAndContact c2 m1 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing @@ -5257,6 +5296,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfo p -> do let contactUsed = connDirect activeConn ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed + -- TODO [pq] create CIRcvDirectE2EEInfo here? toView $ CRContactConnecting user ct pure conn' XGrpLinkInv glInv -> do @@ -5312,7 +5352,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do hostConn <- withStore $ \db -> getConnectionById db user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} - void $ sendDirectMessage hostConn msg (GroupId groupId) + void $ sendDirectMessage hostConn pqDummyFlag msg (GroupId groupId) withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () @@ -5667,6 +5707,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () + -- TODO [pq] track rcv and snd flags separately + updateContactPQ :: Contact -> Connection -> PQFlag -> m (Contact, Connection) + updateContactPQ ct conn@Connection {connId, pqEnabled} pqEnabled' = + flip catchChatError (const $ pure (ct, conn)) $ case (pqEnabled, pqEnabled') of + (Nothing, False) -> pure (ct, conn) + (Nothing, True) -> updatePQ $ CIRcvDirectE2EEInfo (E2EEInfo pqEnabled') + (Just b, b') + | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPQEnabled pqEnabled') + | otherwise -> pure (ct, conn) + where + updatePQ ciContent = do + withStore' $ \db -> updateConnPQEnabled db connId pqEnabled' + let conn' = conn {pqEnabled = Just pqEnabled'} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + createInternalChatItem user (CDDirectRcv ct') ciContent Nothing + toView $ CRContactPQEnabled user ct' pqEnabled' + pure (ct', conn') + metaBrokerTs :: MsgMeta -> UTCTime metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs @@ -5705,7 +5763,7 @@ sendDirectFileInline ct ft sharedMsgId = do sendMemberFileInline :: ChatMonad m => GroupMember -> Connection -> FileTransferMeta -> SharedMsgId -> m () sendMemberFileInline m@GroupMember {groupId} conn ft sharedMsgId = do - msgDeliveryId <- sendFileInline_ ft sharedMsgId $ \msg -> sendDirectMessage conn msg $ GroupId groupId + msgDeliveryId <- sendFileInline_ ft sharedMsgId $ \msg -> sendDirectMessage conn pqDummyFlag msg $ GroupId groupId withStore' $ \db -> updateSndGroupFTDelivery db m conn ft msgDeliveryId sendFileInline_ :: ChatMonad m => FileTransferMeta -> SharedMsgId -> (ChatMsgEvent 'Binary -> m (SndMessage, Int64)) -> m Int64 @@ -5855,7 +5913,7 @@ cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, age when sendCancel $ case fileInline of Just _ -> do (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db user connId - void . sendDirectMessage conn (BFileChunk sharedMsgId FileChunkCancel) $ ConnectionId connId + void . sendDirectMessage conn pqDummyFlag (BFileChunk sharedMsgId FileChunkCancel) $ ConnectionId connId _ -> withAgent $ \a -> void . sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel pure fileConnId fileConnId = if isNothing fileInline then Just acId else Nothing @@ -5896,7 +5954,9 @@ deleteOrUpdateMemberRecord user@User {userId} member = sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) sendDirectContactMessage ct chatMsgEvent = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct - sendDirectMessage conn chatMsgEvent (ConnectionId connId) + -- TODO [pq] look up pqExperimentalEnabled on every send to pass flag to agent apis + pq <- readTVarIO =<< asks pqExperimentalEnabled + sendDirectMessage conn pq chatMsgEvent (ConnectionId connId) contactSendConn_ :: Contact -> Either ChatError Connection contactSendConn_ ct@Contact {activeConn} = case activeConn of @@ -5909,11 +5969,11 @@ contactSendConn_ ct@Contact {activeConn} = case activeConn of where err = Left . ChatError -sendDirectMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64) -sendDirectMessage conn chatMsgEvent connOrGroupId = do +sendDirectMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> PQFlag -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64) +sendDirectMessage conn pq chatMsgEvent connOrGroupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId - (msg,) <$> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId + (msg,) <$> deliverMessage conn pq (toCMEventTag chatMsgEvent) msgBody msgId createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = @@ -5957,35 +6017,44 @@ directMessage chatMsgEvent = do ECMEncoded encodedBody -> pure encodedBody ECMLarge -> throwChatError $ CEException "large message" -deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 -deliverMessage conn cmEventTag msgBody msgId = do +deliverMessage :: ChatMonad m => Connection -> PQFlag -> CMEventTag e -> MsgBody -> MessageId -> m Int64 +deliverMessage conn pq cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} - deliverMessage' conn msgFlags msgBody msgId + deliverMessage' conn pq msgFlags msgBody msgId -deliverMessage' :: ChatMonad m => Connection -> MsgFlags -> MsgBody -> MessageId -> m Int64 -deliverMessage' conn msgFlags msgBody msgId = - deliverMessages [(conn, msgFlags, msgBody, msgId)] >>= \case +deliverMessage' :: ChatMonad m => Connection -> PQFlag -> MsgFlags -> MsgBody -> MessageId -> m Int64 +deliverMessage' conn pq msgFlags msgBody msgId = + deliverMessages [(conn, pq, msgFlags, msgBody, msgId)] >>= \case [r] -> liftEither r rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) -type MsgReq = (Connection, MsgFlags, MsgBody, MessageId) +type MsgReq = (Connection, PQFlag, MsgFlags, MsgBody, MessageId) + +-- TODO [pq] remove, replace in all places with actual flag / pqOff in groups +pqDummyFlag :: PQFlag +pqDummyFlag = False + +-- TODO remove in 5.7 (used for groups) +pqOff :: PQFlag +pqOff = False deliverMessages :: ChatMonad' m => [MsgReq] -> m [Either ChatError Int64] deliverMessages = deliverMessagesB . map Right deliverMessagesB :: ChatMonad' m => [Either ChatError MsgReq] -> m [Either ChatError Int64] deliverMessagesB msgReqs = do + -- TODO [pq] pass _pqFlag to sendMessagesB sent <- zipWith prepareBatch msgReqs <$> withAgent' (`sendMessagesB` map toAgent msgReqs) withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent where toAgent = \case - Right (conn, msgFlags, msgBody, _msgId) -> Right (aConnId conn, msgFlags, msgBody) + Right (conn, _pqFlag, msgFlags, msgBody, _msgId) -> Right (aConnId conn, msgFlags, msgBody) Left _ce -> Left (AP.INTERNAL "ChatError, skip") -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing createDelivery :: DB.Connection -> (MsgReq, AgentMsgId) -> IO (Either ChatError Int64) - createDelivery db ((Connection {connId}, _, _, msgId), agentMsgId) = + 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]) @@ -6016,7 +6085,7 @@ sendGroupMessage' user GroupInfo {groupId} members chatMsgEvent = do recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} (toSend, pending) = foldr addMember ([], []) recipientMembers - msgReqs = map (\(_, conn) -> (conn, msgFlags, msgBody, msgId)) toSend + msgReqs = map (\(_, conn) -> (conn, pqOff, msgFlags, msgBody, msgId)) toSend delivered <- deliverMessages msgReqs let errors = lefts delivered unless (null errors) $ toView $ CRChatErrors (Just user) errors @@ -6075,7 +6144,7 @@ sendGroupMemberMessage user m@GroupMember {groupMemberId} chatMsgEvent groupId i where messageMember :: SndMessage -> m () messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction chatMsgEvent [m] m) $ \case - MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver + MSASend conn -> deliverMessage conn pqDummyFlag (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () @@ -6086,7 +6155,7 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn processPendingMessage pgm `catchChatError` (toView . CRChatError (Just user)) where processPendingMessage PendingGroupMessage {msgId, cmEventTag = ACMEventTag _ tag, msgBody, introId_} = do - void $ deliverMessage conn tag msgBody msgId + void $ deliverMessage conn pqDummyFlag tag msgBody msgId withStore' $ \db -> deletePendingGroupMessage db groupMemberId msgId case tag of XGrpMemFwd_ -> case introId_ of @@ -6120,7 +6189,7 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMessage fmConn (XGrpMemCon amMemId) (GroupId groupId) + void $ sendDirectMessage fmConn pqDummyFlag (XGrpMemCon amMemId) (GroupId groupId) throwError e _ -> throwError e pure (am', conn', msg) @@ -6136,7 +6205,7 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId if sameMemberId refMemberId am then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMessage fmConn (XGrpMemCon amMemberId) (GroupId groupId) + void $ sendDirectMessage fmConn pqDummyFlag (XGrpMemCon amMemberId) (GroupId groupId) else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" throwError e _ -> throwError e @@ -6533,6 +6602,7 @@ chatCommandP = "/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), + "/_pq " *> (APISetPQEnabled <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c482825e18..4b6c45002b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -206,7 +206,8 @@ data ChatController = ChatController encryptLocalFiles :: TVar Bool, tempDirectory :: TVar (Maybe FilePath), logFilePath :: Maybe FilePath, - contactMergeEnabled :: TVar Bool + contactMergeEnabled :: TVar Bool, + pqExperimentalEnabled :: TVar Bool -- TODO remove in 5.7 } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase @@ -243,6 +244,7 @@ data ChatCommand | SetRemoteHostsFolder FilePath | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool + | APISetPQEnabled Bool | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -697,6 +699,7 @@ data ChatResponse | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} + | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: Bool} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index a79eb0d952..f156d62581 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -139,13 +139,23 @@ data CIContent (d :: MsgDirection) where CISndModerated :: CIContent 'MDSnd CIRcvModerated :: CIContent 'MDRcv CIRcvBlocked :: CIContent 'MDRcv + CISndDirectE2EEInfo :: E2EEInfo -> CIContent 'MDSnd + CIRcvDirectE2EEInfo :: E2EEInfo -> CIContent 'MDRcv + CISndGroupE2EEInfo :: E2EEInfo -> CIContent 'MDSnd -- when new group is created + CIRcvGroupE2EEInfo :: E2EEInfo -> CIContent 'MDRcv -- when enabled with some member CIInvalidJSON :: Text -> CIContent d -- this is also used for logical database errors, e.g. SEBadChatItem + -- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! ^ Nested sum types also have to use different encodings for database and API -- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent deriving instance Show (CIContent d) +data E2EEInfo = E2EEInfo + { pqEnabled :: Bool + } + deriving (Eq, Show) + ciMsgContent :: CIContent d -> Maybe MsgContent ciMsgContent = \case CISndMsgContent mc -> Just mc @@ -195,6 +205,8 @@ ciRequiresAttention content = case msgDirection @d of CIRcvGroupFeatureRejected _ -> True CIRcvModerated -> True CIRcvBlocked -> False + CIRcvDirectE2EEInfo _ -> False + CIRcvGroupE2EEInfo _ -> False CIInvalidJSON _ -> False newtype DBMsgErrorType = DBME MsgErrorType @@ -250,8 +262,24 @@ ciContentToText = \case CISndModerated -> ciModeratedText CIRcvModerated -> ciModeratedText CIRcvBlocked -> "blocked" + CISndDirectE2EEInfo e2eeInfo -> directE2EEInfoToText e2eeInfo + CIRcvDirectE2EEInfo e2eeInfo -> directE2EEInfoToText e2eeInfo + CISndGroupE2EEInfo e2eeInfo -> groupE2EEInfoToText e2eeInfo + CIRcvGroupE2EEInfo e2eeInfo -> groupE2EEInfoToText e2eeInfo CIInvalidJSON _ -> "invalid content JSON" +directE2EEInfoToText :: E2EEInfo -> Text +directE2EEInfoToText E2EEInfo {pqEnabled} + | pqEnabled = "This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery." + | otherwise = e2eeInfoNoPQText + +groupE2EEInfoToText :: E2EEInfo -> Text +groupE2EEInfoToText _e2eeInfo = e2eeInfoNoPQText + +e2eeInfoNoPQText :: Text +e2eeInfoNoPQText = + "This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery." + ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role = "invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role) @@ -295,6 +323,9 @@ rcvConnEventToText = \case SPCompleted -> "changed address for you" RCERatchetSync syncStatus -> ratchetSyncStatusToText syncStatus RCEVerificationCodeReset -> "security code changed" + RCEPQEnabled enabled + | enabled -> "post-quantum encryption enabled" + | otherwise -> "post-quantum encryption disabled" ratchetSyncStatusToText :: RatchetSyncState -> Text ratchetSyncStatusToText = \case @@ -382,6 +413,10 @@ data JSONCIContent | JCISndModerated | JCIRcvModerated | JCIRcvBlocked + | JCISndDirectE2EEInfo {e2eeInfo :: E2EEInfo} + | JCIRcvDirectE2EEInfo {e2eeInfo :: E2EEInfo} + | JCISndGroupE2EEInfo {e2eeInfo :: E2EEInfo} + | JCIRcvGroupE2EEInfo {e2eeInfo :: E2EEInfo} | JCIInvalidJSON {direction :: MsgDirection, json :: Text} jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent @@ -412,6 +447,10 @@ jsonCIContent = \case CISndModerated -> JCISndModerated CIRcvModerated -> JCIRcvModerated CIRcvBlocked -> JCIRcvBlocked + CISndDirectE2EEInfo e2eeInfo -> JCISndDirectE2EEInfo e2eeInfo + CIRcvDirectE2EEInfo e2eeInfo -> JCIRcvDirectE2EEInfo e2eeInfo + CISndGroupE2EEInfo e2eeInfo -> JCISndGroupE2EEInfo e2eeInfo + CIRcvGroupE2EEInfo e2eeInfo -> JCIRcvGroupE2EEInfo e2eeInfo CIInvalidJSON json -> JCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentJSON :: JSONCIContent -> ACIContent @@ -442,6 +481,10 @@ aciContentJSON = \case JCISndModerated -> ACIContent SMDSnd CISndModerated JCIRcvModerated -> ACIContent SMDRcv CIRcvModerated JCIRcvBlocked -> ACIContent SMDRcv CIRcvBlocked + JCISndDirectE2EEInfo {e2eeInfo} -> ACIContent SMDSnd $ CISndDirectE2EEInfo e2eeInfo + JCIRcvDirectE2EEInfo {e2eeInfo} -> ACIContent SMDRcv $ CIRcvDirectE2EEInfo e2eeInfo + JCISndGroupE2EEInfo {e2eeInfo} -> ACIContent SMDSnd $ CISndGroupE2EEInfo e2eeInfo + JCIRcvGroupE2EEInfo {e2eeInfo} -> ACIContent SMDRcv $ CIRcvGroupE2EEInfo e2eeInfo JCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -473,6 +516,10 @@ data DBJSONCIContent | DBJCISndModerated | DBJCIRcvModerated | DBJCIRcvBlocked + | DBJCISndDirectE2EEInfo {e2eeInfo :: E2EEInfo} + | DBJCIRcvDirectE2EEInfo {e2eeInfo :: E2EEInfo} + | DBJCISndGroupE2EEInfo {e2eeInfo :: E2EEInfo} + | DBJCIRcvGroupE2EEInfo {e2eeInfo :: E2EEInfo} | DBJCIInvalidJSON {direction :: MsgDirection, json :: Text} dbJsonCIContent :: forall d. MsgDirectionI d => CIContent d -> DBJSONCIContent @@ -503,6 +550,10 @@ dbJsonCIContent = \case CISndModerated -> DBJCISndModerated CIRcvModerated -> DBJCIRcvModerated CIRcvBlocked -> DBJCIRcvBlocked + CISndDirectE2EEInfo e2eeInfo -> DBJCISndDirectE2EEInfo e2eeInfo + CIRcvDirectE2EEInfo e2eeInfo -> DBJCIRcvDirectE2EEInfo e2eeInfo + CISndGroupE2EEInfo e2eeInfo -> DBJCISndGroupE2EEInfo e2eeInfo + CIRcvGroupE2EEInfo e2eeInfo -> DBJCIRcvGroupE2EEInfo e2eeInfo CIInvalidJSON json -> DBJCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentDBJSON :: DBJSONCIContent -> ACIContent @@ -533,6 +584,10 @@ aciContentDBJSON = \case DBJCISndModerated -> ACIContent SMDSnd CISndModerated DBJCIRcvModerated -> ACIContent SMDRcv CIRcvModerated DBJCIRcvBlocked -> ACIContent SMDRcv CIRcvBlocked + DBJCISndDirectE2EEInfo e2eeInfo -> ACIContent SMDSnd $ CISndDirectE2EEInfo e2eeInfo + DBJCIRcvDirectE2EEInfo e2eeInfo -> ACIContent SMDRcv $ CIRcvDirectE2EEInfo e2eeInfo + DBJCISndGroupE2EEInfo e2eeInfo -> ACIContent SMDSnd $ CISndGroupE2EEInfo e2eeInfo + DBJCIRcvGroupE2EEInfo e2eeInfo -> ACIContent SMDRcv $ CIRcvGroupE2EEInfo e2eeInfo DBJCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -558,6 +613,8 @@ ciCallInfoText status duration = case status of CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" +$(JQ.deriveJSON defaultJSON ''E2EEInfo) + $(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError) $(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus) @@ -626,4 +683,8 @@ toCIContentTag ciContent = case ciContent of CISndModerated -> "sndModerated" CIRcvModerated -> "rcvModerated" CIRcvBlocked -> "rcvBlocked" + CISndDirectE2EEInfo _ -> "sndDirectE2EEInfo" + CIRcvDirectE2EEInfo _ -> "rcvDirectE2EEInfo" + CISndGroupE2EEInfo _ -> "sndGroupE2EEInfo" + CIRcvGroupE2EEInfo _ -> "rcvGroupE2EEInfo" CIInvalidJSON _ -> "invalidJSON" diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 05417a2e14..f0ff321118 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -42,6 +42,7 @@ data RcvConnEvent = RCESwitchQueue {phase :: SwitchPhase} | RCERatchetSync {syncStatus :: RatchetSyncState} | RCEVerificationCodeReset + | RCEPQEnabled {enabled :: Bool} deriving (Show) data SndConnEvent diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Migrations/M20240228_pq.hs new file mode 100644 index 0000000000..a72d8915bb --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240228_pq.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240228_pq where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240228_pq :: Query +m20240228_pq = + [sql| +ALTER TABLE connections ADD COLUMN pq_enabled INTEGER; +|] + +down_m20240228_pq :: Query +down_m20240228_pq = + [sql| +ALTER TABLE connections DROP COLUMN pq_enabled; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 98b9cfcc12..2ebfa87623 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -277,6 +277,7 @@ CREATE TABLE connections( peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, contact_conn_initiated INTEGER NOT NULL DEFAULT 0, + pq_enabled INTEGER, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index fdc3703219..0e4ea5c286 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -60,7 +60,8 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + created_at, security_code, security_code_verified_at, pq_enabled, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index b844317593..2ba940d007 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -173,7 +173,7 @@ getContactByConnReqHash db user@User {userId} cReqHash = 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -569,7 +569,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -734,7 +734,7 @@ getContact_ db user@User {userId} contactId deleted = 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -787,7 +787,8 @@ getContactConnections db userId Contact {contactId} = db [sql| SELECT 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id @@ -804,7 +805,8 @@ getConnectionById db User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + created_at, security_code, security_code_verified_at, pq_enabled, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d82cc7570f..8ccec82ddf 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -193,7 +193,8 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -278,7 +279,8 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = 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.member_restriction, 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, 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -685,7 +687,8 @@ groupMemberQuery = 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.member_restriction, 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, 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -1289,7 +1292,8 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = 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.member_restriction, 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, 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id @@ -1909,7 +1913,27 @@ createMemberContact :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db - let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + let ctConn = + Connection + { connId, + agentConnId = AgentConnId acId, + peerChatVRange, + connType = ConnContact, + contactConnInitiated = True, + entityId = Just contactId, + viaContact = Nothing, + viaUserContactLink = Nothing, + viaGroupLink = False, + groupLinkId = Nothing, + customUserProfileId, + connLevel, + connStatus = ConnNew, + localAlias = "", + createdAt = currentTs, + connectionCode = Nothing, + pqEnabled = Nothing, + authErrCounter = 0 + } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} @@ -2018,7 +2042,27 @@ createMemberContactConn_ ) connId <- insertedRowId db setCommandConnId db user cmdId connId - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure + Connection + { connId, + agentConnId = AgentConnId acId, + peerChatVRange, + connType = ConnContact, + contactConnInitiated = False, + entityId = Just contactId, + viaContact = Nothing, + viaUserContactLink = Nothing, + viaGroupLink = False, + groupLinkId = Nothing, + customUserProfileId, + connLevel, + connStatus = ConnJoined, + localAlias = "", + createdAt = currentTs, + connectionCode = Nothing, + pqEnabled = Nothing, + authErrCounter = 0 + } updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember updateMemberProfile db user@User {userId} m p' diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 6d3a7a9a4f..d8bdbd6fd3 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -101,6 +101,7 @@ import Simplex.Chat.Migrations.M20240122_indexes import Simplex.Chat.Migrations.M20240214_redirect_file_id import Simplex.Chat.Migrations.M20240222_app_settings import Simplex.Chat.Migrations.M20240226_users_restrict +import Simplex.Chat.Migrations.M20240228_pq import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -201,7 +202,8 @@ schemaMigrations = ("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes), ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id), ("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings), - ("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict) + ("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict), + ("20240228_pq", m20240228_pq, Just down_m20240228_pq) ] -- | 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 eceb19ba34..ca1240d307 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -337,7 +337,8 @@ getUserAddressConnections db User {userId} = do db [sql| SELECT 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -352,7 +353,8 @@ getUserContactLinks db User {userId} = db [sql| SELECT 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.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.pq_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index bc1232f427..65bf359cd4 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -148,16 +148,32 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Maybe Bool, Int, Version, Version) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Bool, Maybe Int, Maybe Version, Maybe Version) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = - let entityId = entityId_ connType - connectionCode = SecurityCode <$> code_ <*> verifiedAt_ - peerChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias, entityId, connectionCode, authErrCounter, createdAt} +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqEnabled, authErrCounter, minVer, maxVer)) = + Connection + { connId, + agentConnId = AgentConnId acId, + peerChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer, + connLevel, + viaContact, + viaUserContactLink, + viaGroupLink, + groupLinkId, + customUserProfileId, + connStatus, + connType, + contactConnInitiated, + localAlias, + entityId = entityId_ connType, + connectionCode = SecurityCode <$> code_ <*> verifiedAt_, + pqEnabled, + authErrCounter, + createdAt + } where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -167,8 +183,8 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, pqEnabled_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqEnabled_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection @@ -190,7 +206,27 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange :. (minV, maxV, subMode == SMOnlyCreate) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, contactConnInitiated = False, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure + Connection + { connId, + agentConnId = AgentConnId acId, + peerChatVRange = JVersionRange peerChatVRange, + connType, + contactConnInitiated = False, + entityId, + viaContact, + viaUserContactLink, + viaGroupLink, + groupLinkId = Nothing, + customUserProfileId, + connLevel, + connStatus = ConnNew, + localAlias = "", + createdAt = currentTs, + connectionCode = Nothing, + pqEnabled = Nothing, + authErrCounter = 0 + } where ent ct = if connType == ct then entityId else Nothing @@ -205,6 +241,17 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag (displayName, fullName, image, userId, Just True, createdAt, createdAt) insertedRowId db +updateConnPQEnabled :: DB.Connection -> Int64 -> Bool -> IO () +updateConnPQEnabled db connId pqEnabled = + DB.execute + db + [sql| + UPDATE connections + SET pq_enabled = ? + WHERE connection_id = ? + |] + (pqEnabled, connId) + setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () setPeerChatVRange db connId (VersionRange minVer maxVer) = DB.execute diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 0a35a83edd..6376d50c26 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -211,6 +211,11 @@ contactDeleted Contact {contactStatus} = contactStatus == CSDeleted contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode =<< activeConn +contactPQEnabled :: Contact -> Bool +contactPQEnabled Contact {activeConn} = case activeConn of + Just Connection {pqEnabled} -> pqEnabled == Just True + Nothing -> False + data ContactStatus = CSActive | CSDeleted -- contact deleted by contact @@ -563,7 +568,8 @@ data GroupInvitation = GroupInvitation invitedMember :: MemberIdRole, connRequest :: ConnReqInvitation, groupProfile :: GroupProfile, - groupLinkId :: Maybe GroupLinkId + groupLinkId :: Maybe GroupLinkId, + groupSize :: Maybe Int } deriving (Eq, Show) @@ -571,7 +577,8 @@ data GroupLinkInvitation = GroupLinkInvitation { fromMember :: MemberIdRole, fromMemberName :: ContactName, invitedMember :: MemberIdRole, - groupProfile :: GroupProfile + groupProfile :: GroupProfile, + groupSize :: Maybe Int } deriving (Eq, Show) @@ -1277,6 +1284,8 @@ type ConnReqInvitation = ConnectionRequestUri 'CMInvitation type ConnReqContact = ConnectionRequestUri 'CMContact +type PQFlag = Bool + data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, @@ -1293,6 +1302,7 @@ data Connection = Connection localAlias :: Text, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID connectionCode :: Maybe SecurityCode, + pqEnabled :: Maybe PQFlag, authErrCounter :: Int, createdAt :: UTCTime } diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 667613ba6a..de249cec4a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -340,6 +340,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] CRRemoteCtrlStopped {} -> ["remote controller stopped"] + CRContactPQEnabled u c pqOn -> ttyUser u [ttyContact' c <> ": post-quantum encryption " <> (if pqOn then "enabled" else "disabled")] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = @@ -1173,6 +1174,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] + <> ["post-quantum encryption enabled" | contactPQEnabled ct] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index c076ebecfc..822b079e9e 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -229,10 +229,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ==# XContact testProfile Nothing it "x.grp.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") From eebf014ff7ca4cc513e65f109b4741a6a0ff5329 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 5 Mar 2024 20:27:00 +0400 Subject: [PATCH 24/64] core (pq): integrate agent api, create e2ee info items (#3859) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 434 ++++++++++-------- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Messages/CIContent.hs | 3 + src/Simplex/Chat/Messages/CIContent/Events.hs | 1 + src/Simplex/Chat/Migrations/M20240228_pq.hs | 8 +- src/Simplex/Chat/Migrations/chat_schema.sql | 4 +- src/Simplex/Chat/Protocol.hs | 38 +- src/Simplex/Chat/Store/Connections.hs | 11 +- src/Simplex/Chat/Store/Direct.hs | 50 +- src/Simplex/Chat/Store/Files.hs | 11 +- src/Simplex/Chat/Store/Groups.hs | 81 ++-- src/Simplex/Chat/Store/Messages.hs | 17 +- src/Simplex/Chat/Store/Profiles.hs | 6 +- src/Simplex/Chat/Store/Shared.hs | 64 ++- src/Simplex/Chat/Types.hs | 89 +++- src/Simplex/Chat/View.hs | 4 +- tests/ChatClient.hs | 42 +- tests/ChatTests/ChatList.hs | 18 +- tests/ChatTests/Direct.hs | 37 +- tests/ChatTests/Groups.hs | 31 +- tests/ChatTests/Profiles.hs | 26 +- tests/ChatTests/Utils.hs | 39 +- tests/ProtocolTests.hs | 27 +- 25 files changed, 620 insertions(+), 430 deletions(-) diff --git a/cabal.project b/cabal.project index ede8f8be2b..80f94af705 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 246a0d10c22ebe02af2eb34773b77cce10247459 + tag: c280f942ba3d96d48db30ccc3a23d51a7b5fed41 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 394cf11260..146b45cfcf 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."246a0d10c22ebe02af2eb34773b77cce10247459" = "0kx5swx1g9jimg7ks008nqzvkyx5x9irjkjwvgwrd3km5g0wnzf4"; + "https://github.com/simplex-chat/simplexmq.git"."c280f942ba3d96d48db30ccc3a23d51a7b5fed41" = "04aq4mv2q3v5yfbnj9ajylpjvq7hl1hgj5jiwg90rkc6nl3a7dvz"; "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 e59f465dac..f96d3e8a18 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -100,6 +100,7 @@ import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) @@ -367,7 +368,7 @@ subscribeUsers onlyNeeded users = do subscribe vr us subscribe vr us' where - subscribe :: VersionRange -> [User] -> m () + subscribe :: VersionRangeChat -> [User] -> m () subscribe vr = mapM_ $ runExceptT . subscribeUserConnections vr onlyNeeded Agent.subscribeConnections startFilesToReceive :: forall m. ChatMonad' m => [User] -> m () @@ -448,7 +449,7 @@ processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand cmd = chatVersionRange >>= (`processChatCommand'` cmd) {-# INLINE processChatCommand #-} -processChatCommand' :: forall m. ChatMonad m => VersionRange -> ChatCommand -> m ChatResponse +processChatCommand' :: forall m. ChatMonad m => VersionRangeChat -> ChatCommand -> m ChatResponse processChatCommand' vr = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser NewUser {profile, sameServers, pastTimestamp} -> do @@ -664,7 +665,7 @@ processChatCommand' vr = \case (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer ct timed_ <- sndContactCITimed live ct itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ - (msg, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) + (msg, _) <- sendDirectContactMessage user ct (XMsgNew msgContainer) ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) @@ -763,7 +764,7 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -816,7 +817,7 @@ processChatCommand' vr = \case (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertDirectAllowed user MDSnd ct XMsgDel_ - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgDel itemSharedMId Nothing) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgDel itemSharedMId Nothing) if featureAllowed SCFFullDelete forUser ct then deleteDirectCI user ct ci True False else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime @@ -856,7 +857,7 @@ processChatCommand' vr = \case throwChatError (CECommandError "reaction not allowed - chat item has no content") rs <- withStore' $ \db -> getDirectReactions db ct itemSharedMId True checkReactionAllowed rs - (SndMessage {msgId}, _) <- sendDirectContactMessage ct $ XMsgReact itemSharedMId Nothing reaction add + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct $ XMsgReact itemSharedMId Nothing reaction add createdAt <- liftIO getCurrentTime reactions <- withStore' $ \db -> do setDirectReaction db ct itemSharedMId True reaction add msgId createdAt @@ -947,7 +948,7 @@ processChatCommand' vr = \case cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo let doSendDel = contactReady ct && contactActive ct && notify - when doSendDel $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) + when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct) deleteAgentConnectionsAsync' user contactConnIds doSendDel -- functions below are called in separate transactions to prevent crashes on android @@ -1057,7 +1058,7 @@ processChatCommand' vr = \case 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) + (msg, _) <- sendDirectContactMessage user ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls @@ -1084,7 +1085,7 @@ processChatCommand' vr = \case offer = CallOffer {callType, rtcSession, callDhPubKey} callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallOffer callId offer) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallOffer callId offer) withStore' $ \db -> updateDirectChatItemsRead db user contactId $ Just (chatItemId, chatItemId) updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} @@ -1095,28 +1096,28 @@ processChatCommand' vr = \case CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey} aciContent = ACIContent SMDSnd $ CISndCall CISCallNegotiated 0 - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallAnswer callId CallAnswer {rtcSession}) updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallExtraInfo contactId rtcExtraInfo -> -- any call party - withCurrentCall contactId $ \_ ct call@Call {callId, callState} -> case callState of + withCurrentCall contactId $ \user ct call@Call {callId, callState} -> case callState of CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do -- TODO update the list of ice servers in localCallSession - void . sendDirectContactMessage ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} + void . sendDirectContactMessage user ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} let callState' = CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} pure $ Just call {callState = callState'} CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in localCallSession - void . sendDirectContactMessage ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} + void . sendDirectContactMessage user ct $ XCallExtra callId CallExtraInfo {rtcExtraInfo} let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APIEndCall contactId -> -- any call party withCurrentCall contactId $ \user ct call@Call {callId} -> do - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallEnd callId) updateCallItemStatus user ct call WCSDisconnected $ Just msgId pure Nothing APIGetCallInvitations -> withUser $ \_ -> do @@ -1286,9 +1287,10 @@ processChatCommand' vr = \case _ -> throwChatError CEGroupMemberNotActive APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do ct <- withStore $ \db -> getContact db user contactId - case contactConnId ct of - Just connId -> do - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force + case contactConn ct of + Just conn -> do + enablePQ <- contactPQEnc conn + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) enablePQ force createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct @@ -1296,7 +1298,7 @@ processChatCommand' vr = \case (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId case memberConnId m of Just connId -> do - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId CR.PQEncOff force createInternalChatItem user (CDGroupSnd g) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m) Nothing pure $ CRGroupMemberRatchetSyncStarted user g m cStats _ -> throwChatError CEGroupMemberNotActive @@ -1385,8 +1387,9 @@ processChatCommand' vr = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing (CR.IKNoPQ $ CR.PQEncryption enablePQ) subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode enablePQ pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> processChatCommand $ APIAddContact userId incognito @@ -1414,8 +1417,9 @@ processChatCommand' vr = \case incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing False dm <- directMessage $ XInfo profileToSend - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm (CR.PQEncryption enablePQ) subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode enablePQ pure $ CRSentConfirmation user conn APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq @@ -1449,7 +1453,7 @@ processChatCommand' vr = \case processChatCommand $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing subMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing CR.IKPQOff subMode withStore $ \db -> createUserContactLink db user connId cReq subMode pure $ CRUserContactLinkCreated user cReq CreateMyAddress -> withUser $ \User {userId} -> @@ -1552,7 +1556,7 @@ processChatCommand' vr = \case sendAndCount user ll (s, f) ct = (sendToContact user ct $> (s + 1, f)) `catchChatError` \e -> when (ll <= CLLInfo) (toView $ CRChatError (Just user) e) $> (s, f + 1) sendToContact user ct = do - (sndMsg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) + (sndMsg, _) <- sendDirectContactMessage user ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) void $ saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withStore $ \db -> getContactIdByName db user cName @@ -1586,7 +1590,7 @@ processChatCommand' vr = \case -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing groupInfo <- withStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile - -- TODO [pq] create CISndGroupE2EEInfo (would affect tests) + createInternalChatItem user (CDGroupSnd groupInfo) (CISndGroupE2EEInfo $ E2EEInfo {pqEnabled = False}) Nothing pure $ CRGroupCreated user groupInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand $ APINewGroup userId incognito gProfile @@ -1606,7 +1610,7 @@ processChatCommand' vr = \case Nothing -> do gVar <- asks random subMode <- chatReadVar subscriptionMode - (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode + (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing CR.IKPQOff subMode member <- withStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -1631,7 +1635,7 @@ processChatCommand' vr = \case Just Connection {peerChatVRange} -> do subMode <- chatReadVar subscriptionMode dm <- directMessage $ XGrpAcpt membershipMemId - agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm CR.PQEncOff subMode withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode updateGroupMemberStatus db userId fromMember GSMemAccepted @@ -1767,7 +1771,7 @@ processChatCommand' vr = \case groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) subMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) CR.IKPQOff subMode withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo cReq mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do @@ -1794,7 +1798,7 @@ processChatCommand' vr = \case unless (isCompatibleRange (fromJVersionRange peerChatVRange) xGrpDirectInvVRange) $ throwChatError CEPeerChatVRangeIncompatible when (isJust $ memberContactId m) $ throwChatError $ CECommandError "member contact already exists" subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing CR.IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode -- TODO not sure it is correct to set connections status here? @@ -1807,7 +1811,7 @@ processChatCommand' vr = \case case memberConn m of Just mConn -> do let msg = XGrpDirectInv cReq msgContent_ - (sndMsg, _) <- sendDirectMessage mConn pqDummyFlag msg $ GroupId groupId + (sndMsg, _, _) <- sendDirectMessage mConn CR.PQEncOff msg $ GroupId groupId withStore' $ \db -> setContactGrpInvSent db ct True let ct' = ct {contactGrpInvSent = True} forM_ msgContent_ $ \mc -> do @@ -1910,7 +1914,7 @@ processChatCommand' vr = \case Nothing -> pure () Just (ChatRef CTDirect contactId) -> do (contact, sharedMsgId) <- withStore $ \db -> (,) <$> getContact db user contactId <*> getSharedMsgIdByFileId db userId fileId - void . sendDirectContactMessage contact $ XFileCancel sharedMsgId + void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId Just (ChatRef CTGroup groupId) -> do (Group gInfo ms, sharedMsgId) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getSharedMsgIdByFileId db userId fileId void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId @@ -2141,25 +2145,27 @@ processChatCommand' vr = \case connect' (Just gLinkId) cReqHash xContactId True where connect' groupLinkId cReqHash xContactId inGroup = do - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode + enablePQ <- (not inGroup &&) <$> (readTVarIO =<< asks pqExperimentalEnabled) + (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup enablePQ + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode enablePQ pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse connectContactViaAddress user incognito ct cReq = withChatLock "connectViaContact" $ do newXContactId <- XContactId <$> drgRandomBytes 16 - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False enablePQ let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode + ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode enablePQ pure $ CRSentInvitationToContact user ct' incognitoProfile - requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> m (ConnId, Maybe Profile, SubscriptionMode) - requestContact user incognito cReq xContactId inGroup = do + requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQFlag -> m (ConnId, Maybe Profile, SubscriptionMode) + requestContact user incognito cReq xContactId inGroup enablePQ = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing inGroup dm <- directMessage (XContact profileToSend $ Just xContactId) subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm (CR.PQEncryption enablePQ) subMode pure (connId, incognitoProfile, subMode) contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = @@ -2186,7 +2192,8 @@ processChatCommand' vr = \case withChatLock "updateProfile" . procCmd $ do let changedCts = foldr (addChangedProfileContact user') [] contacts idsEvts = map ctSndMsg changedCts - msgReqs_ <- zipWith ctMsgReq changedCts <$> createSndMessages idsEvts + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + msgReqs_ <- zipWith (ctMsgReq enablePQ) changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ unless (null errs) $ toView $ CRChatErrors (Just user) errs let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts @@ -2212,9 +2219,10 @@ processChatCommand' vr = \case mergedProfile' = userProfileToSend user' Nothing (Just ct') False ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, ChatMsgEvent 'Json) ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, XInfo mergedProfile') - ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq - ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> - (conn, pqDummyFlag, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) + ctMsgReq :: PQFlag -> ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq + ctMsgReq enablePQ ChangedProfileContact {conn = conn@Connection {enablePQ = enablePQConn}} = + fmap $ \SndMessage {msgId, msgBody} -> + (conn, CR.PQEncryption $ enablePQ && enablePQConn, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -2227,7 +2235,7 @@ processChatCommand' vr = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withChatLock "updateProfile" $ do - void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) + void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse @@ -2317,7 +2325,7 @@ processChatCommand' vr = \case groupLinkId = Nothing, groupSize = Just currentMemCount } - (msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv + (msg, _) <- sendDirectContactMessage user ct $ XGrpInv groupInv let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -2743,12 +2751,12 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI case (chatRef, grpMemberId) of (ChatRef CTDirect contactId, Nothing) -> do ct <- withStoreCtx (Just "acceptFileReceive, getContact") $ \db -> getContact db user contactId - acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage ct msg + acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId, Just memId) -> do GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db user groupId memId case activeConn of Just conn -> do - acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMessage conn pqDummyFlag msg $ GroupId groupId + acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMessage conn CR.PQEncOff msg $ GroupId groupId _ -> throwChatError $ CEFileInternal "member connection not active" _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where @@ -2849,16 +2857,17 @@ acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId inv subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False 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 contactUsed + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + acId <- withAgent $ \a -> acceptContact a True invId dm (CR.PQEncryption enablePQ) subMode + withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode enablePQ contactUsed -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 +acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQFlag -> m Contact +acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqEnabled = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False - (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode + (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode (CR.PQEncryption pqEnabled) withStore' $ \db -> do - ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode contactUsed + ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode pqEnabled contactUsed forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct @@ -2884,7 +2893,7 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - connIds <- agentAcceptContactAsync user True invId msg subMode + connIds <- agentAcceptContactAsync user True invId msg subMode (CR.PQEncryption False) withStore $ \db -> do liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode getGroupMemberById db user groupMemberId @@ -2932,7 +2941,7 @@ agentSubscriber = do type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ())) -subscribeUserConnections :: forall m. ChatMonad m => VersionRange -> Bool -> AgentBatchSubscribe m -> User -> m () +subscribeUserConnections :: forall m. ChatMonad m => VersionRangeChat -> Bool -> AgentBatchSubscribe m -> User -> m () subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = do -- get user connections ce <- asks $ subscriptionEvents . config @@ -3311,7 +3320,7 @@ processAgentMsgSndFile _corrId aFileId msg = case (rfds, sfts, d, cInfo) of (rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) - msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct + msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage user ct withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId withAgent (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do @@ -3337,7 +3346,9 @@ processAgentMsgSndFile _corrId aFileId msg = useMember _ = Nothing sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m () sendToMember (rfd, (conn, sft)) = - void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn pqDummyFlag msg' $ GroupId groupId + void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> do + (sndMsg, msgDeliveryId, _) <- sendDirectMessage conn CR.PQEncOff msg' $ GroupId groupId + pure (sndMsg, msgDeliveryId) _ -> pure () _ -> pure () -- TODO error? SFERR e @@ -3428,7 +3439,7 @@ processAgentMsgRcvFile _corrId aFileId msg = agentXFTPDeleteRcvFile aFileId fileId toView $ CRRcvFileError user ci e ft -processAgentMessageConn :: forall m. ChatMonad m => VersionRange -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () +processAgentMessageConn :: forall m. ChatMonad m => VersionRangeChat -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of @@ -3460,7 +3471,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = agentMsgConnStatus = \case CONF {} -> Just ConnRequested INFO _ -> Just ConnSndReady - CON -> Just ConnReady + CON _ -> Just ConnReady _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () @@ -3514,42 +3525,39 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do - -- TODO [pq] same for other direct connection events; - -- TODO use ct', conn' downstream; - -- TODO pqAgentDummy - would be returned in agent event - let pqAgentDummy = False - (_ct', _conn') <- updateContactPQ ct conn pqAgentDummy - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - cmdId <- createAckCmd conn + let MsgMeta {pqEncryption = CR.PQEncryption pqRcvEnabled} = msgMeta + (ct', conn') <- updateContactPQRcv user ct conn pqRcvEnabled + checkIntegrityCreateItem (CDDirectRcv ct') msgMeta + cmdId <- createAckCmd conn' withAckMessage agentConnId cmdId msgMeta $ do - (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn msgMeta cmdId msgBody - let ct' = ct {activeConn = Just conn'} :: Contact - assertDirectAllowed user MDRcv ct' $ toCMEventTag event + (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta cmdId msgBody + let ct'' = ct' {activeConn = Just conn''} :: Contact + assertDirectAllowed user MDRcv ct'' $ toCMEventTag event updateChatLock "directMessage" event case event of - XMsgNew mc -> newContentMessage ct' mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct' sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId _ -> messageDelete ct' sharedMsgId msg msgMeta - XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct' sharedMsgId reaction add msg msgMeta + XMsgNew mc -> newContentMessage ct'' mc msg msgMeta + XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live + XMsgDel sharedMsgId _ -> messageDelete ct'' sharedMsgId msg msgMeta + XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile - XFile fInv -> processFileInvitation' ct' fInv msg msgMeta - XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName - XInfo p -> xInfo ct' p - XDirectDel -> xDirectDel ct' msg msgMeta - XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta - XInfoProbe probe -> xInfoProbe (COMContact ct') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct') probeHash - XInfoProbeOk probe -> xInfoProbeOk (COMContact ct') probe - XCallInv callId invitation -> xCallInv ct' callId invitation msg msgMeta - XCallOffer callId offer -> xCallOffer ct' callId offer msg - XCallAnswer callId answer -> xCallAnswer ct' callId answer msg - XCallExtra callId extraInfo -> xCallExtra ct' callId extraInfo msg - XCallEnd callId -> xCallEnd ct' callId msg - BFileChunk sharedMsgId chunk -> bFileChunk ct' sharedMsgId chunk msgMeta + XFile fInv -> processFileInvitation' ct'' fInv msg msgMeta + XFileCancel sharedMsgId -> xFileCancel ct'' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct'' sharedMsgId fileConnReq_ fName + XInfo p -> xInfo ct'' p + XDirectDel -> xDirectDel ct'' msg msgMeta + XGrpInv gInv -> processGroupInvitation ct'' gInv msg msgMeta + XInfoProbe probe -> xInfoProbe (COMContact ct'') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct'') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMContact ct'') probe + XCallInv callId invitation -> xCallInv ct'' callId invitation msg msgMeta + XCallOffer callId offer -> xCallOffer ct'' callId offer msg + XCallAnswer callId answer -> xCallAnswer ct'' callId answer msg + XCallExtra callId extraInfo -> xCallExtra ct'' callId extraInfo msg + XCallEnd callId -> xCallEnd ct'' callId msg + BFileChunk sharedMsgId chunk -> bFileChunk ct'' sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - let Contact {chatSettings = ChatSettings {sendRcpts}} = ct' + let Contact {chatSettings = ChatSettings {sendRcpts}} = ct'' pure $ fromMaybe (sendRcptsContacts user) sendRcpts && hasDeliveryReceipt (toCMEventTag event) RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ @@ -3584,33 +3592,38 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ processContactProfileUpdate ct profile False XOk -> pure () _ -> messageError "INFO for existing contact must have x.grp.mem.info, x.info or x.ok" - CON -> + CON (CR.PQEncryption pqEnabled) -> withStore' (\db -> getViaGroupMember db vr user ct) >>= \case Nothing -> do + withStore' $ \db -> updateConnPQEnabledCON db connId pqEnabled + let conn' = conn {pqSndEnabled = Just pqEnabled, pqRcvEnabled = Just pqEnabled} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - setContactNetworkStatus ct NSConnected - toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) - when (directOrUsed ct) $ createFeatureEnabledItems ct - when (contactConnInitiated conn) $ do - let Connection {groupLinkId} = conn + setContactNetworkStatus ct' NSConnected + toView $ CRContactConnected user ct' (fmap fromLocalProfile incognitoProfile) + when (directOrUsed ct') $ do + createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EEInfo pqEnabled) Nothing + createFeatureEnabledItems ct' + when (contactConnInitiated conn') $ do + let Connection {groupLinkId} = conn' doProbeContacts = isJust groupLinkId - probeMatchingContactsAndMembers ct (contactConnIncognito ct) doProbeContacts - withStore' $ \db -> resetContactConnInitiated db user conn + probeMatchingContactsAndMembers ct' (contactConnIncognito ct') doProbeContacts + withStore' $ \db -> resetContactConnInitiated db user conn' 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) + (msg, _) <- sendDirectContactMessage user 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 vr user groupId subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode + 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 @@ -3714,7 +3727,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupLinkId = groupLinkId, groupSize = Just currentMemCount } - (_msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv + (_msg, _) <- sendDirectContactMessage user ct $ XGrpInv groupInv -- we could link chat item with sent group invitation message (_msg) createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing _ -> throwChatError $ CECommandError "unexpected cmdFunction" @@ -3756,7 +3769,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XOk -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON -> do + CON _pqEnc -> do withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ @@ -3767,7 +3780,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberCategory m of GCHostMember -> do toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} - -- TODO [pq] create CIRcvGroupE2EEInfo (would affect tests) + createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupE2EEInfo $ E2EEInfo {pqEnabled = False}) Nothing createGroupFeatureItems gInfo m let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m @@ -3789,7 +3802,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo profileToSend = profileToSendOnAccept user profileMode True - void $ sendDirectMessage conn pqDummyFlag (XGrpLinkMem profileToSend) (GroupId groupId) + void $ sendDirectMessage conn CR.PQEncOff (XGrpLinkMem profileToSend) (GroupId groupId) sendIntroductions members = do intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m shuffledIntros <- liftIO $ shuffleIntros intros @@ -3815,7 +3828,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMessage conn pqDummyFlag (memberIntro $ reMember intro) (GroupId groupId) + void $ sendDirectMessage conn CR.PQEncOff (memberIntro $ reMember intro) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent sendHistory = when (isCompatibleRange (memberChatVRange' m) batchSendVRange) $ do @@ -3914,12 +3927,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ (invitedByGroupMemberId membership) $ \hostId -> do host <- withStore $ \db -> getGroupMember db user groupId hostId forM_ (memberConn host) $ \hostConn -> - void $ sendDirectMessage hostConn pqDummyFlag (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMessage hostConn CR.PQEncOff (XGrpMemCon memberId) (GroupId groupId) GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do im <- withStore $ \db -> getGroupMember db user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> - void $ sendDirectMessage imConn pqDummyFlag (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMessage imConn CR.PQEncOff (XGrpMemCon memberId) (GroupId groupId) _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta @@ -4103,7 +4116,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.file.acpt: fileName is different from expected" _ -> messageError "CONF from file connection must have x.file.acpt" - CON -> do + CON _ -> do ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSConnected updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 @@ -4144,7 +4157,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CFCreateConnFileInvDirect -> do ct <- withStore $ \db -> getContactByFileId db user fileId sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectContactMessage ct (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) + void $ sendDirectContactMessage user ct (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) -- [async agent commands] group XFileAcptInv continuation on receiving INV CFCreateConnFileInvGroup -> case grpMemberId of Just gMemberId -> do @@ -4152,7 +4165,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case activeConn of Just gMemberConn -> do sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectMessage gMemberConn pqDummyFlag (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) $ GroupId groupId + void $ sendDirectMessage gMemberConn CR.PQEncOff (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) $ GroupId groupId _ -> throwChatError $ CECommandError "no GroupMember activeConn" _ -> throwChatError $ CECommandError "no grpMemberId" _ -> throwChatError $ CECommandError "unexpected cmdFunction" @@ -4166,7 +4179,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case chatMsgEvent of XOk -> allowAgentConnectionAsync user conn' confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability _ -> pure () - CON -> startReceivingFile user fileId + CON _ -> startReceivingFile user fileId MSG meta _ msgBody -> do parseFileChunk msgBody >>= receiveFileChunk ft (Just conn) meta OK -> @@ -4237,7 +4250,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> VersionRange -> Profile -> Maybe XContactId -> m () + profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> m () profileContactRequest invId chatVRange p xContactId_ = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact @@ -4249,7 +4262,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + ct <- acceptContactRequestAsync user cReq incognitoProfile True enablePQ toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId @@ -4260,7 +4274,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo mem else do - ct <- acceptContactRequestAsync user cReq profileMode False + ct <- acceptContactRequestAsync user cReq profileMode False False toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> toView $ CRReceivedContactRequest user cReq @@ -4380,7 +4394,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () - sendProbe probe = void . sendDirectContactMessage ct $ XInfoProbe probe + sendProbe probe = void . sendDirectContactMessage user ct $ XInfoProbe probe probeMatchingMemberContact :: GroupMember -> IncognitoEnabled -> m () probeMatchingMemberContact GroupMember {activeConn = Nothing} _ = pure () @@ -4396,7 +4410,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () - sendProbe probe = void $ sendDirectMessage conn pqDummyFlag (XInfoProbe probe) (GroupId groupId) + sendProbe probe = void $ sendDirectMessage conn CR.PQEncOff (XInfoProbe probe) (GroupId groupId) sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> m () sendProbeHashes cgms probe probeId = @@ -4405,12 +4419,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeHash = ProbeHash $ C.sha256Hash (unProbe probe) sendProbeHash :: ContactOrMember -> m () sendProbeHash cgm@(COMContact c) = do - void . sendDirectContactMessage c $ XInfoProbeCheck probeHash + void . sendDirectContactMessage user c $ XInfoProbeCheck probeHash withStore' $ \db -> createSentProbeHash db userId probeId cgm sendProbeHash (COMGroupMember GroupMember {activeConn = Nothing}) = pure () sendProbeHash cgm@(COMGroupMember m@GroupMember {groupId, activeConn = Just conn}) = when (memberCurrent m) $ do - void $ sendDirectMessage conn pqDummyFlag (XInfoProbeCheck probeHash) (GroupId groupId) + void $ sendDirectMessage conn CR.PQEncOff (XInfoProbeCheck probeHash) (GroupId groupId) withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> m () @@ -4779,7 +4793,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView event ifM (allowSendInline fileSize fileInline) - (sendDirectFileInline ct ft sharedMsgId) + (sendDirectFileInline user ct ft sharedMsgId) (messageError "x.file.acpt.inv: fileSize is bigger than allowed to send inline") else messageError "x.file.acpt.inv: fileName is different from expected" @@ -5082,12 +5096,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case cgm2 of COMContact c2@Contact {contactId = cId2, profile = p2} | cId1 /= cId2 && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe + void . sendDirectContactMessage user c1 $ XInfoProbeOk probe COMContact <$$> mergeContacts c1 c2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} | isNothing memberContactId && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe + void . sendDirectContactMessage user c1 $ XInfoProbeOk probe COMContact <$$> associateMemberAndContact c1 m2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" >> pure Nothing COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing @@ -5095,7 +5109,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case cgm2 of COMContact c2@Contact {profile = p2} | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do - void $ sendDirectMessage conn pqDummyFlag (XInfoProbeOk probe) (GroupId groupId) + void $ sendDirectMessage conn CR.PQEncOff (XInfoProbeOk probe) (GroupId groupId) COMContact <$$> associateMemberAndContact c2 m1 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing @@ -5296,7 +5310,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfo p -> do let contactUsed = connDirect activeConn ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed - -- TODO [pq] create CIRcvDirectE2EEInfo here? toView $ CRContactConnecting user ct pure conn' XGrpLinkInv glInv -> do @@ -5352,7 +5365,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do hostConn <- withStore $ \db -> getConnectionById db user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} - void $ sendDirectMessage hostConn pqDummyFlag msg (GroupId groupId) + void $ sendDirectMessage hostConn CR.PQEncOff msg (GroupId groupId) withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () @@ -5707,23 +5720,45 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () - -- TODO [pq] track rcv and snd flags separately - updateContactPQ :: Contact -> Connection -> PQFlag -> m (Contact, Connection) - updateContactPQ ct conn@Connection {connId, pqEnabled} pqEnabled' = - flip catchChatError (const $ pure (ct, conn)) $ case (pqEnabled, pqEnabled') of - (Nothing, False) -> pure (ct, conn) - (Nothing, True) -> updatePQ $ CIRcvDirectE2EEInfo (E2EEInfo pqEnabled') - (Just b, b') - | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPQEnabled pqEnabled') - | otherwise -> pure (ct, conn) - where - updatePQ ciContent = do - withStore' $ \db -> updateConnPQEnabled db connId pqEnabled' - let conn' = conn {pqEnabled = Just pqEnabled'} :: Connection - ct' = ct {activeConn = Just conn'} :: Contact - createInternalChatItem user (CDDirectRcv ct') ciContent Nothing - toView $ CRContactPQEnabled user ct' pqEnabled' - pure (ct', conn') +createContactPQSndItem :: ChatMonad m => User -> Contact -> Connection -> PQFlag -> m (Contact, Connection) +createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = + -- TODO PQ refactor (?) check for pqSndEnabled change with updatePQSndEnabled in deliverMessagesB + flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of + (Nothing, False) -> pure (ct, conn) + (Nothing, True) -> createPQItem $ CISndDirectE2EEInfo (E2EEInfo pqSndEnabled') + (Just b, b') + | b' /= b -> createPQItem $ CISndConnEvent (SCEPQEnabled pqSndEnabled') + | otherwise -> pure (ct, conn) + where + createPQItem ciContent = do + let cpqe = contactPQEnabled ct + conn' = conn {pqSndEnabled = Just pqSndEnabled'} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + cpqe' = contactPQEnabled ct' + when (cpqe' /= cpqe) $ do + createInternalChatItem user (CDDirectSnd ct') ciContent Nothing + toView $ CRContactPQEnabled user ct' pqSndEnabled' + pure (ct', conn') + +updateContactPQRcv :: ChatMonad m => User -> Contact -> Connection -> PQFlag -> m (Contact, Connection) +updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' = + flip catchChatError (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of + (Nothing, False) -> pure (ct, conn) + (Nothing, True) -> updatePQ $ CIRcvDirectE2EEInfo (E2EEInfo pqRcvEnabled') + (Just b, b') + | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPQEnabled pqRcvEnabled') + | otherwise -> pure (ct, conn) + where + updatePQ ciContent = do + withStore' $ \db -> updateConnPQRcvEnabled db connId pqRcvEnabled' + let cpqe = contactPQEnabled ct + conn' = conn {pqRcvEnabled = Just pqRcvEnabled'} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + cpqe' = contactPQEnabled ct' + when (cpqe' /= cpqe) $ do + createInternalChatItem user (CDDirectRcv ct') ciContent Nothing + toView $ CRContactPQEnabled user ct' pqRcvEnabled' + pure (ct', conn') metaBrokerTs :: MsgMeta -> UTCTime metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs @@ -5731,7 +5766,7 @@ metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs sameMemberId :: MemberId -> GroupMember -> Bool sameMemberId memId GroupMember {memberId} = memId == memberId -updatePeerChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection +updatePeerChatVRange :: ChatMonad m => Connection -> VersionRangeChat -> m Connection updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do let jMsgChatVRange = JVersionRange msgChatVRange if jMsgChatVRange /= peerChatVRange @@ -5740,7 +5775,7 @@ updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do pure conn {peerChatVRange = jMsgChatVRange} else pure conn -updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRange -> m (GroupMember, Connection) +updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRangeChat -> m (GroupMember, Connection) updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, peerChatVRange} msgChatVRange = do let jMsgChatVRange = JVersionRange msgChatVRange if jMsgChatVRange /= peerChatVRange @@ -5756,14 +5791,16 @@ parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescr parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) -sendDirectFileInline :: ChatMonad m => Contact -> FileTransferMeta -> SharedMsgId -> m () -sendDirectFileInline ct ft sharedMsgId = do - msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage ct +sendDirectFileInline :: ChatMonad m => User -> Contact -> FileTransferMeta -> SharedMsgId -> m () +sendDirectFileInline user ct ft sharedMsgId = do + msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage user ct withStore $ \db -> updateSndDirectFTDelivery db ct ft msgDeliveryId sendMemberFileInline :: ChatMonad m => GroupMember -> Connection -> FileTransferMeta -> SharedMsgId -> m () sendMemberFileInline m@GroupMember {groupId} conn ft sharedMsgId = do - msgDeliveryId <- sendFileInline_ ft sharedMsgId $ \msg -> sendDirectMessage conn pqDummyFlag msg $ GroupId groupId + msgDeliveryId <- sendFileInline_ ft sharedMsgId $ \msg -> do + (sndMsg, msgDeliveryId, _) <- sendDirectMessage conn CR.PQEncOff msg $ GroupId groupId + pure (sndMsg, msgDeliveryId) withStore' $ \db -> updateSndGroupFTDelivery db m conn ft msgDeliveryId sendFileInline_ :: ChatMonad m => FileTransferMeta -> SharedMsgId -> (ChatMsgEvent 'Binary -> m (SndMessage, Int64)) -> m Int64 @@ -5805,7 +5842,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo sendFileChunkNo :: ChatMonad m => SndFileTransfer -> Integer -> m () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do chunkBytes <- readFileChunk ft chunkNo - msgId <- withAgent $ \a -> sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} + (msgId, _) <- withAgent $ \a -> sendMessage a acId CR.PQEncOff SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} withStore' $ \db -> updateSndFileChunkMsg db ft chunkNo msgId readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString @@ -5913,8 +5950,8 @@ cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, age when sendCancel $ case fileInline of Just _ -> do (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db user connId - void . sendDirectMessage conn pqDummyFlag (BFileChunk sharedMsgId FileChunkCancel) $ ConnectionId connId - _ -> withAgent $ \a -> void . sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel + void . sendDirectMessage conn CR.PQEncOff (BFileChunk sharedMsgId FileChunkCancel) $ ConnectionId connId + _ -> withAgent $ \a -> void . sendMessage a acId CR.PQEncOff SMP.noMsgFlags $ smpEncode FileChunkCancel pure fileConnId fileConnId = if isNothing fileInline then Just acId else Nothing @@ -5951,12 +5988,15 @@ deleteOrUpdateMemberRecord user@User {userId} member = Just _ -> updateGroupMemberStatus db userId member GSMemRemoved Nothing -> deleteGroupMember db user member -sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) -sendDirectContactMessage ct chatMsgEvent = do +sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => User -> Contact -> ChatMsgEvent e -> m (SndMessage, Int64) +sendDirectContactMessage user ct chatMsgEvent = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct - -- TODO [pq] look up pqExperimentalEnabled on every send to pass flag to agent apis - pq <- readTVarIO =<< asks pqExperimentalEnabled - sendDirectMessage conn pq chatMsgEvent (ConnectionId connId) + pqEnc <- contactPQEnc conn + r <- sendDirectMessage conn pqEnc chatMsgEvent (ConnectionId connId) + let (sndMessage, msgDeliveryId, CR.PQEncryption pqEnabled') = r + -- TODO PQ use updated ct' and conn'? check downstream if it may affect something, maybe it's not necessary + (_ct', _conn') <- createContactPQSndItem user ct conn pqEnabled' + pure (sndMessage, msgDeliveryId) contactSendConn_ :: Contact -> Either ChatError Connection contactSendConn_ ct@Contact {activeConn} = case activeConn of @@ -5969,11 +6009,12 @@ contactSendConn_ ct@Contact {activeConn} = case activeConn of where err = Left . ChatError -sendDirectMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> PQFlag -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64) -sendDirectMessage conn pq chatMsgEvent connOrGroupId = do +sendDirectMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> CR.PQEncryption -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64, CR.PQEncryption) +sendDirectMessage conn pqEnc chatMsgEvent connOrGroupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId - (msg,) <$> deliverMessage conn pq (toCMEventTag chatMsgEvent) msgBody msgId + (msgDeliveryId, pqEnc') <- deliverMessage conn pqEnc (toCMEventTag chatMsgEvent) msgBody msgId + pure (msg, msgDeliveryId, pqEnc') createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = @@ -6005,7 +6046,7 @@ sendGroupMemberMessages user conn@Connection {connId} events groupId = do where processBatch :: MsgBatch -> m () processBatch (MsgBatch batchBody sndMsgs) = do - agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) MsgFlags {notification = True} batchBody + (agentMsgId, _pqEnc) <- withAgent $ \a -> sendMessage a (aConnId conn) CR.PQEncOff MsgFlags {notification = True} batchBody let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs @@ -6017,45 +6058,52 @@ directMessage chatMsgEvent = do ECMEncoded encodedBody -> pure encodedBody ECMLarge -> throwChatError $ CEException "large message" -deliverMessage :: ChatMonad m => Connection -> PQFlag -> CMEventTag e -> MsgBody -> MessageId -> m Int64 -deliverMessage conn pq cmEventTag msgBody msgId = do +deliverMessage :: ChatMonad m => Connection -> CR.PQEncryption -> CMEventTag e -> MsgBody -> MessageId -> m (Int64, CR.PQEncryption) +deliverMessage conn pqEnc cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} - deliverMessage' conn pq msgFlags msgBody msgId + deliverMessage' conn pqEnc msgFlags msgBody msgId -deliverMessage' :: ChatMonad m => Connection -> PQFlag -> MsgFlags -> MsgBody -> MessageId -> m Int64 -deliverMessage' conn pq msgFlags msgBody msgId = - deliverMessages [(conn, pq, msgFlags, msgBody, msgId)] >>= \case +deliverMessage' :: ChatMonad m => Connection -> CR.PQEncryption -> MsgFlags -> MsgBody -> MessageId -> m (Int64, CR.PQEncryption) +deliverMessage' conn pqEnc msgFlags msgBody msgId = + deliverMessages [(conn, pqEnc, msgFlags, msgBody, msgId)] >>= \case [r] -> liftEither r rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) -type MsgReq = (Connection, PQFlag, MsgFlags, MsgBody, MessageId) +type MsgReq = (Connection, CR.PQEncryption, MsgFlags, MsgBody, MessageId) --- TODO [pq] remove, replace in all places with actual flag / pqOff in groups -pqDummyFlag :: PQFlag -pqDummyFlag = False +contactPQEnc :: ChatMonad m => Connection -> m CR.PQEncryption +contactPQEnc Connection {enablePQ = enablePQConn} = do + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + pure $ CR.PQEncryption $ enablePQ && enablePQConn --- TODO remove in 5.7 (used for groups) -pqOff :: PQFlag -pqOff = False - -deliverMessages :: ChatMonad' m => [MsgReq] -> m [Either ChatError Int64] +deliverMessages :: ChatMonad' m => [MsgReq] -> m [Either ChatError (Int64, CR.PQEncryption)] deliverMessages = deliverMessagesB . map Right -deliverMessagesB :: ChatMonad' m => [Either ChatError MsgReq] -> m [Either ChatError Int64] +deliverMessagesB :: ChatMonad' m => [Either ChatError MsgReq] -> m [Either ChatError (Int64, CR.PQEncryption)] deliverMessagesB msgReqs = do - -- TODO [pq] pass _pqFlag to sendMessagesB - sent <- zipWith prepareBatch msgReqs <$> withAgent' (`sendMessagesB` map toAgent msgReqs) + sent <- zipWith prepareBatch msgReqs <$> withAgent' (\a -> sendMessagesB a $ map toAgent msgReqs) + void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights sent) withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent where toAgent = \case - Right (conn, _pqFlag, msgFlags, msgBody, _msgId) -> Right (aConnId conn, msgFlags, msgBody) + Right (conn, pqEnc, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEnc, msgFlags, msgBody) Left _ce -> Left (AP.INTERNAL "ChatError, skip") -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing - createDelivery :: DB.Connection -> (MsgReq, AgentMsgId) -> IO (Either ChatError Int64) - createDelivery db ((Connection {connId}, _, _, _, msgId), agentMsgId) = - Right <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId + createDelivery :: DB.Connection -> (MsgReq, (AgentMsgId, CR.PQEncryption)) -> IO (Either ChatError (Int64, CR.PQEncryption)) + createDelivery db ((Connection {connId}, _, _, _, msgId), (agentMsgId, pqEnc')) = + Right . (,pqEnc') <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId + updatePQSndEnabled :: DB.Connection -> (MsgReq, (AgentMsgId, CR.PQEncryption)) -> IO () + updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _, _, _), (_, CR.PQEncryption pqSndEnabled')) = + case (pqSndEnabled, pqSndEnabled') of + (Nothing, False) -> pure () + (Nothing, True) -> updatePQ + (Just b, b') + | b' /= b -> updatePQ + | otherwise -> pure () + where + updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage user gInfo members chatMsgEvent = do @@ -6085,7 +6133,7 @@ sendGroupMessage' user GroupInfo {groupId} members chatMsgEvent = do recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} (toSend, pending) = foldr addMember ([], []) recipientMembers - msgReqs = map (\(_, conn) -> (conn, pqOff, msgFlags, msgBody, msgId)) toSend + msgReqs = map (\(_, conn) -> (conn, CR.PQEncOff, msgFlags, msgBody, msgId)) toSend delivered <- deliverMessages msgReqs let errors = lefts delivered unless (null errors) $ toView $ CRChatErrors (Just user) errors @@ -6144,7 +6192,7 @@ sendGroupMemberMessage user m@GroupMember {groupMemberId} chatMsgEvent groupId i where messageMember :: SndMessage -> m () messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction chatMsgEvent [m] m) $ \case - MSASend conn -> deliverMessage conn pqDummyFlag (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver + MSASend conn -> deliverMessage conn CR.PQEncOff (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () @@ -6155,7 +6203,7 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn processPendingMessage pgm `catchChatError` (toView . CRChatError (Just user)) where processPendingMessage PendingGroupMessage {msgId, cmEventTag = ACMEventTag _ tag, msgBody, introId_} = do - void $ deliverMessage conn pqDummyFlag tag msgBody msgId + void $ deliverMessage conn CR.PQEncOff tag msgBody msgId withStore' $ \db -> deletePendingGroupMessage db groupMemberId msgId case tag of XGrpMemFwd_ -> case introId_ of @@ -6189,7 +6237,7 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMessage fmConn pqDummyFlag (XGrpMemCon amMemId) (GroupId groupId) + void $ sendDirectMessage fmConn CR.PQEncOff (XGrpMemCon amMemId) (GroupId groupId) throwError e _ -> throwError e pure (am', conn', msg) @@ -6205,7 +6253,7 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId if sameMemberId refMemberId am then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMessage fmConn pqDummyFlag (XGrpMemCon amMemberId) (GroupId groupId) + void $ sendDirectMessage fmConn CR.PQEncOff (XGrpMemCon amMemberId) (GroupId groupId) else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" throwError e _ -> throwError e @@ -6301,13 +6349,13 @@ cancelCIFile user file_ = createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction - connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode subMode + connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode CR.IKPQOff subMode pure (cmdId, connId) joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> m (CommandId, ConnId) joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo subMode + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo CR.PQEncOff subMode pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () @@ -6317,11 +6365,11 @@ allowAgentConnectionAsync user conn@Connection {connId} confId msg = do withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> m (CommandId, ConnId) -agentAcceptContactAsync user enableNtfs invId msg subMode = do +agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> CR.PQEncryption -> m (CommandId, ConnId) +agentAcceptContactAsync user enableNtfs invId msg subMode pqEnc = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact dm <- directMessage msg - connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm subMode + connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqEnc subMode pure (cmdId, connId) deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () @@ -6555,7 +6603,7 @@ waitChatStartedAndActivated = do activated <- readTVar chatActivated unless (isJust started && activated) retry -chatVersionRange :: ChatMonad' m => m VersionRange +chatVersionRange :: ChatMonad' m => m VersionRangeChat chatVersionRange = do ChatConfig {chatVRange} <- asks config pure chatVRange diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4b6c45002b..97ff5a93ca 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -80,7 +80,6 @@ import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) -import Simplex.Messaging.Version import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types @@ -122,7 +121,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, - chatVRange :: VersionRange, + chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, defaultServers :: DefaultAgentServers, tbqSize :: Natural, @@ -207,7 +206,7 @@ data ChatController = ChatController tempDirectory :: TVar (Maybe FilePath), logFilePath :: Maybe FilePath, contactMergeEnabled :: TVar Bool, - pqExperimentalEnabled :: TVar Bool -- TODO remove in 5.7 + pqExperimentalEnabled :: TVar PQFlag -- TODO remove in 5.7 } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index f156d62581..dfe3d0d043 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -343,6 +343,9 @@ sndConnEventToText = \case SPSecured -> "secured new address" <> forMember m <> "..." SPCompleted -> "you changed address" <> forMember m SCERatchetSync syncStatus m -> ratchetSyncStatusToText syncStatus <> forMember m + SCEPQEnabled enabled + | enabled -> "post-quantum encryption enabled" + | otherwise -> "post-quantum encryption disabled" where forMember member_ = maybe "" (\GroupMemberRef {profile = Profile {displayName}} -> " for " <> displayName) member_ diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index f0ff321118..f8a877187a 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -48,6 +48,7 @@ data RcvConnEvent data SndConnEvent = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} + | SCEPQEnabled {enabled :: Bool} deriving (Show) data RcvDirectEvent diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Migrations/M20240228_pq.hs index a72d8915bb..4f3ca3b743 100644 --- a/src/Simplex/Chat/Migrations/M20240228_pq.hs +++ b/src/Simplex/Chat/Migrations/M20240228_pq.hs @@ -8,11 +8,15 @@ import Database.SQLite.Simple.QQ (sql) m20240228_pq :: Query m20240228_pq = [sql| -ALTER TABLE connections ADD COLUMN pq_enabled INTEGER; +ALTER TABLE connections ADD COLUMN enable_pq INTEGER; +ALTER TABLE connections ADD COLUMN pq_snd_enabled INTEGER; +ALTER TABLE connections ADD COLUMN pq_rcv_enabled INTEGER; |] down_m20240228_pq :: Query down_m20240228_pq = [sql| -ALTER TABLE connections DROP COLUMN pq_enabled; +ALTER TABLE connections DROP COLUMN enable_pq; +ALTER TABLE connections DROP COLUMN pq_snd_enabled; +ALTER TABLE connections DROP COLUMN pq_rcv_enabled; |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 2ebfa87623..ad5dbe1620 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -277,7 +277,9 @@ CREATE TABLE connections( peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, contact_conn_initiated INTEGER NOT NULL DEFAULT 0, - pq_enabled INTEGER, + enable_pq INTEGER, + pq_snd_enabled INTEGER, + pq_rcv_enabled INTEGER, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index c4423bfe6a..7c8bd0e602 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -53,40 +53,40 @@ import Simplex.Messaging.Version hiding (version) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. -currentChatVersion :: Version -currentChatVersion = 7 +currentChatVersion :: VersionChat +currentChatVersion = VersionChat 7 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) -supportedChatVRange :: VersionRange -supportedChatVRange = mkVersionRange 1 currentChatVersion +supportedChatVRange :: VersionRangeChat +supportedChatVRange = mkVersionRange (VersionChat 1) currentChatVersion -- version range that supports skipping establishing direct connections in a group -groupNoDirectVRange :: VersionRange -groupNoDirectVRange = mkVersionRange 2 currentChatVersion +groupNoDirectVRange :: VersionRangeChat +groupNoDirectVRange = mkVersionRange (VersionChat 2) currentChatVersion -- version range that supports establishing direct connection via x.grp.direct.inv with a group member -xGrpDirectInvVRange :: VersionRange -xGrpDirectInvVRange = mkVersionRange 2 currentChatVersion +xGrpDirectInvVRange :: VersionRangeChat +xGrpDirectInvVRange = mkVersionRange (VersionChat 2) currentChatVersion -- version range that supports joining group via group link without creating direct contact -groupLinkNoContactVRange :: VersionRange -groupLinkNoContactVRange = mkVersionRange 3 currentChatVersion +groupLinkNoContactVRange :: VersionRangeChat +groupLinkNoContactVRange = mkVersionRange (VersionChat 3) currentChatVersion -- version range that supports group forwarding -groupForwardVRange :: VersionRange -groupForwardVRange = mkVersionRange 4 currentChatVersion +groupForwardVRange :: VersionRangeChat +groupForwardVRange = mkVersionRange (VersionChat 4) currentChatVersion -- version range that supports batch sending in groups -batchSendVRange :: VersionRange -batchSendVRange = mkVersionRange 5 currentChatVersion +batchSendVRange :: VersionRangeChat +batchSendVRange = mkVersionRange (VersionChat 5) currentChatVersion -- version range that supports sending group welcome message in group history -groupHistoryIncludeWelcomeVRange :: VersionRange -groupHistoryIncludeWelcomeVRange = mkVersionRange 6 currentChatVersion +groupHistoryIncludeWelcomeVRange :: VersionRangeChat +groupHistoryIncludeWelcomeVRange = mkVersionRange (VersionChat 6) currentChatVersion -- version range that supports sending member profile updates to groups -memberProfileUpdateVRange :: VersionRange -memberProfileUpdateVRange = mkVersionRange 7 currentChatVersion +memberProfileUpdateVRange :: VersionRangeChat +memberProfileUpdateVRange = mkVersionRange (VersionChat 7) currentChatVersion data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} @@ -217,7 +217,7 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) data ChatMessage e = ChatMessage - { chatVRange :: VersionRange, + { chatVRange :: VersionRangeChat, msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent e } diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 0e4ea5c286..61ed54416b 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -35,9 +35,8 @@ import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Util (eitherToMaybe) -import Simplex.Messaging.Version (VersionRange) -getConnectionEntity :: DB.Connection -> VersionRange -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity +getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ case entityId of @@ -61,7 +60,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, - created_at, security_code, security_code_verified_at, pq_enabled, auth_err_counter, + created_at, security_code, security_code_verified_at, enable_pq, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? @@ -158,7 +157,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionEntityByConnReq :: DB.Connection -> VersionRange -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ @@ -169,7 +168,7 @@ getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address -getContactConnEntityByConnReqHash :: DB.Connection -> VersionRange -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ @@ -189,7 +188,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ -getConnectionsToSubscribe :: DB.Connection -> VersionRange -> IO ([ConnId], [ConnectionEntity]) +getConnectionsToSubscribe :: DB.Connection -> VersionRangeChat -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db vr = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" entities <- forM aConnIds $ \acId -> do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 2ba940d007..4bfe87d5f1 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -124,14 +124,14 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> ExceptT StoreError IO Contact -createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode = do - PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode +createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> PQFlag -> ExceptT StoreError IO Contact +createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode enablePQ = do + PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode enablePQ liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId) getContact db user contactId -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> PQFlag -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode enablePQ = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -140,10 +140,14 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + created_at, updated_at, to_subscribe, enable_pq + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) + ( (userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) + :. (customUserProfileId, isJust groupLinkId, groupLinkId) + :. (createdAt, createdAt, subMode == SMOnlyCreate, enablePQ) + ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -173,7 +177,7 @@ getContactByConnReqHash db user@User {userId} cReqHash = 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.pq_enabled, c.auth_err_counter, + 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -184,8 +188,8 @@ getContactByConnReqHash db user@User {userId} cReqHash = |] (userId, cReqHash, CSActive) -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> PQFlag -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode enablePQ = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let contactConnInitiated = pccConnStatus == ConnNew @@ -193,9 +197,13 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?,?) + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + created_at, updated_at, to_subscribe, enable_pq) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) + ( (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId) + :. (createdAt, createdAt, subMode == SMOnlyCreate, enablePQ) + ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -522,7 +530,7 @@ getUserContacts db user@User {userId} = do contacts <- rights <$> mapM (runExceptT . getContact db user) contactIds pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts -createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRange -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest +createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = liftIO (maybeM getContact' xContactId_) >>= \case Just contact -> pure $ CORContact contact @@ -569,7 +577,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers 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.pq_enabled, c.auth_err_counter, + 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -697,8 +705,8 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) 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 -> Bool -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode contactUsed = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQFlag -> Bool -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode enablePQ contactUsed = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -710,7 +718,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "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 + conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode enablePQ let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn 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} @@ -734,7 +742,7 @@ getContact_ db user@User {userId} contactId deleted = 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.pq_enabled, c.auth_err_counter, + 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -788,7 +796,7 @@ getContactConnections db userId Contact {contactId} = [sql| SELECT 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.pq_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id @@ -806,7 +814,7 @@ getConnectionById db User {userId} connId = ExceptT $ do [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, - created_at, security_code, security_code_verified_at, pq_enabled, auth_err_counter, + created_at, security_code, security_code_verified_at, enable_pq, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 8d54c6860d..a6985f08c2 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -115,7 +115,6 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Version (VersionRange) import System.FilePath (takeFileName) getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] @@ -431,7 +430,7 @@ lookupChatRefByFileId db User {userId} fileId = createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection createSndFileConnection_ db userId fileId agentConnId subMode = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode + createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode False updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do @@ -693,7 +692,7 @@ getRcvFileTransfer_ db userId fileId = do _ -> pure Nothing cancelled = fromMaybe False cancelled_ -acceptRcvFileTransfer :: DB.Connection -> VersionRange -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem +acceptRcvFileTransfer :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do currentTs <- getCurrentTime acceptRcvFT_ db user fileId filePath Nothing currentTs @@ -714,7 +713,7 @@ getContactByFileId db user@User {userId} fileId = do ExceptT . firstRow fromOnly (SEContactNotFoundByFileId fileId) $ DB.query db "SELECT contact_id FROM files WHERE user_id = ? AND file_id = ?" (userId, fileId) -acceptRcvInlineFT :: DB.Connection -> VersionRange -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +acceptRcvInlineFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem acceptRcvInlineFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath (Just IFMOffer) =<< getCurrentTime getChatItemByFileId db vr user fileId @@ -723,7 +722,7 @@ startRcvInlineFT :: DB.Connection -> User -> RcvFileTransfer -> FilePath -> Mayb startRcvInlineFT db user RcvFileTransfer {fileId} filePath rcvFileInline = acceptRcvFT_ db user fileId filePath rcvFileInline =<< getCurrentTime -xftpAcceptRcvFT :: DB.Connection -> VersionRange -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +xftpAcceptRcvFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem xftpAcceptRcvFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath Nothing =<< getCurrentTime getChatItemByFileId db vr user fileId @@ -998,7 +997,7 @@ getLocalCryptoFile db userId fileId sent = pure $ CryptoFile filePath fileCryptoArgs _ -> throwError $ SEFileNotFound fileId -updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> VersionRange -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem +updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> VersionRangeChat -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db vr user fileId fileStatus = do aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db vr user fileId case (cType, testEquality d $ msgDirection @d) of diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 8ccec82ddf..c50ec4fbf7 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -148,11 +148,11 @@ import UnliftIO.STM type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, Version, Version, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe Version, Maybe Version, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) -toGroupInfo :: VersionRange -> Int64 -> GroupInfoRow -> GroupInfo +toGroupInfo :: VersionRangeChat -> Int64 -> GroupInfoRow -> GroupInfo toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = JVersionRange vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} @@ -184,7 +184,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode False getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = @@ -194,7 +194,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = [sql| SELECT 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.pq_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -259,7 +259,7 @@ setGroupLinkMemberRole :: DB.Connection -> User -> Int64 -> GroupMemberRole -> I setGroupLinkMemberRole db User {userId} userContactLinkId memberRole = DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId) -getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRange -> ExceptT StoreError IO (GroupInfo, GroupMember) +getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId vr = ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ DB.query @@ -280,7 +280,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = 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, 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.pq_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -304,7 +304,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRange -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo +createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -346,7 +346,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> VersionRange -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) +createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case @@ -417,7 +417,7 @@ getHostMemberId_ db User {userId} groupId = ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRange -> ExceptT StoreError IO GroupMember +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt memberChatVRange@(VersionRange minV maxV) = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of @@ -480,7 +480,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe ) pure $ Right incognitoLdn -createGroupInvitedViaLink :: DB.Connection -> VersionRange -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupInvitedViaLink db vr @@ -551,7 +551,7 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroup :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO Group +getGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO Group getGroup db vr user groupId = do gInfo <- getGroupInfo db vr user groupId members <- liftIO $ getGroupMembers db user gInfo @@ -606,12 +606,12 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroups :: DB.Connection -> VersionRange -> User -> IO [Group] +getUserGroups :: DB.Connection -> VersionRangeChat -> User -> IO [Group] getUserGroups db vr user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db vr user) groupIds -getUserGroupDetails :: DB.Connection -> VersionRange -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] +getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = map (toGroupInfo vr userContactId) <$> DB.query @@ -634,7 +634,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = where search = fromMaybe "" search_ -getUserGroupsWithSummary :: DB.Connection -> VersionRange -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)] +getUserGroupsWithSummary :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)] getUserGroupsWithSummary db vr user _contactId_ search_ = getUserGroupDetails db vr user _contactId_ search_ >>= mapM (\g@GroupInfo {groupId} -> (g,) <$> getGroupSummary db user groupId) @@ -675,7 +675,7 @@ checkContactHasGroups :: DB.Connection -> User -> Contact -> IO (Maybe GroupId) checkContactHasGroups db User {userId} Contact {contactId} = maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) -getGroupInfoByName :: DB.Connection -> VersionRange -> User -> GroupName -> ExceptT StoreError IO GroupInfo +getGroupInfoByName :: DB.Connection -> VersionRangeChat -> User -> GroupName -> ExceptT StoreError IO GroupInfo getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName getGroupInfo db vr user gId @@ -688,7 +688,7 @@ groupMemberQuery = 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, 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.pq_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -765,7 +765,7 @@ getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do (groupId, userId) pure $ length $ filter memberCurrent' statuses -getGroupInvitation :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation +getGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation getGroupInvitation db vr user groupId = getConnRec_ user >>= \case Just connRequest -> do @@ -830,7 +830,7 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, :. (minV, maxV) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> ExceptT StoreError IO () +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime @@ -896,7 +896,7 @@ createAcceptedMemberConnection groupMemberId subMode = do createdAt <- liftIO getCurrentTime - Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode False setCommandConnId db user cmdId connId getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact @@ -926,12 +926,12 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) -createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> SubscriptionMode -> IO () +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRangeChat -> SubscriptionMode -> IO () createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange subMode = do currentTs <- getCurrentTime void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode -createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> IO () +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRangeChat -> SubscriptionMode -> IO () createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange subMode = do currentTs <- getCurrentTime Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode @@ -1065,7 +1065,7 @@ updateGroupMemberRole :: DB.Connection -> User -> GroupMember -> GroupMemberRole updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = DB.execute db "UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ?" (memRole, userId, groupMemberId) -createIntroductions :: DB.Connection -> Version -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] +createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] createIntroductions db chatV members toMember = do let reMembers = filter (\m -> memberCurrent m && groupMemberId' m /= groupMemberId' toMember) members if null reMembers @@ -1218,7 +1218,7 @@ createIntroReMember currentTs <- liftIO getCurrentTime newMember <- case directConnIds of Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode False liftIO $ setCommandConnId db user directCmdId directConnId (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) @@ -1232,14 +1232,14 @@ createIntroReMember liftIO $ setCommandConnId db user groupCmdId groupConnId pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- getCurrentTime Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode False setCommandConnId db user directCmdId directConnId contactId <- createMemberContact_ directConnId currentTs updateMember_ contactId currentTs @@ -1269,10 +1269,11 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = |] [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection +createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact connLevel currentTs subMode = + createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode False -getViaGroupMember :: DB.Connection -> VersionRange -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) +getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = maybeFirstRow toGroupAndMember $ DB.query @@ -1293,7 +1294,7 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = 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, 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.pq_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id @@ -1368,7 +1369,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, (ldn, currentTs, userId, groupId) safeDeleteLDN db user localDisplayName -getGroupInfo :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db vr User {userId, userContactId} groupId = ExceptT . firstRow (toGroupInfo vr userContactId) (SEGroupNotFound groupId) $ DB.query @@ -1391,7 +1392,7 @@ getGroupInfo db vr User {userId, userContactId} groupId = |] (groupId, userId, userContactId) -getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRange -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do groupId_ <- maybeFirstRow fromOnly $ @@ -1405,7 +1406,7 @@ getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReq (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ -getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRange -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- maybeFirstRow fromOnly $ @@ -1432,7 +1433,7 @@ getGroupMemberIdByName db User {userId} groupId groupMemberName = ExceptT . firstRow fromOnly (SEGroupMemberNameNotFound groupId groupMemberName) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ?" (userId, groupId, groupMemberName) -getActiveMembersByName :: DB.Connection -> VersionRange -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] +getActiveMembersByName :: DB.Connection -> VersionRangeChat -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] getActiveMembersByName db vr user@User {userId} groupMemberName = do groupMemberIds :: [(GroupId, GroupMemberId)] <- liftIO $ @@ -1931,13 +1932,15 @@ createMemberContact localAlias = "", createdAt = currentTs, connectionCode = Nothing, - pqEnabled = Nothing, + enablePQ = False, + pqSndEnabled = Nothing, + pqRcvEnabled = Nothing, authErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} -getMemberContact :: DB.Connection -> VersionRange -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) +getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do ct <- getContact db user contactId let Contact {contactGroupMemberId, activeConn} = ct @@ -2060,7 +2063,9 @@ createMemberContactConn_ localAlias = "", createdAt = currentTs, connectionCode = Nothing, - pqEnabled = Nothing, + enablePQ = False, + pqSndEnabled = Nothing, + pqRcvEnabled = Nothing, authErrCounter = 0 } @@ -2113,7 +2118,7 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" (xGrpLinkMemReceived, currentTs, mId) -createNewUnknownGroupMember :: DB.Connection -> VersionRange -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember +createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index a755353da7..c7e25e3b96 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -147,7 +147,6 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) -import Simplex.Messaging.Version (VersionRange) import UnliftIO.STM deleteContactCIs :: DB.Connection -> User -> Contact -> IO () @@ -482,7 +481,7 @@ 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 -> VersionRange -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] +getChatPreviews :: DB.Connection -> VersionRangeChat -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] getChatPreviews db vr user withPCC pagination query = do directChats <- findDirectChatPreviews_ db user pagination query groupChats <- findGroupChatPreviews_ db user pagination query @@ -715,7 +714,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = ) ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) -getGroupChatPreview_ :: DB.Connection -> VersionRange -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat +getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db vr user groupId lastItem <- case lastItemId_ of @@ -1040,7 +1039,7 @@ getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItem |] (userId, contactId, search, beforeChatItemId, count) -getGroupChat :: DB.Connection -> VersionRange -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChat db vr user groupId pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId @@ -1506,7 +1505,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRange -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] +getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of @@ -2150,7 +2149,7 @@ deleteLocalChatItem db User {userId} NoteFolder {noteFolderId} ci = do |] (userId, noteFolderId, itemId) -getChatItemByFileId :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO AChatItem +getChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO AChatItem getChatItemByFileId db vr user@User {userId} fileId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ @@ -2166,13 +2165,13 @@ getChatItemByFileId db vr user@User {userId} fileId = do (userId, fileId) getAChatItem db vr user chatRef itemId -lookupChatItemByFileId :: DB.Connection -> VersionRange -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) +lookupChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) lookupChatItemByFileId db vr user fileId = do fmap Just (getChatItemByFileId db vr user fileId) `catchError` \case SEChatItemNotFoundByFileId {} -> pure Nothing e -> throwError e -getChatItemByGroupId :: DB.Connection -> VersionRange -> User -> GroupId -> ExceptT StoreError IO AChatItem +getChatItemByGroupId :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO AChatItem getChatItemByGroupId db vr user@User {userId} groupId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByGroupId groupId) $ @@ -2198,7 +2197,7 @@ getChatRefViaItemId db User {userId} itemId = do (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId (_, _) -> Left $ SEBadChatItem itemId Nothing -getAChatItem :: DB.Connection -> VersionRange -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem +getAChatItem :: DB.Connection -> VersionRangeChat -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem getAChatItem db vr user chatRef itemId = case chatRef of ChatRef CTDirect contactId -> do ct <- getContact db user contactId diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index ca1240d307..c4611f4b9e 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -323,7 +323,7 @@ createUserContactLink db User {userId} agentConnId cReq subMode = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode False getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] getUserAddressConnections db User {userId} = do @@ -338,7 +338,7 @@ getUserAddressConnections db User {userId} = do [sql| SELECT 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.pq_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -354,7 +354,7 @@ getUserContactLinks db User {userId} = [sql| SELECT 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.pq_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 65bf359cd4..e961c4bcd0 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -148,12 +148,12 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Maybe Bool, Int, Version, Version) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Maybe PQFlag, Maybe PQFlag, Maybe PQFlag, Int, VersionChat, VersionChat) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Bool, Maybe Int, Maybe Version, Maybe Version) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQFlag, Maybe PQFlag, Maybe PQFlag, Maybe Int, Maybe VersionChat, Maybe VersionChat) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqEnabled, authErrCounter, minVer, maxVer)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, enablePQ_, pqSndEnabled, pqRcvEnabled, authErrCounter, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, @@ -170,7 +170,9 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup localAlias, entityId = entityId_ connType, connectionCode = SecurityCode <$> code_ <*> verifiedAt_, - pqEnabled, + enablePQ = fromMaybe False enablePQ_, + pqSndEnabled, + pqRcvEnabled, authErrCounter, createdAt } @@ -183,12 +185,12 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, pqEnabled_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqEnabled_, authErrCounter, minVer, maxVer)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, enablePQ_, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, enablePQ_, pqSndEnabled_, pqRcvEnabled_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection -createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQFlag -> IO Connection +createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode enablePQ = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId @@ -198,12 +200,12 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + peer_chat_min_version, peer_chat_max_version, to_subscribe, enable_pq + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (minV, maxV, subMode == SMOnlyCreate) + :. (minV, maxV, subMode == SMOnlyCreate, enablePQ) ) connId <- insertedRowId db pure @@ -224,7 +226,9 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange localAlias = "", createdAt = currentTs, connectionCode = Nothing, - pqEnabled = Nothing, + enablePQ, + pqSndEnabled = Nothing, + pqRcvEnabled = Nothing, authErrCounter = 0 } where @@ -241,18 +245,40 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag (displayName, fullName, image, userId, Just True, createdAt, createdAt) insertedRowId db -updateConnPQEnabled :: DB.Connection -> Int64 -> Bool -> IO () -updateConnPQEnabled db connId pqEnabled = +updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQFlag -> IO () +updateConnPQSndEnabled db connId pqSndEnabled = DB.execute db [sql| UPDATE connections - SET pq_enabled = ? + SET pq_snd_enabled = ? WHERE connection_id = ? |] - (pqEnabled, connId) + (pqSndEnabled, connId) -setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () +updateConnPQRcvEnabled :: DB.Connection -> Int64 -> PQFlag -> IO () +updateConnPQRcvEnabled db connId pqRcvEnabled = + DB.execute + db + [sql| + UPDATE connections + SET pq_rcv_enabled = ? + WHERE connection_id = ? + |] + (pqRcvEnabled, connId) + +updateConnPQEnabledCON :: DB.Connection -> Int64 -> PQFlag -> IO () +updateConnPQEnabledCON db connId pqEnabled = + DB.execute + db + [sql| + UPDATE connections + SET pq_snd_enabled = ?, pq_rcv_enabled = ? + WHERE connection_id = ? + |] + (pqEnabled, pqEnabled, connId) + +setPeerChatVRange :: DB.Connection -> Int64 -> VersionRangeChat -> IO () setPeerChatVRange db connId (VersionRange minVer maxVer) = DB.execute db @@ -263,7 +289,7 @@ setPeerChatVRange db connId (VersionRange minVer maxVer) = |] (minVer, maxVer, connId) -setMemberChatVRange :: DB.Connection -> GroupMemberId -> VersionRange -> IO () +setMemberChatVRange :: DB.Connection -> GroupMemberId -> VersionRangeChat -> IO () setMemberChatVRange db mId (VersionRange minVer maxVer) = DB.execute db @@ -350,7 +376,7 @@ getProfileById db userId profileId = toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, contactLink, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime, Version, Version) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) toContactRequest :: ContactRequestRow -> UserContactRequest toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, preferences, createdAt, updatedAt, minVer, maxVer)) = do diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 6376d50c26..05b4bf46ed 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -38,6 +38,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) +import Data.Word (Word16) import Database.SQLite.Simple (ResultError (..), SQLData (..)) import Database.SQLite.Simple.FromField (FromField (..), returnError) import Database.SQLite.Simple.Internal (Field (..)) @@ -53,6 +54,58 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextFie import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version +import Simplex.Messaging.Version.Internal + +-- TODO PQ replace with actual instances +instance Eq (ConnectionRequestUri m) where _ == _ = True + +instance Eq (APartyCmdTag p) where + t1 == t2 = case (t1, t2) of + (APCT SAEConn NEW_, APCT SAEConn NEW_) -> True + (APCT SAEConn INV_, APCT SAEConn INV_) -> True + (APCT SAEConn JOIN_, APCT SAEConn JOIN_) -> True + (APCT SAEConn CONF_, APCT SAEConn CONF_) -> True + (APCT SAEConn LET_, APCT SAEConn LET_) -> True + (APCT SAEConn REQ_, APCT SAEConn REQ_) -> True + (APCT SAEConn ACPT_, APCT SAEConn ACPT_) -> True + (APCT SAEConn RJCT_, APCT SAEConn RJCT_) -> True + (APCT SAEConn INFO_, APCT SAEConn INFO_) -> True + (APCT SAEConn CON_, APCT SAEConn CON_) -> True + (APCT SAEConn SUB_, APCT SAEConn SUB_) -> True + (APCT SAEConn END_, APCT SAEConn END_) -> True + (APCT SAENone CONNECT_, APCT SAENone CONNECT_) -> True + (APCT SAENone DISCONNECT_, APCT SAENone DISCONNECT_) -> True + (APCT SAENone DOWN_, APCT SAENone DOWN_) -> True + (APCT SAENone UP_, APCT SAENone UP_) -> True + (APCT SAEConn SWITCH_, APCT SAEConn SWITCH_) -> True + (APCT SAEConn RSYNC_, APCT SAEConn RSYNC_) -> True + (APCT SAEConn SEND_, APCT SAEConn SEND_) -> True + (APCT SAEConn MID_, APCT SAEConn MID_) -> True + (APCT SAEConn SENT_, APCT SAEConn SENT_) -> True + (APCT SAEConn MERR_, APCT SAEConn MERR_) -> True + (APCT SAEConn MERRS_, APCT SAEConn MERRS_) -> True + (APCT SAEConn MSG_, APCT SAEConn MSG_) -> True + (APCT SAEConn MSGNTF_, APCT SAEConn MSGNTF_) -> True + (APCT SAEConn ACK_, APCT SAEConn ACK_) -> True + (APCT SAEConn RCVD_, APCT SAEConn RCVD_) -> True + (APCT SAEConn SWCH_, APCT SAEConn SWCH_) -> True + (APCT SAEConn OFF_, APCT SAEConn OFF_) -> True + (APCT SAEConn DEL_, APCT SAEConn DEL_) -> True + (APCT SAEConn DEL_RCVQ_, APCT SAEConn DEL_RCVQ_) -> True + (APCT SAEConn DEL_CONN_, APCT SAEConn DEL_CONN_) -> True + (APCT SAENone DEL_USER_, APCT SAENone DEL_USER_) -> True + (APCT SAEConn CHK_, APCT SAEConn CHK_) -> True + (APCT SAEConn STAT_, APCT SAEConn STAT_) -> True + (APCT SAEConn OK_, APCT SAEConn OK_) -> True + (APCT SAEConn ERR_, APCT SAEConn ERR_) -> True + (APCT SAENone SUSPENDED_, APCT SAENone SUSPENDED_) -> True + (APCT SAERcvFile RFDONE_, APCT SAERcvFile RFDONE_) -> True + (APCT SAERcvFile RFPROG_, APCT SAERcvFile RFPROG_) -> True + (APCT SAERcvFile RFERR_, APCT SAERcvFile RFERR_) -> True + (APCT SAESndFile SFPROG_, APCT SAESndFile SFPROG_) -> True + (APCT SAESndFile SFDONE_, APCT SAESndFile SFDONE_) -> True + (APCT SAESndFile SFERR_, APCT SAESndFile SFERR_) -> True + _ -> False class IsContact a where contactId' :: a -> ContactId @@ -212,9 +265,7 @@ contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode =<< activeConn contactPQEnabled :: Contact -> Bool -contactPQEnabled Contact {activeConn} = case activeConn of - Just Connection {pqEnabled} -> pqEnabled == Just True - Nothing -> False +contactPQEnabled Contact {activeConn} = maybe False connPQEnabled activeConn data ContactStatus = CSActive @@ -706,7 +757,7 @@ memberConn GroupMember {activeConn} = activeConn memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn -memberChatVRange' :: GroupMember -> VersionRange +memberChatVRange' :: GroupMember -> VersionRangeChat memberChatVRange' GroupMember {activeConn, memberChatVRange} = fromJVersionRange $ case activeConn of Just Connection {peerChatVRange} -> peerChatVRange @@ -1302,7 +1353,9 @@ data Connection = Connection localAlias :: Text, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID connectionCode :: Maybe SecurityCode, - pqEnabled :: Maybe PQFlag, + enablePQ :: PQFlag, + pqSndEnabled :: Maybe PQFlag, + pqRcvEnabled :: Maybe PQFlag, authErrCounter :: Int, createdAt :: UTCTime } @@ -1337,6 +1390,10 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId +connPQEnabled :: Connection -> Bool +connPQEnabled Connection {pqSndEnabled, pqRcvEnabled} = + pqSndEnabled == Just True && pqRcvEnabled == Just True + data PendingContactConnection = PendingContactConnection { pccConnId :: Int64, pccAgentConnId :: AgentConnId, @@ -1625,10 +1682,24 @@ data ServerCfg p = ServerCfg } deriving (Show) -newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) +data ChatVersion -chatInitialVRange :: VersionRange -chatInitialVRange = versionToRange 1 +instance VersionScope ChatVersion + +type VersionChat = Version ChatVersion + +type VersionRangeChat = VersionRange ChatVersion + +pattern VersionChat :: Word16 -> VersionChat +pattern VersionChat v = Version v + +newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRangeChat} deriving (Eq, Show) + +initialChatVersion :: VersionChat +initialChatVersion = VersionChat 1 + +chatInitialVRange :: VersionRangeChat +chatInitialVRange = versionToRange initialChatVersion instance FromJSON ChatVersionRange where parseJSON v = ChatVersionRange <$> strParseJSON "ChatVersionRange" v @@ -1637,7 +1708,7 @@ instance ToJSON ChatVersionRange where toJSON (ChatVersionRange vr) = strToJSON vr toEncoding (ChatVersionRange vr) = strToJEncoding vr -newtype JVersionRange = JVersionRange {fromJVersionRange :: VersionRange} deriving (Eq, Show) +newtype JVersionRange = JVersionRange {fromJVersionRange :: VersionRangeChat} deriving (Eq, Show) instance FromJSON JVersionRange where parseJSON = J.withObject "JVersionRange" $ \o -> do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index de249cec4a..44bbc007bf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -49,7 +49,7 @@ import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (.. import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Chat.Types.Preferences -import qualified Simplex.FileTransfer.Protocol as XFTP +import qualified Simplex.FileTransfer.Transport as XFTPTransport import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..)) import Simplex.Messaging.Agent.Protocol @@ -1134,7 +1134,7 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case Just ProtocolTestFailure {testStep, testError} -> result <> [pName <> " server requires authorization to create queues, check password" | testStep == TSCreateQueue && testError == SMP SMP.AUTH] - <> [pName <> " server requires authorization to upload files, check password" | testStep == TSCreateFile && testError == XFTP XFTP.AUTH] + <> [pName <> " server requires authorization to upload files, check password" | testStep == TSCreateFile && testError == XFTP XFTPTransport.AUTH] <> ["Possibly, certificate fingerprint in " <> pName <> " server address is incorrect" | testStep == TSConnect && brokerErr] where result = [pName <> " server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> plain (strEncode testError)] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 13c814d3c5..153f7050ab 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -3,6 +3,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -30,20 +31,25 @@ import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Terminal import Simplex.Chat.Terminal.Output (newChatTerminal) -import Simplex.Chat.Types (AgentUserId (..), Profile, User (..)) +import Simplex.Chat.Types import Simplex.FileTransfer.Description (kb, mb) import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration) import Simplex.Messaging.Agent.Env.SQLite +import Simplex.Messaging.Agent.Protocol (pattern VersionSMPA) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) +import Simplex.Messaging.Crypto.Ratchet (pattern VersionE2E) +import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Agent.Protocol (supportedSMPAgentVRange) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Server (defaultTransportServerConfig) import Simplex.Messaging.Version +import Simplex.Messaging.Version.Internal import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) import System.FilePath (()) import qualified System.Terminal as C @@ -136,9 +142,9 @@ testCfg = testAgentCfgVPrev :: AgentConfig testAgentCfgVPrev = testAgentCfg - { smpAgentVRange = prevRange $ smpAgentVRange testAgentCfg, - smpClientVRange = prevRange $ smpClientVRange testAgentCfg, - e2eEncryptVRange = prevRange $ e2eEncryptVRange testAgentCfg, + { smpClientVRange = prevRange $ smpClientVRange testAgentCfg, + smpAgentVRange = \_ -> prevRange $ supportedSMPAgentVRange CR.PQEncOff, + e2eEncryptVRange = \_ -> prevRange $ CR.supportedE2EEncryptVRange CR.PQEncOff, smpCfg = (smpCfg testAgentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg testAgentCfg} } @@ -146,9 +152,9 @@ testAgentCfgV1 :: AgentConfig testAgentCfgV1 = testAgentCfg { smpClientVRange = v1Range, - smpAgentVRange = versionToRange 2, -- duplexHandshakeSMPAgentVersion, - e2eEncryptVRange = versionToRange 2, -- kdfX3DHE2EEncryptVersion, - smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange 4} -- batchCmdsSMPVersion + smpAgentVRange = \_ -> versionToRange (VersionSMPA 2), -- duplexHandshakeSMPAgentVersion, + e2eEncryptVRange = \_ -> versionToRange (VersionE2E 2), -- kdfX3DHE2EEncryptVersion, + smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange batchCmdsSMPVersion} } testCfgVPrev :: ChatConfig @@ -165,11 +171,14 @@ testCfgV1 = agentConfig = testAgentCfgV1 } -prevRange :: VersionRange -> VersionRange -prevRange vr = vr {maxVersion = max (minVersion vr) (maxVersion vr - 1)} +prevRange :: VersionRange v -> VersionRange v +prevRange vr = vr {maxVersion = max (minVersion vr) (prevVersion $ maxVersion vr)} -v1Range :: VersionRange -v1Range = mkVersionRange 1 1 +v1Range :: VersionRange v +v1Range = mkVersionRange (Version 1) (Version 1) + +prevVersion :: Version v -> Version v +prevVersion (Version v) = Version (v - 1) testCfgCreateGroupDirect :: ChatConfig testCfgCreateGroupDirect = @@ -178,8 +187,8 @@ testCfgCreateGroupDirect = mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} -groupCreateDirectVRange :: VersionRange -groupCreateDirectVRange = mkVersionRange 1 1 +groupCreateDirectVRange :: VersionRangeChat +groupCreateDirectVRange = mkVersionRange (VersionChat 1) (VersionChat 1) testCfgGroupLinkViaContact :: ChatConfig testCfgGroupLinkViaContact = @@ -188,8 +197,8 @@ testCfgGroupLinkViaContact = mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} -groupLinkViaContactVRange :: VersionRange -groupLinkViaContactVRange = mkVersionRange 1 2 +groupLinkViaContactVRange :: VersionRangeChat +groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do @@ -318,7 +327,8 @@ getTermLine cc = _ -> error "no output for 5 seconds" userName :: TestCC -> IO [Char] -userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (T.unpack . localDisplayName) <$> readTVarIO currentUser +userName (TestCC ChatController {currentUser} _ _ _ _ _) = + maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index 8492ab0f0d..7f02fafc2c 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -199,14 +199,14 @@ testPaginationAllChatTypes = ts7 <- iso8601Show <$> getCurrentTime - getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "")] + getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr)] getChats_ alice ("after=" <> ts2 <> " count=2") [(":3", ""), ("<@cath", "")] - getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", ""), (":3", "")] - getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", "")] + getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", e2eeInfoNoPQStr), (":3", "")] + getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", "")] getChats_ alice ("before=" <> ts4 <> " count=10") [(":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", ""), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", e2eeInfoNoPQStr), (":3", ""), ("<@cath", ""), ("@bob", "hey")] getChats_ alice ("after=" <> ts7 <> " count=10") [] getChats_ alice ("before=" <> ts1 <> " count=10") [] @@ -218,11 +218,11 @@ testPaginationAllChatTypes = alice ##> "/_settings #1 {\"enableNtfs\":\"all\",\"favorite\":true}" alice <## "ok" - getChats_ alice queryFavorite [("#team", ""), ("@bob", "hey")] + getChats_ alice queryFavorite [("#team", e2eeInfoNoPQStr), ("@bob", "hey")] getChats_ alice ("before=" <> ts4 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "")] + getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", e2eeInfoNoPQStr)] getChats_ alice ("after=" <> ts1 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "")] + getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", e2eeInfoNoPQStr)] let queryUnread = "{\"type\": \"filters\", \"favorite\": false, \"unread\": true}" diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 44bfb543f6..0bb579853f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE RankNTypes #-} @@ -22,7 +23,7 @@ import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) -import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode) +import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version @@ -105,8 +106,10 @@ chatDirectTests = do it "mark group member verified" testMarkGroupMemberVerified describe "message errors" $ do it "show message decryption error" testMsgDecryptError - it "should report ratchet de-synchronization, synchronize ratchets" testSyncRatchet - it "synchronize ratchets, reset connection code" testSyncRatchetCodeReset + skip "TODO PQ ratchet synchronization" $ + describe "TODO sporadically fail with unexpected \"post-quantum encryption enabled\" output" $ do + it "should report ratchet de-synchronization, synchronize ratchets" testSyncRatchet + it "synchronize ratchets, reset connection code" testSyncRatchetCodeReset describe "message reactions" $ do it "set message reactions" testSetMessageReactions describe "delivery receipts" $ do @@ -633,13 +636,13 @@ testDirectLiveMessage = connectUsers alice bob -- non-empty live message is sent instantly alice `send` "/live @bob hello" - bob <# "alice> [LIVE started] use /show [on/off/6] hello" + bob <# "alice> [LIVE started] use /show [on/off/7] hello" alice ##> ("/_update item @2 " <> itemId 1 <> " text hello there") alice <# "@bob [LIVE] hello there" bob <# "alice> [LIVE ended] hello there" -- empty live message is also sent instantly alice `send` "/live @bob" - bob <# "alice> [LIVE started] use /show [on/off/7]" + bob <# "alice> [LIVE started] use /show [on/off/8]" alice ##> ("/_update item @2 " <> itemId 2 <> " text hello 2") alice <# "@bob [LIVE] hello 2" bob <# "alice> [LIVE ended] hello 2" @@ -2083,15 +2086,16 @@ testUserPrivacy = alice <##? chatHistory alice ##> "/_get items count=10" alice <##? chatHistory - alice ##> "/_get items before=11 count=10" + alice ##> "/_get items before=13 count=10" alice - <##? [ "bob> Disappearing messages: allowed", + <##? [ ConsoleString ("bob> " <> e2eeInfoNoPQStr), + "bob> Disappearing messages: allowed", "bob> Full deletion: off", "bob> Message reactions: enabled", "bob> Voice messages: enabled", "bob> Audio/video calls: enabled" ] - alice ##> "/_get items after=10 count=10" + alice ##> "/_get items after=12 count=10" alice <##? [ "@bob hello", "bob> hey", @@ -2155,7 +2159,8 @@ testUserPrivacy = alice <## "messages are shown" alice <## "profile is visible" chatHistory = - [ "bob> Disappearing messages: allowed", + [ ConsoleString ("bob> " <> e2eeInfoNoPQStr), + "bob> Disappearing messages: allowed", "bob> Full deletion: off", "bob> Message reactions: enabled", "bob> Voice messages: enabled", @@ -2269,7 +2274,7 @@ testSwitchGroupMember = alice <## "#team: you started changing address for bob" bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" bob <# "#team alice> hey" @@ -2300,7 +2305,7 @@ testAbortSwitchGroupMember tmp = do bob <## "#team: alice started changing address for you" bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "started changing address for bob..."), (1, "started changing address for bob..."), (1, "you changed address for bob")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "started changing address for bob..."), (1, "started changing address for bob..."), (1, "you changed address for bob")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" bob <# "#team alice> hey" @@ -2654,7 +2659,7 @@ testConfigureDeliveryReceipts tmp = cc2 <# (name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> FilePath -> IO () +testConnInvChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () testConnInvChatVRange ct1VRange ct2VRange tmp = withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do @@ -2666,7 +2671,7 @@ testConnInvChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testConnReqChatVRange :: HasCallStack => VersionRange -> VersionRange -> FilePath -> IO () +testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () testConnReqChatVRange ct1VRange ct2VRange tmp = withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do @@ -2738,10 +2743,10 @@ testGetNetworkStatuses tmp = do where cfg = testCfg {coreApi = True} -vr11 :: VersionRange -vr11 = mkVersionRange 1 1 +vr11 :: VersionRangeChat +vr11 = mkVersionRange (VersionChat 1) (VersionChat 1) -contactInfoChatVRange :: TestCC -> VersionRange -> IO () +contactInfoChatVRange :: TestCC -> VersionRangeChat -> IO () contactInfoChatVRange cc (VersionRange minVer maxVer) = do cc <## "contact ID: 2" cc <## "receiving messages via: localhost" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 3057fa7b70..16e26ac3ab 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -14,9 +14,8 @@ import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) -import Simplex.Chat.Types (GroupMemberRole (..)) +import Simplex.Chat.Types (GroupMemberRole (..), VersionRangeChat) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Messaging.Version import System.Directory (copyFile) import System.FilePath (()) import Test.Hspec hiding (it) @@ -336,11 +335,11 @@ testGroupShared alice bob cath checkMessages directConnections = do getReadChats :: HasCallStack => String -> String -> IO () getReadChats msgItem1 msgItem2 = do alice @@@ [("#team", "hey team"), ("@cath", "sent invitation to join group team as admin"), ("@bob", "sent invitation to join group team as admin")] - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) -- "before" and "after" define a chat item id across all chats, -- so we take into account group event items as well as sent group invitations in direct chats alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")]) - alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) + alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) @@ -499,9 +498,10 @@ testGroup2 = dan <##> cath dan <##> alice -- show last messages - alice ##> "/t #club 8" + alice ##> "/t #club 9" alice -- these strings are expected in any order because of sorting by time and rounding of time for sent - <##? [ "#club bob> connected", + <##? [ ConsoleString ("#club " <> e2eeInfoNoPQStr), + "#club bob> connected", "#club cath> connected", "#club bob> added dan (Daniel)", "#club dan> connected", @@ -1858,7 +1858,7 @@ testGroupLink = bob <## "#team: you joined the group" ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) -- contacts connected via group link are not in chat previews alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -2697,7 +2697,7 @@ testGroupLinkNoContact = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -2760,7 +2760,7 @@ testGroupLinkNoContactInviteesWereConnected = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected"), ("@cath", "hey")] @@ -2841,7 +2841,7 @@ testGroupLinkNoContactAllMembersWereConnected = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (1, "Recent history: off"), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected"), ("@bob", "hey"), ("@cath", "hey")] bob @@@ [("#team", "connected"), ("@alice", "hey"), ("@cath", "hey")] @@ -2996,7 +2996,7 @@ testGroupLinkNoContactHostIncognito = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -3029,7 +3029,7 @@ testGroupLinkNoContactInviteeIncognito = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] @@ -3096,7 +3096,7 @@ testGroupLinkNoContactExistingContactMerged = ] threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "invited via your group link"), (0, "connected")]) alice <##> bob @@ -3579,7 +3579,7 @@ testConfigureGroupDeliveryReceipts tmp = cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc1 VersionRange -> VersionRange -> VersionRange -> Bool -> FilePath -> IO () +testNoGroupDirectConns :: HasCallStack => VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> FilePath -> IO () testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do @@ -5050,8 +5050,7 @@ testGroupHistoryDeletedMessage = testGroupHistoryDisappearingMessage :: HasCallStack => FilePath -> IO () testGroupHistoryDisappearingMessage = testChat3 aliceProfile bobProfile cathProfile $ - -- \alice bob cath -> do -- revert when test is stable - \a b c -> withTestOutput a $ \alice -> withTestOutput b $ \bob -> withTestOutput c $ \cath -> do + \alice bob cath -> do createGroup2 "team" alice bob threadDelay 1000000 diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 30c78138ad..7996fde3ad 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1509,7 +1509,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice ##> "/_set prefs @2 {}" alice <## "your preferences for bob did not change" (bob ("/_get chat @2 count=100", chat, startFeatures) bob #$> ("/_get chat @2 count=100", chat, startFeatures) let sendVoice = "/_send @2 json {\"filePath\": \"test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}" @@ -1608,13 +1608,13 @@ testUpdateGroupPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected")]) threadDelay 500000 bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected")]) alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"on\"}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" alice <## "updated group preferences:" alice <## "Full deletion: on" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" @@ -1624,7 +1624,7 @@ testUpdateGroupPrefs = alice <## "updated group preferences:" alice <## "Full deletion: off" alice <## "Voice messages: off" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: off" @@ -1634,7 +1634,7 @@ testUpdateGroupPrefs = alice ##> "/set voice #team on" alice <## "updated group preferences:" alice <## "Voice messages: on" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Voice messages: on" @@ -1644,14 +1644,14 @@ testUpdateGroupPrefs = alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" -- no update threadDelay 500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) alice #> "#team hey" bob <# "#team alice> hey" threadDelay 1000000 bob #> "#team hi" alice <# "#team bob> hi" threadDelay 500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on"), (0, "hey"), (1, "hi")]) testAllowFullDeletionContact :: HasCallStack => FilePath -> IO () @@ -1677,7 +1677,7 @@ testAllowFullDeletionGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob - threadDelay 1000000 + threadDelay 1500000 alice #> "#team hi" bob <# "#team alice> hi" threadDelay 1000000 @@ -1691,11 +1691,11 @@ testAllowFullDeletionGroup = bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "hi"), (0, "hey"), (1, "Full deletion: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "hi"), (0, "hey"), (1, "Full deletion: on")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (1, "hey"), (0, "Full deletion: on")]) bob #$> ("/_delete item #1 " <> msgItemId <> " broadcast", id, "message deleted") alice <# "#team bob> [deleted] hey" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "hi"), (1, "Full deletion: on")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "hi"), (1, "Full deletion: on")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (0, "Full deletion: on")]) testProhibitDirectMessages :: HasCallStack => FilePath -> IO () @@ -1817,12 +1817,12 @@ testEnableTimedMessagesGroup = alice #> "#team hi" bob <# "#team alice> hi" threadDelay 500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "hi")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "hi")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)"), (0, "hi")]) threadDelay 1000000 alice <## "timed message deleted: hi" bob <## "timed message deleted: hi" - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Disappearing messages: on (1 sec)")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Disappearing messages: on (1 sec)")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)")]) -- turn off, messages are not disappearing alice ##> "/set disappear #team off" @@ -1835,7 +1835,7 @@ testEnableTimedMessagesGroup = alice #> "#team hey" bob <# "#team alice> hey" threadDelay 1500000 - alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "Disappearing messages: off"), (1, "hey")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "Disappearing messages: off"), (1, "hey")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)"), (0, "Disappearing messages: off"), (0, "hey")]) -- test api alice ##> "/set disappear #team on 30s" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index b7cf46766a..e98d05de33 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -20,6 +20,7 @@ import Data.String import qualified Data.Text as T import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) +import Simplex.Chat.Messages.CIContent (e2eeInfoNoPQText) import Simplex.Chat.Protocol import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) @@ -76,24 +77,29 @@ ifCI xrun run d t = do ci <- runIO $ lookupEnv "CI" (if ci == Just "true" then xrun else run) d t +skip :: String -> SpecWith a -> SpecWith a +skip = before_ . pendingWith + versionTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> IO ()) -> SpecWith FilePath versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile runTest - it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest - it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest - it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest - it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest - it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest - it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest + skip "TODO PQ versioning" $ describe "TODO fails with previous version" $ do + it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest + it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest + it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest + it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest + it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest + it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath versionTestMatrix3 runTest = do it "current" $ testChat3 aliceProfile bobProfile cathProfile runTest - it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest - it "prev to curr" $ runTestCfg3 testCfg testCfgVPrev testCfgVPrev runTest - it "curr+prev to curr" $ runTestCfg3 testCfg testCfg testCfgVPrev runTest - it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest - it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest + skip "TODO PQ versioning" $ describe "TODO fails with previous version" $ do + it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest + it "prev to curr" $ runTestCfg3 testCfg testCfgVPrev testCfgVPrev runTest + it "curr+prev to curr" $ runTestCfg3 testCfg testCfg testCfgVPrev runTest + it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest + it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () runTestCfg2 aliceCfg bobCfg runTest tmp = @@ -189,13 +195,17 @@ chatFeaturesF = map (\(a, _, c) -> (a, c)) chatFeatures'' chatFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] chatFeatures'' = - [ ((0, "Disappearing messages: allowed"), Nothing, Nothing), + [ ((0, e2eeInfoNoPQStr), Nothing, Nothing), + ((0, "Disappearing messages: allowed"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Message reactions: enabled"), Nothing, Nothing), ((0, "Voice messages: enabled"), Nothing, Nothing), ((0, "Audio/video calls: enabled"), Nothing, Nothing) ] +e2eeInfoNoPQStr :: String +e2eeInfoNoPQStr = T.unpack e2eeInfoNoPQText + lastChatFeature :: String lastChatFeature = snd $ last chatFeatures @@ -204,7 +214,8 @@ groupFeatures = map (\(a, _, _) -> a) groupFeatures'' groupFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] groupFeatures'' = - [ ((0, "Disappearing messages: off"), Nothing, Nothing), + [ ((0, e2eeInfoNoPQStr), Nothing, Nothing), + ((0, "Disappearing messages: off"), Nothing, Nothing), ((0, "Direct messages: on"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Message reactions: on"), Nothing, Nothing), @@ -575,7 +586,7 @@ currentChatVRangeInfo :: String currentChatVRangeInfo = "peer chat protocol version range: " <> vRangeStr supportedChatVRange -vRangeStr :: VersionRange -> String +vRangeStr :: VersionRange v -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" linkAnotherSchema :: String -> String diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 822b079e9e..8236215c4f 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -14,6 +14,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (supportedSMPClientVRange) import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Version @@ -39,7 +40,7 @@ connReqData :: ConnReqUriData connReqData = ConnReqUriData { crScheme = SSSimplex, - crAgentVRange = mkVersionRange 1 1, + crAgentVRange = mkVersionRange (VersionSMPA 1) (VersionSMPA 1), crSmpQueues = [queue], crClientData = Nothing } @@ -47,8 +48,8 @@ connReqData = testDhPubKey :: C.PublicKeyX448 testDhPubKey = "MEIwBQYDK2VvAzkAmKuSYeQ/m0SixPDS8Wq8VBaTS1cW+Lp0n0h4Diu+kUpR+qXx4SDJ32YGEFoGFGSbGPry5Ychr6U=" -testE2ERatchetParams :: E2ERatchetParamsUri 'C.X448 -testE2ERatchetParams = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKey testDhPubKey +testE2ERatchetParams :: RcvE2ERatchetParamsUri 'C.X448 +testE2ERatchetParams = E2ERatchetParamsUri (supportedE2EEncryptVRange CR.PQEncOn) testDhPubKey testDhPubKey Nothing testConnReq :: ConnectionRequestUri 'CMInvitation testConnReq = CRInvitationUri connReqData testE2ERatchetParams @@ -192,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -201,7 +202,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -228,10 +229,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -252,16 +253,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -282,10 +283,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") it "x.grp.direct.inv without content" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing -- it "x.grp.msg.forward" -- $ "{\"v\":\"1\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" From 64dc758ffd82e8c7d250806f6d1790832334e4e2 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 6 Mar 2024 16:02:19 +0200 Subject: [PATCH 25/64] core: compressed message encoding, variable vrange (#3844) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 205 +++++++++++++++++++++------------ src/Simplex/Chat/Controller.hs | 3 +- src/Simplex/Chat/Protocol.hs | 50 ++++++-- tests/ChatClient.hs | 11 +- tests/ChatTests/Direct.hs | 37 +++--- tests/ChatTests/Groups.hs | 14 ++- tests/ChatTests/Utils.hs | 28 ++--- tests/MessageBatching.hs | 4 +- tests/ProtocolTests.hs | 12 +- 11 files changed, 230 insertions(+), 138 deletions(-) diff --git a/cabal.project b/cabal.project index 80f94af705..64e2e5e447 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c280f942ba3d96d48db30ccc3a23d51a7b5fed41 + tag: e04705d9c5e6b3d3652f909a5176c375acf29411 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 146b45cfcf..6c79acf47b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c280f942ba3d96d48db30ccc3a23d51a7b5fed41" = "04aq4mv2q3v5yfbnj9ajylpjvq7hl1hgj5jiwg90rkc6nl3a7dvz"; + "https://github.com/simplex-chat/simplexmq.git"."317f2d5552332eb5d26a15ede87887e59408a10b" = "1dc4nv5zcbv4712sjv0ncyswdcx4igwzhgybx1rd9x6a7mwv2kr5"; "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 f96d3e8a18..aa854094ee 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -22,6 +22,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader +import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A @@ -97,9 +98,11 @@ import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client (defaultNetworkConfig) +import Simplex.Messaging.Compression (withCompressCtx) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -364,7 +367,8 @@ startChatController mainApp = do subscribeUsers :: forall m. ChatMonad' m => Bool -> [User] -> m () subscribeUsers onlyNeeded users = do let (us, us') = partition activeUser users - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff subscribe vr us subscribe vr us' where @@ -446,7 +450,9 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse -processChatCommand cmd = chatVersionRange >>= (`processChatCommand'` cmd) +processChatCommand cmd = + chatVersionRange PQEncOff -- TODO PQ this is only used to set membership version range (?) + >>= (`processChatCommand'` cmd) {-# INLINE processChatCommand #-} processChatCommand' :: forall m. ChatMonad m => VersionRangeChat -> ChatCommand -> m ChatResponse @@ -1416,8 +1422,8 @@ processChatCommand' vr = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing False - dm <- directMessage $ XInfo profileToSend enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + dm <- directMessagePQ (CR.PQEncryption enablePQ) maxConnInfoLength $ XInfo profileToSend connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm (CR.PQEncryption enablePQ) subMode conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode enablePQ pure $ CRSentConfirmation user conn @@ -2146,7 +2152,7 @@ processChatCommand' vr = \case where connect' groupLinkId cReqHash xContactId inGroup = do enablePQ <- (not inGroup &&) <$> (readTVarIO =<< asks pqExperimentalEnabled) - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup enablePQ + (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup (CR.PQEncryption enablePQ) conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode enablePQ pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse @@ -2154,18 +2160,18 @@ processChatCommand' vr = \case withChatLock "connectViaContact" $ do newXContactId <- XContactId <$> drgRandomBytes 16 enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False enablePQ + (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False (CR.PQEncryption enablePQ) let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode enablePQ pure $ CRSentInvitationToContact user ct' incognitoProfile - requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQFlag -> m (ConnId, Maybe Profile, SubscriptionMode) - requestContact user incognito cReq xContactId inGroup enablePQ = do + requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQEncryption -> m (ConnId, Maybe Profile, SubscriptionMode) + requestContact user incognito cReq xContactId inGroup pqEnc = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing inGroup - dm <- directMessage (XContact profileToSend $ Just xContactId) + dm <- directMessagePQ pqEnc maxConnInfoLength (XContact profileToSend $ Just xContactId) subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm (CR.PQEncryption enablePQ) subMode + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqEnc subMode pure (connId, incognitoProfile, subMode) contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = @@ -2190,15 +2196,18 @@ processChatCommand' vr = \case user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" . procCmd $ do - let changedCts = foldr (addChangedProfileContact user') [] contacts - idsEvts = map ctSndMsg changedCts - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - msgReqs_ <- zipWith (ctMsgReq enablePQ) changedCts <$> createSndMessages idsEvts - (errs, cts) <- partitionEithers . zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ - unless (null errs) $ toView $ CRChatErrors (Just user) errs - let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts - createContactsSndFeatureItems user' changedCts' - let summary = + let changedCts_ = L.nonEmpty $ foldr (addChangedProfileContact user') [] contacts + summary <- case changedCts_ of + Nothing -> pure $ UserProfileUpdateSummary 0 0 [] + Just changedCts -> do + let idsEvts = L.map ctSndMsg changedCts + enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + msgReqs_ <- L.zipWith (ctMsgReq enablePQ) changedCts <$> createSndMessages idsEvts + (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ + unless (null errs) $ toView $ CRChatErrors (Just user) errs + let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts + createContactsSndFeatureItems user' changedCts' + pure UserProfileUpdateSummary { updateSuccesses = length cts, updateFailures = length errs, @@ -2217,8 +2226,8 @@ processChatCommand' vr = \case mergedProfile = userProfileToSend user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileToSend user' Nothing (Just ct') False - ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, ChatMsgEvent 'Json) - ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, XInfo mergedProfile') + ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, PQEncryption, ChatMsgEvent 'Json) + ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId, enablePQ = enablePQConn}} = (ConnectionId connId, CR.PQEncryption enablePQConn, XInfo mergedProfile') ctMsgReq :: PQFlag -> ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq ctMsgReq enablePQ ChangedProfileContact {conn = conn@Connection {enablePQ = enablePQConn}} = fmap $ \SndMessage {msgId, msgBody} -> @@ -2725,7 +2734,8 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI unless (fileStatus == RFSNew) $ case fileStatus of RFSCancelled _ -> throwChatError $ CEFileCancelled fName _ -> throwChatError $ CEFileAlreadyReceiving fName - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff case (xftpRcvFile, fileConnReq) of -- direct file protocol (Nothing, Just connReq) -> do @@ -2764,7 +2774,8 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI acceptFile cmdFunction send = do filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff if | inline -> do -- accepting inline @@ -2811,7 +2822,8 @@ receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile startReceivingFile :: ChatMonad m => User -> FileTransferId -> m () startReceivingFile user fileId = do - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff ci <- withStoreCtx (Just "startReceivingFile, updateRcvFileStatus ...") $ \db -> do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 @@ -2856,8 +2868,8 @@ acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe Incog 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 False - dm <- directMessage $ XInfo profileToSend enablePQ <- readTVarIO =<< asks pqExperimentalEnabled + dm <- directMessagePQ (CR.PQEncryption enablePQ) maxConnInfoLength $ XInfo profileToSend acId <- withAgent $ \a -> acceptContact a True invId dm (CR.PQEncryption enablePQ) subMode withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode enablePQ contactUsed @@ -3182,7 +3194,8 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do ts <- liftIO getCurrentTime liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts waitChatStartedAndActivated - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff case cType of CTDirect -> do (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId @@ -3203,7 +3216,8 @@ startUpdatedTimedItemThread user chatRef ci ci' = expireChatItems :: forall m. ChatMonad m => User -> Int64 -> Bool -> m () expireChatItems user@User {userId} ttl sync = do currentTs <- liftIO getCurrentTime - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs @@ -3250,7 +3264,8 @@ processAgentMessage _ connId (DEL_RCVQ srv qId err_) = processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = do - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range (?) + vr <- chatVersionRange PQEncOff withStore' (`getUserByAConnId` AgentConnId connId) >>= \case Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) @@ -3289,7 +3304,8 @@ processAgentMsgSndFile _corrId aFileId msg = (ft@FileTransferMeta {fileId, xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> do fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId getSndFileTransfer db user fileId - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff unless cancelled $ case msg of SFPROG sndProgress sndTotal -> do let status = CIFSSndTransfer {sndProgress, sndTotal} @@ -3408,7 +3424,8 @@ processAgentMsgRcvFile _corrId aFileId msg = ft@RcvFileTransfer {fileId} <- withStore $ \db -> do fileId <- getXFTPRcvFileDBId db $ AgentRcvFileId aFileId getRcvFileTransfer db user fileId - vr <- chatVersionRange + -- TODO PQ this is only used to set membership version range + vr <- chatVersionRange PQEncOff unless (rcvFileCompleteOrCancelled ft) $ case msg of RFPROG rcvProgress rcvTotal -> do let status = CIFSRcvTransfer {rcvProgress, rcvTotal} @@ -5827,7 +5844,7 @@ parseChatMessage conn s = do sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do - vr <- chatVersionRange + vr <- chatVersionRange PQEncOff withStore' (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo Nothing -> do @@ -6012,51 +6029,83 @@ contactSendConn_ ct@Contact {activeConn} = case activeConn of sendDirectMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> CR.PQEncryption -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64, CR.PQEncryption) sendDirectMessage conn pqEnc chatMsgEvent connOrGroupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId pqEnc (msgDeliveryId, pqEnc') <- deliverMessage conn pqEnc (toCMEventTag chatMsgEvent) msgBody msgId pure (msg, msgDeliveryId, pqEnc') -createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage -createSndMessage chatMsgEvent connOrGroupId = - liftEither . runIdentity =<< createSndMessages (Identity (connOrGroupId, chatMsgEvent)) +createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> PQEncryption -> m SndMessage +createSndMessage chatMsgEvent connOrGroupId pqEnc = + liftEither . runIdentity =<< createSndMessages (Identity (connOrGroupId, pqEnc, chatMsgEvent)) -createSndMessages :: forall e m t. (MsgEncodingI e, ChatMonad' m, Traversable t) => t (ConnOrGroupId, ChatMsgEvent e) -> m (t (Either ChatError SndMessage)) +createSndMessages :: forall e m t. (MsgEncodingI e, ChatMonad' m, Traversable t) => t (ConnOrGroupId, PQEncryption, ChatMsgEvent e) -> m (t (Either ChatError SndMessage)) createSndMessages idsEvents = do - gVar <- asks random - vr <- chatVersionRange - withStoreBatch $ \db -> fmap (uncurry (createMsg db gVar vr)) idsEvents + g <- asks random + ChatConfig {chatVRange = vr} <- asks config + withStoreBatch $ \db -> fmap (createMsg db g vr) idsEvents where - createMsg db gVar chatVRange connOrGroupId evnt = runExceptT $ do - withExceptT ChatErrorStore $ createNewSndMessage db gVar connOrGroupId evnt (encodeMessage chatVRange evnt) - encodeMessage chatVRange evnt sharedMsgId = - encodeChatMessage ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent = evnt} + createMsg :: DB.Connection -> TVar ChaChaDRG -> (PQEncryption -> VersionRangeChat) -> (ConnOrGroupId, PQEncryption, ChatMsgEvent e) -> IO (Either ChatError SndMessage) + createMsg db g vr (connOrGroupId, pqEnc, evnt) = runExceptT $ do + withExceptT ChatErrorStore $ createNewSndMessage db g connOrGroupId evnt encodeMessage + where + encodeMessage sharedMsgId = + encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr pqEnc, msgId = Just sharedMsgId, chatMsgEvent = evnt} sendGroupMemberMessages :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> Connection -> NonEmpty (ChatMsgEvent e) -> GroupId -> m () -sendGroupMemberMessages user conn@Connection {connId} events groupId = do +sendGroupMemberMessages user conn events groupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - let idsEvts = L.map (GroupId groupId,) events + let idsEvts = L.map (GroupId groupId,PQEncOff,) events (errs, msgs) <- partitionEithers . L.toList <$> createSndMessages idsEvts unless (null errs) $ toView $ CRChatErrors (Just user) errs - unless (null msgs) $ do - let (errs', msgBatches) = partitionEithers $ batchMessages maxChatMsgSize msgs + forM_ (L.nonEmpty msgs) $ \msgs' -> do + -- TODO PQ based on version (?) + -- let shouldCompress = False + -- batched <- if shouldCompress then batchSndMessagesBinary msgs' else pure $ batchSndMessagesJSON msgs' + let batched = batchSndMessagesJSON msgs' + let (errs', msgBatches) = partitionEithers batched -- 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 batchBody sndMsgs) = do - (agentMsgId, _pqEnc) <- withAgent $ \a -> sendMessage a (aConnId conn) CR.PQEncOff MsgFlags {notification = True} batchBody - let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs + processSndMessageBatch conn batch `catchChatError` (toView . CRChatError (Just user)) + +processSndMessageBatch :: ChatMonad m => Connection -> MsgBatch -> m () +processSndMessageBatch conn@Connection {connId} (MsgBatch batchBody sndMsgs) = do + (agentMsgId, _pqEnc) <- withAgent $ \a -> sendMessage a (aConnId conn) CR.PQEncOff MsgFlags {notification = True} batchBody + let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} + void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs + +batchSndMessagesJSON :: NonEmpty SndMessage -> [Either ChatError MsgBatch] +batchSndMessagesJSON = batchMessages (maxEncodedMsgLength PQEncOff) . L.toList + +-- batchSndMessagesBinary :: forall m. ChatMonad m => NonEmpty SndMessage -> m [Either ChatError MsgBatch] +-- batchSndMessagesBinary msgs = do +-- compressed <- liftIO $ withCompressCtx maxChatMsgSize $ \cctx -> mapM (compressForBatch cctx) msgs +-- pure . map toMsgBatch . SMP.batchTransmissions_ (maxEncodedMsgLength PQEncOff) $ L.zip compressed msgs +-- where +-- compressForBatch cctx SndMessage {msgBody} = bimap (const TELargeMsg) smpEncode <$> compress cctx msgBody +-- toMsgBatch :: SMP.TransportBatch SndMessage -> Either ChatError MsgBatch +-- toMsgBatch = \case +-- SMP.TBTransmissions combined _n sms -> Right $ MsgBatch (markCompressedBatch combined) sms +-- SMP.TBError tbe SndMessage {msgId} -> Left . ChatError $ CEInternalError (show tbe <> " " <> show msgId) +-- SMP.TBTransmission {} -> Left . ChatError $ CEInternalError "batchTransmissions_ didn't produce a batch" directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString -directMessage chatMsgEvent = do - chatVRange <- chatVersionRange - let r = encodeChatMessage ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} +directMessage = directMessagePQ PQEncOff maxConnInfoLength + +-- TODO PQ check size after compression (in compressedBatchMsgBody_ ?) +directMessagePQ :: (MsgEncodingI e, ChatMonad m) => CR.PQEncryption -> (CR.PQEncryption -> Int) -> ChatMsgEvent e -> m ByteString +directMessagePQ pqEnc maxMsgSize chatMsgEvent = do + chatVRange <- chatVersionRange pqEnc + let shouldCompress = maxVersion chatVRange >= compressedBatchingVersion + r = encodeChatMessage maxMsgSize ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} case r of - ECMEncoded encodedBody -> pure encodedBody + ECMEncoded encodedBody + | shouldCompress -> compressedBatchMsgBody encodedBody + | otherwise -> pure encodedBody ECMLarge -> throwChatError $ CEException "large message" + where + compressedBatchMsgBody msgBody = + liftEitherError (ChatError . CEException . mappend "compressedBatchMsgBody: ") $ + withCompressCtx (B.length msgBody) (`compressedBatchMsgBody_` msgBody) deliverMessage :: ChatMonad m => Connection -> CR.PQEncryption -> CMEventTag e -> MsgBody -> MessageId -> m (Int64, CR.PQEncryption) deliverMessage conn pqEnc cmEventTag msgBody msgId = do @@ -6065,8 +6114,8 @@ deliverMessage conn pqEnc cmEventTag msgBody msgId = do deliverMessage' :: ChatMonad m => Connection -> CR.PQEncryption -> MsgFlags -> MsgBody -> MessageId -> m (Int64, CR.PQEncryption) deliverMessage' conn pqEnc msgFlags msgBody msgId = - deliverMessages [(conn, pqEnc, msgFlags, msgBody, msgId)] >>= \case - [r] -> liftEither r + deliverMessages ((conn, pqEnc, msgFlags, msgBody, msgId) :| []) >>= \case + r :| [] -> liftEither r rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) type MsgReq = (Connection, CR.PQEncryption, MsgFlags, MsgBody, MessageId) @@ -6076,15 +6125,23 @@ contactPQEnc Connection {enablePQ = enablePQConn} = do enablePQ <- readTVarIO =<< asks pqExperimentalEnabled pure $ CR.PQEncryption $ enablePQ && enablePQConn -deliverMessages :: ChatMonad' m => [MsgReq] -> m [Either ChatError (Int64, CR.PQEncryption)] -deliverMessages = deliverMessagesB . map Right +deliverMessages :: ChatMonad' m => NonEmpty MsgReq -> m (NonEmpty (Either ChatError (Int64, CR.PQEncryption))) +deliverMessages msgs = deliverMessagesB $ L.map Right msgs -deliverMessagesB :: ChatMonad' m => [Either ChatError MsgReq] -> m [Either ChatError (Int64, CR.PQEncryption)] +deliverMessagesB :: ChatMonad' m => NonEmpty (Either ChatError MsgReq) -> m (NonEmpty (Either ChatError (Int64, CR.PQEncryption))) deliverMessagesB msgReqs = do - sent <- zipWith prepareBatch msgReqs <$> withAgent' (\a -> sendMessagesB a $ map toAgent msgReqs) - void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights sent) - withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent + msgReqs' <- compressBodies + sent <- L.zipWith prepareBatch msgReqs' <$> withAgent' (`sendMessagesB` L.map toAgent msgReqs') + void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) + withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where + compressBodies = liftIO $ withCompressCtx maxRawMsgLength $ \cctx -> + forM msgReqs $ \case + mr@(Right (conn, pqEnc, msgFlags, msgBody, msgId)) + | pqEnc == CR.PQEncOn -> do + bimap (ChatError . CEException) (\cBody -> (conn, pqEnc, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody + | otherwise -> pure mr + skip -> pure skip toAgent = \case Right (conn, pqEnc, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEnc, msgFlags, msgBody) Left _ce -> Left (AP.INTERNAL "ChatError, skip") -- as long as it is Left, the agent batchers should just step over it @@ -6129,12 +6186,12 @@ sendGroupMessage user gInfo members chatMsgEvent = do 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) + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) PQEncOff recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} (toSend, pending) = foldr addMember ([], []) recipientMembers msgReqs = map (\(_, conn) -> (conn, CR.PQEncOff, msgFlags, msgBody, msgId)) toSend - delivered <- deliverMessages msgReqs + delivered <- maybe (pure []) (fmap L.toList . deliverMessages) $ L.nonEmpty 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 @@ -6187,7 +6244,7 @@ memberSendAction chatMsgEvent members m@GroupMember {invitedByGroupMemberId} = c 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) + msg <- createSndMessage chatMsgEvent (GroupId groupId) PQEncOff messageMember msg `catchChatError` (\e -> toView (CRChatError (Just user) e)) where messageMember :: SndMessage -> m () @@ -6359,16 +6416,16 @@ joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () -allowAgentConnectionAsync user conn@Connection {connId} confId msg = do +allowAgentConnectionAsync user conn@Connection {connId, enablePQ} confId msg = do cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn - dm <- directMessage msg + dm <- directMessagePQ (CR.PQEncryption enablePQ) maxConnInfoLength msg withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> CR.PQEncryption -> m (CommandId, ConnId) agentAcceptContactAsync user enableNtfs invId msg subMode pqEnc = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - dm <- directMessage msg + dm <- directMessagePQ pqEnc maxConnInfoLength msg connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqEnc subMode pure (cmdId, connId) @@ -6603,10 +6660,10 @@ waitChatStartedAndActivated = do activated <- readTVar chatActivated unless (isJust started && activated) retry -chatVersionRange :: ChatMonad' m => m VersionRangeChat -chatVersionRange = do +chatVersionRange :: ChatMonad' m => CR.PQEncryption -> m VersionRangeChat +chatVersionRange pqEnc = do ChatConfig {chatVRange} <- asks config - pure chatVRange + pure $ chatVRange pqEnc chatCommandP :: Parser ChatCommand chatCommandP = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 97ff5a93ca..935e6cb079 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -73,6 +73,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) @@ -121,7 +122,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, - chatVRange :: VersionRangeChat, + chatVRange :: CR.PQEncryption -> VersionRangeChat, confirmMigrations :: MigrationConfirmation, defaultServers :: DefaultAgentServers, tbqSize :: Natural, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 7c8bd0e602..a4c3e0a4b5 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -7,6 +7,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} @@ -30,6 +31,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.String import Data.Text (Text) @@ -44,10 +46,13 @@ import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Util +import Simplex.Messaging.Compression (CompressCtx, compress, decompressBatch) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOn, pattern PQEncOff) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) -import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Protocol (MsgBody) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$$>), (<$?>)) import Simplex.Messaging.Version hiding (version) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. @@ -57,8 +62,11 @@ currentChatVersion :: VersionChat currentChatVersion = VersionChat 7 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) -supportedChatVRange :: VersionRangeChat -supportedChatVRange = mkVersionRange (VersionChat 1) currentChatVersion +-- TODO remove parameterization in 5.7 +supportedChatVRange :: PQEncryption -> VersionRangeChat +supportedChatVRange pq = mkVersionRange (VersionChat 1) $ case pq of + PQEncOn -> compressedBatchingVersion + PQEncOff -> currentChatVersion -- version range that supports skipping establishing direct connections in a group groupNoDirectVRange :: VersionRangeChat @@ -88,6 +96,10 @@ groupHistoryIncludeWelcomeVRange = mkVersionRange (VersionChat 6) currentChatVer memberProfileUpdateVRange :: VersionRangeChat memberProfileUpdateVRange = mkVersionRange (VersionChat 7) currentChatVersion +-- version range that supports compressing messages +compressedBatchingVersion :: VersionChat +compressedBatchingVersion = VersionChat 8 + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -507,17 +519,27 @@ $(JQ.deriveJSON defaultJSON ''QuotedMsg) -- this limit reserves space for metadata in forwarded messages -- 15780 (limit used for fileChunkSize) - 161 (x.grp.msg.forward overhead) = 15619, round to 15610 -maxChatMsgSize :: Int -maxChatMsgSize = 15610 +maxRawMsgLength :: Int +maxRawMsgLength = 15610 + +maxEncodedMsgLength :: PQEncryption -> Int +maxEncodedMsgLength = \case + PQEncOn -> 13410 -- reduced by 2200 (original message should be compressed) + PQEncOff -> maxRawMsgLength + +maxConnInfoLength :: PQEncryption -> Int +maxConnInfoLength = \case + PQEncOn -> 10902 -- reduced by 3700 + PQEncOff -> 14602 -- 15610 - delta in agent between MSG and INFO data EncodedChatMessage = ECMEncoded ByteString | ECMLarge -encodeChatMessage :: MsgEncodingI e => ChatMessage e -> EncodedChatMessage -encodeChatMessage msg = do +encodeChatMessage :: MsgEncodingI e => (PQEncryption -> Int) -> ChatMessage e -> EncodedChatMessage +encodeChatMessage getMaxSize msg = do case chatToAppMessage msg of AMJson m -> do let body = LB.toStrict $ J.encode m - if B.length body > maxChatMsgSize + if B.length body > getMaxSize PQEncOff then ECMLarge else ECMEncoded body AMBinary m -> ECMEncoded $ strEncode m @@ -529,10 +551,22 @@ parseChatMessages s = case B.head s of '[' -> case J.eitherDecodeStrict' s of Right v -> map parseItem v Left e -> [Left e] + 'X' -> decodeCompressed (B.drop 1 s) _ -> [ACMsg SBinary <$> (appBinaryToCM =<< strDecode s)] where parseItem :: J.Value -> Either String AChatMessage parseItem v = ACMsg SJson <$> JT.parseEither parseJSON v + decodeCompressed :: ByteString -> [Either String AChatMessage] + decodeCompressed s' = case smpDecode s' of + Left e -> [Left e] + Right compressed -> concatMap (either (pure . Left) parseChatMessages) . L.toList $ decompressBatch maxRawMsgLength compressed + +compressedBatchMsgBody_ :: CompressCtx -> MsgBody -> IO (Either String ByteString) +compressedBatchMsgBody_ ctx msgBody = markCompressedBatch . smpEncode . (L.:| []) <$$> compress ctx msgBody + +markCompressedBatch :: ByteString -> ByteString +markCompressedBatch = B.cons 'X' +{-# INLINE markCompressedBatch #-} parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 153f7050ab..e5e33761c5 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -36,14 +36,13 @@ import Simplex.FileTransfer.Description (kb, mb) import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration) import Simplex.Messaging.Agent.Env.SQLite -import Simplex.Messaging.Agent.Protocol (pattern VersionSMPA) +import Simplex.Messaging.Agent.Protocol (supportedSMPAgentVRange, pattern VersionSMPA) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) import Simplex.Messaging.Crypto.Ratchet (pattern VersionE2E) import qualified Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Agent.Protocol (supportedSMPAgentVRange) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport @@ -160,14 +159,14 @@ testAgentCfgV1 = testCfgVPrev :: ChatConfig testCfgVPrev = testCfg - { chatVRange = prevRange $ chatVRange testCfg, + { chatVRange = prevRange . chatVRange testCfg, agentConfig = testAgentCfgVPrev } testCfgV1 :: ChatConfig testCfgV1 = testCfg - { chatVRange = v1Range, + { chatVRange = const v1Range, agentConfig = testAgentCfgV1 } @@ -185,7 +184,7 @@ testCfgCreateGroupDirect = mkCfgCreateGroupDirect testCfg mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig -mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} +mkCfgCreateGroupDirect cfg = cfg {chatVRange = const groupCreateDirectVRange} groupCreateDirectVRange :: VersionRangeChat groupCreateDirectVRange = mkVersionRange (VersionChat 1) (VersionChat 1) @@ -195,7 +194,7 @@ testCfgGroupLinkViaContact = mkCfgGroupLinkViaContact testCfg mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig -mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} +mkCfgGroupLinkViaContact cfg = cfg {chatVRange = const groupLinkViaContactVRange} groupLinkViaContactVRange :: VersionRangeChat groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 0bb579853f..3dc5500204 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -25,6 +25,7 @@ import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) @@ -106,10 +107,8 @@ chatDirectTests = do it "mark group member verified" testMarkGroupMemberVerified describe "message errors" $ do it "show message decryption error" testMsgDecryptError - skip "TODO PQ ratchet synchronization" $ - describe "TODO sporadically fail with unexpected \"post-quantum encryption enabled\" output" $ do - it "should report ratchet de-synchronization, synchronize ratchets" testSyncRatchet - it "synchronize ratchets, reset connection code" testSyncRatchetCodeReset + it "should report ratchet de-synchronization, synchronize ratchets" testSyncRatchet + it "synchronize ratchets, reset connection code" testSyncRatchetCodeReset describe "message reactions" $ do it "set message reactions" testSetMessageReactions describe "delivery receipts" $ do @@ -117,14 +116,14 @@ chatDirectTests = do it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts describe "negotiate connection peer chat protocol version range" $ do describe "peer version range correctly set for new connection via invitation" $ do - testInvVRange supportedChatVRange supportedChatVRange - testInvVRange supportedChatVRange vr11 - testInvVRange vr11 supportedChatVRange + testInvVRange (supportedChatVRange PQEncOff) (supportedChatVRange PQEncOff) + testInvVRange (supportedChatVRange PQEncOff) vr11 + testInvVRange vr11 (supportedChatVRange PQEncOff) testInvVRange vr11 vr11 describe "peer version range correctly set for new connection via contact request" $ do - testReqVRange supportedChatVRange supportedChatVRange - testReqVRange supportedChatVRange vr11 - testReqVRange vr11 supportedChatVRange + testReqVRange (supportedChatVRange PQEncOff) (supportedChatVRange PQEncOff) + testReqVRange (supportedChatVRange PQEncOff) vr11 + testReqVRange vr11 (supportedChatVRange PQEncOff) testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange describe "network statuses" $ do @@ -2661,8 +2660,8 @@ testConfigureDeliveryReceipts tmp = testConnInvChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () testConnInvChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct2VRange} "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -2673,8 +2672,8 @@ testConnInvChatVRange ct1VRange ct2VRange tmp = testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () testConnReqChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = const ct2VRange} "bob" bobProfile $ \bob -> do alice ##> "/ad" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) @@ -2701,7 +2700,7 @@ testUpdatePeerChatVRange tmp = contactInfoChatVRange alice vr11 bob ##> "/i alice" - contactInfoChatVRange bob supportedChatVRange + contactInfoChatVRange bob (supportedChatVRange PQEncOff) withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2710,10 +2709,10 @@ testUpdatePeerChatVRange tmp = alice <# "bob> hello 1" alice ##> "/i bob" - contactInfoChatVRange alice supportedChatVRange + contactInfoChatVRange alice (supportedChatVRange PQEncOff) bob ##> "/i alice" - contactInfoChatVRange bob supportedChatVRange + contactInfoChatVRange bob (supportedChatVRange PQEncOff) withTestChatCfg tmp cfg11 "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2725,9 +2724,9 @@ testUpdatePeerChatVRange tmp = contactInfoChatVRange alice vr11 bob ##> "/i alice" - contactInfoChatVRange bob supportedChatVRange + contactInfoChatVRange bob (supportedChatVRange PQEncOff) where - cfg11 = testCfg {chatVRange = vr11} :: ChatConfig + cfg11 = testCfg {chatVRange = const vr11} :: ChatConfig testGetNetworkStatuses :: HasCallStack => FilePath -> IO () testGetNetworkStatuses tmp = do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 16e26ac3ab..088bc45969 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} module ChatTests.Groups where @@ -16,6 +17,7 @@ import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..), VersionRangeChat) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) import System.Directory (copyFile) import System.FilePath (()) import Test.Hspec hiding (it) @@ -147,19 +149,19 @@ chatGroupTests = do it "member was blocked before joining group" testBlockForAllBeforeJoining it "can't repeat block, unblock" testBlockForAllCantRepeat where - _0 = supportedChatVRange -- don't create direct connections + _0 = supportedChatVRange PQEncOff -- don't create direct connections _1 = groupCreateDirectVRange -- having host configured with older version doesn't have effect in tests -- because host uses current code and sends version in MemberInfo testNoDirect vrMem2 vrMem3 noConns = it ( "host " - <> vRangeStr supportedChatVRange + <> vRangeStr (supportedChatVRange PQEncOff) <> (", 2nd mem " <> vRangeStr vrMem2) <> (", 3rd mem " <> vRangeStr vrMem3) <> (if noConns then " : 2 3" else " : 2 <##> 3") ) - $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns + $ testNoGroupDirectConns (supportedChatVRange PQEncOff) vrMem2 vrMem3 noConns testGroup :: HasCallStack => FilePath -> IO () testGroup = @@ -3581,9 +3583,9 @@ testConfigureGroupDeliveryReceipts tmp = testNoGroupDirectConns :: HasCallStack => VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> FilePath -> IO () testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = - withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do + withNewTestChatCfg tmp testCfg {chatVRange = const hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfg {chatVRange = const mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfg {chatVRange = const mem3VRange} "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath if noDirectConns then contactsDontExist bob cath diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index e98d05de33..810cd58bfa 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -2,6 +2,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} module ChatTests.Utils where @@ -29,6 +30,7 @@ import Simplex.Chat.Types.Preferences import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) @@ -83,23 +85,21 @@ skip = before_ . pendingWith versionTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> IO ()) -> SpecWith FilePath versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile runTest - skip "TODO PQ versioning" $ describe "TODO fails with previous version" $ do - it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest - it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest - it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest - it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest - it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest - it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest + it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile runTest + it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev runTest + it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg runTest + it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile runTest + it "old to curr" $ runTestCfg2 testCfg testCfgV1 runTest + it "curr to old" $ runTestCfg2 testCfgV1 testCfg runTest versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath versionTestMatrix3 runTest = do it "current" $ testChat3 aliceProfile bobProfile cathProfile runTest - skip "TODO PQ versioning" $ describe "TODO fails with previous version" $ do - it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest - it "prev to curr" $ runTestCfg3 testCfg testCfgVPrev testCfgVPrev runTest - it "curr+prev to curr" $ runTestCfg3 testCfg testCfg testCfgVPrev runTest - it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest - it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest + it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest + it "prev to curr" $ runTestCfg3 testCfg testCfgVPrev testCfgVPrev runTest + it "curr+prev to curr" $ runTestCfg3 testCfg testCfg testCfgVPrev runTest + it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest + it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () runTestCfg2 aliceCfg bobCfg runTest tmp = @@ -584,7 +584,7 @@ checkActionDeletesFile file action = do currentChatVRangeInfo :: String currentChatVRangeInfo = - "peer chat protocol version range: " <> vRangeStr supportedChatVRange + "peer chat protocol version range: " <> vRangeStr (supportedChatVRange PQEncOff) vRangeStr :: VersionRange v -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 1a9d968718..010fb5a2b4 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -17,7 +17,7 @@ import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages.Batch import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Messages (SndMessage (..)) -import Simplex.Chat.Protocol (SharedMsgId (..), maxChatMsgSize) +import Simplex.Chat.Protocol (SharedMsgId (..), maxRawMsgLength) import Test.Hspec batchingTests :: Spec @@ -99,7 +99,7 @@ testImageFitsSingleBatch = do msg s = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = s} batched = "[" <> xMsgNewStr <> "," <> descrStr <> "]" - runBatcherTest' maxChatMsgSize [msg xMsgNewStr, msg descrStr] [] [batched] + runBatcherTest' maxRawMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] runBatcherTest :: Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest maxLen msgs expectedErrors expectedBatches = diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 8236215c4f..ece24132e8 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -72,12 +72,12 @@ s ==## msg = do (##==) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation s ##== msg = do - let r = encodeChatMessage msg + let r = encodeChatMessage maxEncodedMsgLength msg case r of ECMEncoded encodedBody -> J.eitherDecodeStrict' encodedBody `shouldBe` (J.eitherDecodeStrict' s :: Either String J.Value) - ECMLarge -> expectationFailure $ "large message" + ECMLarge -> expectationFailure "large message" (##==##) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation s ##==## msg = do @@ -132,7 +132,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ "{\"v\":\"1-7\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) + ##==## ChatMessage (supportedChatVRange PQEncOff) (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage @@ -242,13 +242,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQEncOff, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQEncOff, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked}) @@ -263,7 +263,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQEncOff, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile From 61a3eb32eed0723c9a501dfe033feeb5a9a037cb Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 6 Mar 2024 19:06:01 +0400 Subject: [PATCH 26/64] core (pq): global flag only affects new connections; api to allow PQ in old contacts (#3869) --- src/Simplex/Chat.hs | 39 ++++++++++++++++++-------------- src/Simplex/Chat/Controller.hs | 2 ++ src/Simplex/Chat/Store/Shared.hs | 11 +++++++++ src/Simplex/Chat/View.hs | 1 + 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aa854094ee..d8089d48d2 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -601,6 +601,18 @@ processChatCommand' vr = \case APISetPQEnabled onOff -> do asks pqExperimentalEnabled >>= atomically . (`writeTVar` onOff) ok_ + APIAllowContactPQ contactId -> withUser $ \user -> do + ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + -- TODO PQ check different flag? + case activeConn of + Just conn@Connection {connId, enablePQ} + | enablePQ -> pure $ chatCmdError (Just user) "already allowed" + | otherwise -> do + withStore' $ \db -> allowConnEnablePQ db connId + let conn' = conn {enablePQ = True} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + pure $ CRContactPQAllowed user ct' + Nothing -> throwChatError $ CEContactNotActive ct APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -1294,9 +1306,8 @@ processChatCommand' vr = \case APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do ct <- withStore $ \db -> getContact db user contactId case contactConn ct of - Just conn -> do - enablePQ <- contactPQEnc conn - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) enablePQ force + Just conn@Connection {enablePQ} -> do + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) (CR.PQEncryption enablePQ) force createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct @@ -2201,8 +2212,7 @@ processChatCommand' vr = \case Nothing -> pure $ UserProfileUpdateSummary 0 0 [] Just changedCts -> do let idsEvts = L.map ctSndMsg changedCts - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - msgReqs_ <- L.zipWith (ctMsgReq enablePQ) changedCts <$> createSndMessages idsEvts + msgReqs_ <- L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ unless (null errs) $ toView $ CRChatErrors (Just user) errs let changedCts' = filter (\ChangedProfileContact {ct, ct'} -> directOrUsed ct' && mergedPreferences ct' /= mergedPreferences ct) cts @@ -2227,11 +2237,11 @@ processChatCommand' vr = \case ct' = updateMergedPreferences user' ct mergedProfile' = userProfileToSend user' Nothing (Just ct') False ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, PQEncryption, ChatMsgEvent 'Json) - ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId, enablePQ = enablePQConn}} = (ConnectionId connId, CR.PQEncryption enablePQConn, XInfo mergedProfile') - ctMsgReq :: PQFlag -> ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq - ctMsgReq enablePQ ChangedProfileContact {conn = conn@Connection {enablePQ = enablePQConn}} = + ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId, enablePQ}} = (ConnectionId connId, CR.PQEncryption enablePQ, XInfo mergedProfile') + ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq + ctMsgReq ChangedProfileContact {conn = conn@Connection {enablePQ}} = fmap $ \SndMessage {msgId, msgBody} -> - (conn, CR.PQEncryption $ enablePQ && enablePQConn, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) + (conn, CR.PQEncryption enablePQ, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -6007,9 +6017,8 @@ deleteOrUpdateMemberRecord user@User {userId} member = sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => User -> Contact -> ChatMsgEvent e -> m (SndMessage, Int64) sendDirectContactMessage user ct chatMsgEvent = do - conn@Connection {connId} <- liftEither $ contactSendConn_ ct - pqEnc <- contactPQEnc conn - r <- sendDirectMessage conn pqEnc chatMsgEvent (ConnectionId connId) + conn@Connection {connId, enablePQ} <- liftEither $ contactSendConn_ ct + r <- sendDirectMessage conn (CR.PQEncryption enablePQ) chatMsgEvent (ConnectionId connId) let (sndMessage, msgDeliveryId, CR.PQEncryption pqEnabled') = r -- TODO PQ use updated ct' and conn'? check downstream if it may affect something, maybe it's not necessary (_ct', _conn') <- createContactPQSndItem user ct conn pqEnabled' @@ -6120,11 +6129,6 @@ deliverMessage' conn pqEnc msgFlags msgBody msgId = type MsgReq = (Connection, CR.PQEncryption, MsgFlags, MsgBody, MessageId) -contactPQEnc :: ChatMonad m => Connection -> m CR.PQEncryption -contactPQEnc Connection {enablePQ = enablePQConn} = do - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - pure $ CR.PQEncryption $ enablePQ && enablePQConn - deliverMessages :: ChatMonad' m => NonEmpty MsgReq -> m (NonEmpty (Either ChatError (Int64, CR.PQEncryption))) deliverMessages msgs = deliverMessagesB $ L.map Right msgs @@ -6708,6 +6712,7 @@ chatCommandP = "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), "/_pq " *> (APISetPQEnabled <$> onOffP), + "/_pq allow " *> (APIAllowContactPQ <$> A.decimal), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 935e6cb079..a23d88a7b7 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -245,6 +245,7 @@ data ChatCommand | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool | APISetPQEnabled Bool + | APIAllowContactPQ ContactId | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -699,6 +700,7 @@ data ChatResponse | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} + | CRContactPQAllowed {user :: User, contact :: Contact} | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: Bool} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e961c4bcd0..77fd56489f 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -245,6 +245,17 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag (displayName, fullName, image, userId, Just True, createdAt, createdAt) insertedRowId db +allowConnEnablePQ :: DB.Connection -> Int64 -> IO () +allowConnEnablePQ db connId = + DB.execute + db + [sql| + UPDATE connections + SET enable_pq = 1 + WHERE connection_id = ? + |] + (Only connId) + updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQFlag -> IO () updateConnPQSndEnabled db connId pqSndEnabled = DB.execute diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 44bbc007bf..7783ac804a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -341,6 +341,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] CRRemoteCtrlStopped {} -> ["remote controller stopped"] CRContactPQEnabled u c pqOn -> ttyUser u [ttyContact' c <> ": post-quantum encryption " <> (if pqOn then "enabled" else "disabled")] + CRContactPQAllowed u c -> ttyUser u [ttyContact' c <> ": post-quantum encryption allowed"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = From 7b7c3227e35fc24d81fd863927302226785772c4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 7 Mar 2024 08:22:39 +0000 Subject: [PATCH 27/64] core: update dependencies for nix --- scripts/nix/sha256map.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6c79acf47b..40228c262d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."317f2d5552332eb5d26a15ede87887e59408a10b" = "1dc4nv5zcbv4712sjv0ncyswdcx4igwzhgybx1rd9x6a7mwv2kr5"; + "https://github.com/simplex-chat/simplexmq.git"."e04705d9c5e6b3d3652f909a5176c375acf29411" = "1dc4nv5zcbv4712sjv0ncyswdcx4igwzhgybx1rd9x6a7mwv2kr5"; "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"; From f1c22a330845549490b88427974d2cb9bfae5612 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 7 Mar 2024 08:36:01 +0000 Subject: [PATCH 28/64] 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 c3851802b7..1195aed3c5 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -61,6 +61,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 */; }; + 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD32B99B38B00C72EFF /* libgmp.a */; }; + 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD42B99B38B00C72EFF /* libgmpxx.a */; }; + 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD52B99B38B00C72EFF /* libffi.a */; }; + 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */; }; + 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.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 */; }; @@ -139,11 +144,6 @@ 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 */; }; - 5CF4416D2B8E14EF00C52786 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF441682B8E14EF00C52786 /* libgmpxx.a */; }; - 5CF4416E2B8E14EF00C52786 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF441692B8E14EF00C52786 /* libffi.a */; }; - 5CF4416F2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */; }; - 5CF441702B8E14EF00C52786 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416B2B8E14EF00C52786 /* libgmp.a */; }; - 5CF441712B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */; }; 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; }; 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; }; @@ -325,6 +325,11 @@ 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; + 5C777BD32B99B38B00C72EFF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C777BD42B99B38B00C72EFF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C777BD52B99B38B00C72EFF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a"; sourceTree = ""; }; + 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a"; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -426,11 +431,6 @@ 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 = ""; }; - 5CF441682B8E14EF00C52786 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF441692B8E14EF00C52786 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a"; sourceTree = ""; }; - 5CF4416B2B8E14EF00C52786 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a"; sourceTree = ""; }; 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = ""; }; 5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = ""; }; @@ -514,13 +514,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF4416D2B8E14EF00C52786 /* libgmpxx.a in Frameworks */, - 5CF441712B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a in Frameworks */, - 5CF4416F2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a in Frameworks */, + 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */, + 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */, + 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CF441702B8E14EF00C52786 /* libgmp.a in Frameworks */, - 5CF4416E2B8E14EF00C52786 /* libffi.a in Frameworks */, + 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -582,11 +582,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF441692B8E14EF00C52786 /* libffi.a */, - 5CF4416B2B8E14EF00C52786 /* libgmp.a */, - 5CF441682B8E14EF00C52786 /* libgmpxx.a */, - 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */, - 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */, + 5C777BD52B99B38B00C72EFF /* libffi.a */, + 5C777BD32B99B38B00C72EFF /* libgmp.a */, + 5C777BD42B99B38B00C72EFF /* libgmpxx.a */, + 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */, + 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */, ); path = Libraries; sourceTree = ""; From ce9b909495f7b7a9d2ca69e79c56a8fe65eff049 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 7 Mar 2024 16:43:10 +0400 Subject: [PATCH 29/64] ios: pq support (#3870) * ios: pq support * fix * fix * update * text * rename --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 19 +++++++ apps/ios/Shared/Views/Chat/ChatInfoView.swift | 57 +++++++++++++++++++ apps/ios/Shared/Views/Chat/ChatItemView.swift | 5 ++ .../Views/UserSettings/DeveloperView.swift | 25 ++++++++ apps/ios/SimpleXChat/APITypes.swift | 13 +++++ apps/ios/SimpleXChat/AppGroup.swift | 4 ++ apps/ios/SimpleXChat/ChatTypes.swift | 54 +++++++++++++++++- 7 files changed, 174 insertions(+), 3 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 1fc6b54390..20975dfe3c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -258,6 +258,18 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { throw r } +func apiSetPQEnabled(_ enable: Bool) throws { + let r = chatSendCmdSync(.apiSetPQEnabled(enable: enable)) + if case .cmdOk = r { return } + throw r +} + +func apiAllowContactPQ(_ contactId: Int64) async throws -> Contact { + let r = await chatSendCmd(.apiAllowContactPQ(contactId: contactId)) + if case let .contactPQAllowed(_, contact) = r { return contact } + throw r +} + func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -1244,6 +1256,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) + try apiSetPQEnabled(pqExperimentalEnabledDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { @@ -1818,6 +1831,12 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .contactPQEnabled(user, contact, _): + if active(user) { + await MainActor.run { + m.updateContact(contact) // or updateContactConnectionStats? + } + } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index b702c2cc23..07d83ac475 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -103,6 +103,7 @@ struct ChatInfoView: View { @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @AppStorage(GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED, store: groupDefaults) private var pqExperimentalEnabled = false enum ChatInfoViewAlert: Identifiable { case clearChatAlert @@ -110,6 +111,7 @@ struct ChatInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert + case allowContactPQEncryptionAlert case error(title: LocalizedStringKey, error: LocalizedStringKey = "") var id: String { @@ -119,6 +121,7 @@ struct ChatInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" + case .allowContactPQEncryptionAlert: return "allowContactPQEncryptionAlert" case let .error(title, _): return "error \(title)" } } @@ -165,6 +168,22 @@ struct ChatInfoView: View { } .disabled(!contact.ready || !contact.active) + if pqExperimentalEnabled, + let conn = contact.activeConn { + Section { + infoRow(Text(String("PQ E2E encryption")), conn.connPQEnabled ? "Enabled" : "Disabled") + if !conn.enablePQ { + allowPQButton() + } + } header: { + Text(String("Post-quantum E2E encryption")) + } footer: { + if !conn.enablePQ { + Text(String("After allowing post-quantum encryption, it will be enabled after several messages if your contact also allows it.")) + } + } + } + if let contactLink = contact.contactLink { Section { SimpleXLinkQRCode(uri: contactLink) @@ -237,6 +256,7 @@ struct ChatInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) }) + case .allowContactPQEncryptionAlert: return allowContactPQEncryptionAlert() case let .error(title, error): return mkAlert(title: title, message: error) } } @@ -410,6 +430,15 @@ struct ChatInfoView: View { } } + private func allowPQButton() -> some View { + Button { + alert = .allowContactPQEncryptionAlert + } label: { + Label(String("Allow PQ encryption"), systemImage: "exclamationmark.triangle") + .foregroundColor(.orange) + } + } + private func networkStatusRow() -> some View { HStack { Text("Network status") @@ -543,6 +572,34 @@ struct ChatInfoView: View { } } } + + private func allowContactPQEncryption() { + Task { + do { + let ct = try await apiAllowContactPQ(contact.apiId) + contact = ct + await MainActor.run { + chatModel.updateContact(contact) + dismiss() + } + } catch let error { + logger.error("allowContactPQEncryption apiAllowContactPQ error: \(responseError(error))") + let a = getErrorAlert(error, "Error allowing contact PQ encryption") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } + } + + func allowContactPQEncryptionAlert() -> Alert { + Alert( + title: Text(String("Allow post-quantum encryption?")), + message: Text(String("This is an experimental feature, it is not recommended to enable it for high importance communications. It may result in connection errors!")), + primaryButton: .destructive(Text(String("Allow")), action: allowContactPQEncryption), + secondaryButton: .cancel() + ) + } } func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 8f67a8f737..d9404547e2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -111,6 +111,11 @@ struct ChatItemContentView: View { case .rcvModerated: deletedItemView() case .rcvBlocked: deletedItemView() case let .invalidJSON(json): CIInvalidJSONView(json: json) + // TODO proper items + case .sndDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) + case .rcvDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) + case .sndGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) + case .rcvGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) } } diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 3bbfbfe33e..816b46c54f 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct DeveloperView: View { @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false + @AppStorage(GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED, store: groupDefaults) private var pqExperimentalEnabled = false @Environment(\.colorScheme) var colorScheme var body: some View { @@ -42,9 +43,33 @@ struct DeveloperView: View { } footer: { (developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.") } + + if developerTools { + Section { + settingsRow("key") { + Toggle("Post-quantum E2EE", isOn: $pqExperimentalEnabled) + .onChange(of: pqExperimentalEnabled) { + setPQExperimentalEnabled($0) + } + } + } header: { + Text(String("Experimental")) + } footer: { + Text(String("In this version applies only to new contacts.")) + } + } } } } + + private func setPQExperimentalEnabled(_ enable: Bool) { + do { + try apiSetPQEnabled(enable) + } catch let error { + let err = responseError(error) + logger.error("apiSetPQEnabled \(err)") + } + } } struct DeveloperView_Previews: PreviewProvider { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 88bb8910dd..6bb3fbb3c2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -32,6 +32,8 @@ public enum ChatCommand { case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) case apiSetEncryptLocalFiles(enable: Bool) + case apiSetPQEnabled(enable: Bool) + case apiAllowContactPQ(contactId: Int64) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -162,6 +164,8 @@ public enum ChatCommand { case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" + case let .apiSetPQEnabled(enable): return "/_pq \(onOff(enable))" + case let .apiAllowContactPQ(contactId): return "/_pq allow \(contactId)" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -306,6 +310,8 @@ public enum ChatCommand { case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" + case .apiSetPQEnabled: return "apiSetPQEnabled" + case .apiAllowContactPQ: return "apiAllowContactPQ" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -617,6 +623,9 @@ public enum ChatResponse: Decodable, Error { case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) + // pq + case contactPQAllowed(user: UserRef, contact: Contact) + case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) @@ -765,6 +774,8 @@ public enum ChatResponse: Decodable, Error { case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" case .remoteCtrlConnected: return "remoteCtrlConnected" case .remoteCtrlStopped: return "remoteCtrlStopped" + case .contactPQAllowed: return "contactPQAllowed" + case .contactPQEnabled: return "contactPQAllowed" case .versionInfo: return "versionInfo" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" @@ -915,6 +926,8 @@ public enum ChatResponse: Decodable, Error { case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case .remoteCtrlStopped: return noDetails + case let .contactPQAllowed(u, contact): return withUser(u, "contact: \(String(describing: contact))") + case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" case .cmdOk: return noDetails case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index ceb7d9d7db..47e250b7e9 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -39,6 +39,7 @@ let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades" public let GROUP_DEFAULT_CALL_KIT_ENABLED = "callKitEnabled" +public let GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED = "pqExperimentalEnabled" public let APP_GROUP_NAME = "group.chat.simplex.app" @@ -67,6 +68,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false, GROUP_DEFAULT_CALL_KIT_ENABLED: true, + GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false, ]) } @@ -193,6 +195,8 @@ public let confirmDBUpgradesGroupDefault = BoolDefault(defaults: groupDefaults, public let callKitEnabledGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CALL_KIT_ENABLED) +public let pqExperimentalEnabledDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) + public class DateDefault { var defaults: UserDefaults var key: String diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 997f6e3537..0125973d14 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1532,22 +1532,30 @@ public struct Connection: Decodable { public var viaGroupLink: Bool public var customUserProfileId: Int64? public var connectionCode: SecurityCode? + public var enablePQ: Bool + public var pqSndEnabled: Bool? + public var pqRcvEnabled: Bool? public var connectionStats: ConnectionStats? = nil private enum CodingKeys: String, CodingKey { - case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode + case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, enablePQ, pqSndEnabled, pqRcvEnabled } public var id: ChatId { get { ":\(connId)" } } + public var connPQEnabled: Bool { + pqSndEnabled == true && pqRcvEnabled == true + } + static let sampleData = Connection( connId: 1, agentConnId: "abc", peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1), connStatus: .ready, connLevel: 0, - viaGroupLink: false + viaGroupLink: false, + enablePQ: false ) } @@ -2300,6 +2308,10 @@ public struct ChatItem: Identifiable, Decodable { case .sndModerated: return false case .rcvModerated: return false case .rcvBlocked: return false + case .sndDirectE2EEInfo: return false + case .rcvDirectE2EEInfo: return false + case .sndGroupE2EEInfo: return false + case .rcvGroupE2EEInfo: return false case .invalidJSON: return false } } @@ -2735,6 +2747,10 @@ public enum CIContent: Decodable, ItemContent { case sndModerated case rcvModerated case rcvBlocked + case sndDirectE2EEInfo(e2eeInfo: E2EEInfo) + case rcvDirectE2EEInfo(e2eeInfo: E2EEInfo) + case sndGroupE2EEInfo(e2eeInfo: E2EEInfo) + case rcvGroupE2EEInfo(e2eeInfo: E2EEInfo) case invalidJSON(json: String) public var text: String { @@ -2766,11 +2782,25 @@ public enum CIContent: Decodable, ItemContent { case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") + case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) + case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) + case .sndGroupE2EEInfo: return e2eeInfoNoPQText + case .rcvGroupE2EEInfo: return e2eeInfoNoPQText case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } } } + private func directE2EEInfoToText(_ e2eeInfo: E2EEInfo) -> String { + e2eeInfo.pqEnabled + ? NSLocalizedString("This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery.", comment: "E2EE info chat item") + : e2eeInfoNoPQText + } + + private var e2eeInfoNoPQText: String { + NSLocalizedString("This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.", comment: "E2EE info chat item") + } + static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { feature.hasParam ? "\(feature.text): \(timeText(param))" @@ -3457,6 +3487,10 @@ public enum CIGroupInvitationStatus: String, Decodable { case expired } +public struct E2EEInfo: Decodable { + public var pqEnabled: Bool +} + public enum RcvDirectEvent: Decodable { case contactDeleted case profileUpdated(fromProfile: Profile, toProfile: Profile) @@ -3574,7 +3608,8 @@ public enum RcvConnEvent: Decodable { case switchQueue(phase: SwitchPhase) case ratchetSync(syncStatus: RatchetSyncState) case verificationCodeReset - + case pqEnabled(enabled: Bool) + var text: String { switch self { case let .switchQueue(phase): @@ -3586,6 +3621,12 @@ public enum RcvConnEvent: Decodable { return ratchetSyncStatusToText(syncStatus) case .verificationCodeReset: return NSLocalizedString("security code changed", comment: "chat item text") + case let .pqEnabled(enabled): + if enabled { + return NSLocalizedString("enabled post-quantum encryption", comment: "chat item text") + } else { + return NSLocalizedString("disabled post-quantum encryption", comment: "chat item text") + } } } } @@ -3603,6 +3644,7 @@ func ratchetSyncStatusToText(_ ratchetSyncStatus: RatchetSyncState) -> String { public enum SndConnEvent: Decodable { case switchQueue(phase: SwitchPhase, member: GroupMemberRef?) case ratchetSync(syncStatus: RatchetSyncState, member: GroupMemberRef?) + case pqEnabled(enabled: Bool) var text: String { switch self { @@ -3626,6 +3668,12 @@ public enum SndConnEvent: Decodable { } } return ratchetSyncStatusToText(syncStatus) + case let .pqEnabled(enabled): + if enabled { + return NSLocalizedString("enabled post-quantum encryption", comment: "chat item text") + } else { + return NSLocalizedString("disabled post-quantum encryption", comment: "chat item text") + } } } } From bc2b1358801c0a15c4df1d0c38a6c6ccfba3682f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 7 Mar 2024 17:39:09 +0400 Subject: [PATCH 30/64] core (pq): update types (#3872) * core (pq): update types * imports * encode / max msg size types * integrate new types * update types/pq support * tests compile --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 411 +++++++++--------- src/Simplex/Chat/Controller.hs | 10 +- src/Simplex/Chat/Messages/CIContent.hs | 68 +-- src/Simplex/Chat/Messages/CIContent/Events.hs | 5 +- src/Simplex/Chat/Protocol.hs | 27 +- src/Simplex/Chat/Store/Direct.hs | 25 +- src/Simplex/Chat/Store/Files.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 18 +- src/Simplex/Chat/Store/Profiles.hs | 3 +- src/Simplex/Chat/Store/Shared.hs | 31 +- src/Simplex/Chat/Types.hs | 16 +- src/Simplex/Chat/View.hs | 3 +- tests/ChatClient.hs | 7 +- tests/ChatTests/Direct.hs | 22 +- tests/ChatTests/Groups.hs | 8 +- tests/ChatTests/Utils.hs | 8 +- tests/ProtocolTests.hs | 12 +- 19 files changed, 361 insertions(+), 321 deletions(-) diff --git a/cabal.project b/cabal.project index 64e2e5e447..f7f929a95b 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e04705d9c5e6b3d3652f909a5176c375acf29411 + tag: 11288866f90bafb0892701b0e0679eddb030b5df source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 40228c262d..2faa7a1dd4 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e04705d9c5e6b3d3652f909a5176c375acf29411" = "1dc4nv5zcbv4712sjv0ncyswdcx4igwzhgybx1rd9x6a7mwv2kr5"; + "https://github.com/simplex-chat/simplexmq.git"."00ae2cb6e134e3cd7c8089e30f95a9430d3c4e3d" = "1dvghlsrf0dw8g279gnb4m2s7jrj9bwdibcq61hkkb9h5975f93d"; "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 d8089d48d2..8a5f88f45f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -102,7 +102,7 @@ import Simplex.Messaging.Compression (withCompressCtx) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF -import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKNoPQ, pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -247,7 +247,7 @@ newChatController encryptLocalFiles <- newTVarIO False tempDirectory <- newTVarIO Nothing contactMergeEnabled <- newTVarIO True - pqExperimentalEnabled <- newTVarIO False + pqExperimentalEnabled <- newTVarIO PQSupportOff pure ChatController { firstTime, @@ -368,7 +368,7 @@ subscribeUsers :: forall m. ChatMonad' m => Bool -> [User] -> m () subscribeUsers onlyNeeded users = do let (us, us') = partition activeUser users -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff subscribe vr us subscribe vr us' where @@ -451,7 +451,7 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand cmd = - chatVersionRange PQEncOff -- TODO PQ this is only used to set membership version range (?) + chatVersionRange PQSupportOff -- TODO PQ this is only used to set membership version range (?) >>= (`processChatCommand'` cmd) {-# INLINE processChatCommand #-} @@ -595,23 +595,20 @@ processChatCommand' vr = \case chatWriteVar remoteHostsFolder $ Just rf ok_ APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ - SetContactMergeEnabled onOff -> do - asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) - ok_ - APISetPQEnabled onOff -> do - asks pqExperimentalEnabled >>= atomically . (`writeTVar` onOff) - ok_ + SetContactMergeEnabled onOff -> chatWriteVar contactMergeEnabled onOff >> ok_ + APISetPQEnabled onOff -> chatWriteVar pqExperimentalEnabled onOff >> ok_ APIAllowContactPQ contactId -> withUser $ \user -> do ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId -- TODO PQ check different flag? case activeConn of - Just conn@Connection {connId, enablePQ} - | enablePQ -> pure $ chatCmdError (Just user) "already allowed" - | otherwise -> do - withStore' $ \db -> allowConnEnablePQ db connId - let conn' = conn {enablePQ = True} :: Connection - ct' = ct {activeConn = Just conn'} :: Contact - pure $ CRContactPQAllowed user ct' + Just conn@Connection {connId, pqEncryption} -> case pqEncryption of + PQEncOn -> pure $ chatCmdError (Just user) "already allowed" + PQEncOff -> do + -- TODO PQ add / change database field(s) + withStore' $ \db -> allowConnEnablePQ db connId + let conn' = conn {pqSupport = PQSupportOn, pqEncryption = PQEncOn} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + pure $ CRContactPQAllowed user ct' Nothing -> throwChatError $ CEContactNotActive ct APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do @@ -1306,8 +1303,8 @@ processChatCommand' vr = \case APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do ct <- withStore $ \db -> getContact db user contactId case contactConn ct of - Just conn@Connection {enablePQ} -> do - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) (CR.PQEncryption enablePQ) force + Just conn@Connection {pqSupport} -> do + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) pqSupport force createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct @@ -1315,7 +1312,7 @@ processChatCommand' vr = \case (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId case memberConnId m of Just connId -> do - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId CR.PQEncOff force + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force createInternalChatItem user (CDGroupSnd g) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m) Nothing pure $ CRGroupMemberRatchetSyncStarted user g m cStats _ -> throwChatError CEGroupMemberNotActive @@ -1404,9 +1401,9 @@ processChatCommand' vr = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing (CR.IKNoPQ $ CR.PQEncryption enablePQ) subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode enablePQ + pqSup <- chatReadVar pqExperimentalEnabled + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing (IKNoPQ pqSup) subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode pqSup pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> processChatCommand $ APIAddContact userId incognito @@ -1433,10 +1430,15 @@ processChatCommand' vr = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing False - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - dm <- directMessagePQ (CR.PQEncryption enablePQ) maxConnInfoLength $ XInfo profileToSend - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm (CR.PQEncryption enablePQ) subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode enablePQ + pqSup <- chatReadVar pqExperimentalEnabled + -- TODO PQ connRequestPQSupport + -- connRequestPQSupport :: AgentMonad' m => PQSupport -> ConnectionRequestUri c -> m (Maybe PQSupport) + -- connRequestPQSupport pqSup cReq + -- or if you know support of another side alread (e.g. in REQ) use: + -- pqSupportAnd :: PQSupport -> PQSupport -> PQSupport + dm <- encodeConnInfoPQ pqSup $ XInfo profileToSend + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode pqSup pure $ CRSentConfirmation user conn APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq @@ -1470,7 +1472,8 @@ processChatCommand' vr = \case processChatCommand $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing CR.IKPQOff subMode + -- TODO v5.7 pass IPPQOn + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing IKPQOff subMode withStore $ \db -> createUserContactLink db user connId cReq subMode pure $ CRUserContactLinkCreated user cReq CreateMyAddress -> withUser $ \User {userId} -> @@ -1607,7 +1610,7 @@ processChatCommand' vr = \case -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing groupInfo <- withStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile - createInternalChatItem user (CDGroupSnd groupInfo) (CISndGroupE2EEInfo $ E2EEInfo {pqEnabled = False}) Nothing + createInternalChatItem user (CDGroupSnd groupInfo) (CISndGroupE2EEInfo $ E2EInfo {pqEnabled = PQEncOff}) Nothing pure $ CRGroupCreated user groupInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand $ APINewGroup userId incognito gProfile @@ -1627,7 +1630,7 @@ processChatCommand' vr = \case Nothing -> do gVar <- asks random subMode <- chatReadVar subscriptionMode - (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing CR.IKPQOff subMode + (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode member <- withStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -1651,8 +1654,8 @@ processChatCommand' vr = \case case activeConn of Just Connection {peerChatVRange} -> do subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XGrpAcpt membershipMemId - agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm CR.PQEncOff subMode + dm <- encodeConnInfo $ XGrpAcpt membershipMemId + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm PQSupportOff subMode withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode updateGroupMemberStatus db userId fromMember GSMemAccepted @@ -1788,7 +1791,7 @@ processChatCommand' vr = \case groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) CR.IKPQOff subMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) IKPQOff subMode withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo cReq mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do @@ -1815,7 +1818,7 @@ processChatCommand' vr = \case unless (isCompatibleRange (fromJVersionRange peerChatVRange) xGrpDirectInvVRange) $ throwChatError CEPeerChatVRangeIncompatible when (isJust $ memberContactId m) $ throwChatError $ CECommandError "member contact already exists" subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing CR.IKPQOff subMode + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode -- TODO not sure it is correct to set connections status here? @@ -1828,7 +1831,7 @@ processChatCommand' vr = \case case memberConn m of Just mConn -> do let msg = XGrpDirectInv cReq msgContent_ - (sndMsg, _, _) <- sendDirectMessage mConn CR.PQEncOff msg $ GroupId groupId + (sndMsg, _, _) <- sendDirectMemberMessage mConn msg groupId withStore' $ \db -> setContactGrpInvSent db ct True let ct' = ct {contactGrpInvSent = True} forM_ msgContent_ $ \mc -> do @@ -2162,27 +2165,32 @@ processChatCommand' vr = \case connect' (Just gLinkId) cReqHash xContactId True where connect' groupLinkId cReqHash xContactId inGroup = do - enablePQ <- (not inGroup &&) <$> (readTVarIO =<< asks pqExperimentalEnabled) - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup (CR.PQEncryption enablePQ) - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode enablePQ + pqSup <- if inGroup then pure PQSupportOff else chatReadVar pqExperimentalEnabled + (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup pqSup + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode pqSup pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse connectContactViaAddress user incognito ct cReq = withChatLock "connectViaContact" $ do newXContactId <- XContactId <$> drgRandomBytes 16 - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False (CR.PQEncryption enablePQ) + pqSup <- chatReadVar pqExperimentalEnabled + (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False pqSup let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode enablePQ + ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode pqSup pure $ CRSentInvitationToContact user ct' incognitoProfile - requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQEncryption -> m (ConnId, Maybe Profile, SubscriptionMode) - requestContact user incognito cReq xContactId inGroup pqEnc = do + requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQSupport -> m (ConnId, Maybe Profile, SubscriptionMode) + requestContact user incognito cReq xContactId inGroup pqSup = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing inGroup - dm <- directMessagePQ pqEnc maxConnInfoLength (XContact profileToSend $ Just xContactId) + -- TODO PQ connecting via address + -- 0) toggle disabled - PQSupportOff + -- 1) toggle enabled, address supports PQ (connRequestPQSupport returns Just True) - PQSupportOn, enable support with compression + -- 2) toggle enabled, address doesn't support PQ - PQSupportOn but without compression, with version range indicating support + -- see joinContactInitialKeys: PQSupportOn -> IKUsePQ - I will change to IKNoPQ PQSupportOn + dm <- encodeConnInfoPQ pqSup (XContact profileToSend $ Just xContactId) subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqEnc subMode + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup subMode pure (connId, incognitoProfile, subMode) contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = @@ -2236,12 +2244,12 @@ processChatCommand' vr = \case mergedProfile = userProfileToSend user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileToSend user' Nothing (Just ct') False - ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, PQEncryption, ChatMsgEvent 'Json) - ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId, enablePQ}} = (ConnectionId connId, CR.PQEncryption enablePQ, XInfo mergedProfile') + ctSndMsg :: ChangedProfileContact -> (ConnOrGroupId, PQSupport, ChatMsgEvent 'Json) + ctSndMsg ChangedProfileContact {mergedProfile', conn = Connection {connId, pqSupport}} = (ConnectionId connId, pqSupport, XInfo mergedProfile') ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError MsgReq - ctMsgReq ChangedProfileContact {conn = conn@Connection {enablePQ}} = + ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> - (conn, CR.PQEncryption enablePQ, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) + (conn, MsgFlags {notification = hasNotification XInfo_}, msgBody, msgId) updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -2745,12 +2753,12 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI RFSCancelled _ -> throwChatError $ CEFileCancelled fName _ -> throwChatError $ CEFileAlreadyReceiving fName -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff case (xftpRcvFile, fileConnReq) of -- direct file protocol (Nothing, Just connReq) -> do subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XFileAcpt fName + dm <- encodeConnInfo $ XFileAcpt fName connIds <- joinAgentConnectionAsync user True connReq dm subMode filePath <- getRcvFilePath fileId filePath_ fName True withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnJoined filePath subMode @@ -2776,7 +2784,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db user groupId memId case activeConn of Just conn -> do - acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMessage conn CR.PQEncOff msg $ GroupId groupId + acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMemberMessage conn msg groupId _ -> throwChatError $ CEFileInternal "member connection not active" _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where @@ -2785,7 +2793,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff if | inline -> do -- accepting inline @@ -2833,7 +2841,7 @@ receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile startReceivingFile :: ChatMonad m => User -> FileTransferId -> m () startReceivingFile user fileId = do -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff ci <- withStoreCtx (Just "startReceivingFile, updateRcvFileStatus ...") $ \db -> do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 @@ -2878,18 +2886,19 @@ acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe Incog 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 False - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - dm <- directMessagePQ (CR.PQEncryption enablePQ) maxConnInfoLength $ XInfo profileToSend - acId <- withAgent $ \a -> acceptContact a True invId dm (CR.PQEncryption enablePQ) subMode - withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode enablePQ contactUsed + pqSup <- chatReadVar pqExperimentalEnabled + -- TODO combine pqSup with pqSupport from UserContactRequest + dm <- encodeConnInfoPQ pqSup $ XInfo profileToSend + acId <- withAgent $ \a -> acceptContact a True invId dm pqSup subMode + withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed -acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQFlag -> m Contact -acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqEnabled = do +acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> m Contact +acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False - (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode (CR.PQEncryption pqEnabled) + (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup withStore' $ \db -> do - ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode pqEnabled contactUsed + ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct @@ -2915,7 +2924,7 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - connIds <- agentAcceptContactAsync user True invId msg subMode (CR.PQEncryption False) + connIds <- agentAcceptContactAsync user True invId msg subMode (PQSupport False) withStore $ \db -> do liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode getGroupMemberById db user groupMemberId @@ -3205,7 +3214,7 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts waitChatStartedAndActivated -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff case cType of CTDirect -> do (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId @@ -3227,7 +3236,7 @@ expireChatItems :: forall m. ChatMonad m => User -> Int64 -> Bool -> m () expireChatItems user@User {userId} ttl sync = do currentTs <- liftIO getCurrentTime -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs @@ -3275,7 +3284,7 @@ processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = do -- TODO PQ this is only used to set membership version range (?) - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff withStore' (`getUserByAConnId` AgentConnId connId) >>= \case Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) @@ -3315,7 +3324,7 @@ processAgentMsgSndFile _corrId aFileId msg = fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId getSndFileTransfer db user fileId -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff unless cancelled $ case msg of SFPROG sndProgress sndTotal -> do let status = CIFSSndTransfer {sndProgress, sndTotal} @@ -3373,7 +3382,7 @@ processAgentMsgSndFile _corrId aFileId msg = sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m () sendToMember (rfd, (conn, sft)) = void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> do - (sndMsg, msgDeliveryId, _) <- sendDirectMessage conn CR.PQEncOff msg' $ GroupId groupId + (sndMsg, msgDeliveryId, _) <- sendDirectMemberMessage conn msg' groupId pure (sndMsg, msgDeliveryId) _ -> pure () _ -> pure () -- TODO error? @@ -3435,7 +3444,7 @@ processAgentMsgRcvFile _corrId aFileId msg = fileId <- getXFTPRcvFileDBId db $ AgentRcvFileId aFileId getRcvFileTransfer db user fileId -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff unless (rcvFileCompleteOrCancelled ft) $ case msg of RFPROG rcvProgress rcvTotal -> do let status = CIFSRcvTransfer {rcvProgress, rcvTotal} @@ -3497,21 +3506,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = agentMsgConnStatus :: ACommand 'Agent e -> Maybe ConnStatus agentMsgConnStatus = \case CONF {} -> Just ConnRequested - INFO _ -> Just ConnSndReady + INFO {} -> Just ConnSndReady CON _ -> Just ConnReady _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of - CONF confId _ connInfo -> do + -- TODO PQ if connection was created with PQSupportOn and CONF has PQSupportOff, then disable it in connection (store in DB, update connection object, pass PQSupportOff) + -- if the opposite, ignore or log warning + CONF confId pqSupport _ connInfo -> do -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing False conn' <- saveConnInfo conn connInfo -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId $ XInfo profileToSend - INFO connInfo -> do + -- TODO PQ if connection has pqSupport different from pqSupport in INFO log warning, ignore + INFO pqSupport connInfo -> do _conn' <- saveConnInfo conn connInfo pure () MSG meta _msgFlags msgBody -> do @@ -3552,15 +3564,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do - let MsgMeta {pqEncryption = CR.PQEncryption pqRcvEnabled} = msgMeta - (ct', conn') <- updateContactPQRcv user ct conn pqRcvEnabled + let MsgMeta {pqEncryption} = msgMeta + (ct', conn') <- updateContactPQRcv user ct conn pqEncryption checkIntegrityCreateItem (CDDirectRcv ct') msgMeta cmdId <- createAckCmd conn' withAckMessage agentConnId cmdId msgMeta $ do (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta cmdId msgBody let ct'' = ct' {activeConn = Just conn''} :: Contact assertDirectAllowed user MDRcv ct'' $ toCMEventTag event - updateChatLock "directMessage" event + updateChatLock "direct message" event case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr @@ -3589,7 +3601,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ directMsgReceived ct conn msgMeta msgRcpt - CONF confId _ connInfo -> do + -- TODO PQ this will happen with members and with contact cards - same as above + CONF confId pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -3607,7 +3620,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowAgentConnectionAsync user conn' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" - INFO connInfo -> do + INFO pqSupport connInfo -> do + -- TODO PQ log warning same above ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -3619,18 +3633,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ processContactProfileUpdate ct profile False XOk -> pure () _ -> messageError "INFO for existing contact must have x.grp.mem.info, x.info or x.ok" - CON (CR.PQEncryption pqEnabled) -> + CON pqEnc -> withStore' (\db -> getViaGroupMember db vr user ct) >>= \case Nothing -> do - withStore' $ \db -> updateConnPQEnabledCON db connId pqEnabled - let conn' = conn {pqSndEnabled = Just pqEnabled, pqRcvEnabled = Just pqEnabled} :: Connection + withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc + let conn' = conn {pqSndEnabled = Just pqEnc, pqRcvEnabled = Just pqEnc} :: Connection ct' = ct {activeConn = Just conn'} :: Contact -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) setContactNetworkStatus ct' NSConnected toView $ CRContactConnected user ct' (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct') $ do - createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EEInfo pqEnabled) Nothing + createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo pqEnc) Nothing createFeatureEnabledItems ct' when (contactConnInitiated conn') $ do let Connection {groupLinkId} = conn' @@ -3759,7 +3773,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" - CONF confId _ connInfo -> do + CONF confId _pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo conn' <- updatePeerChatVRange conn chatVRange case memberCategory m of @@ -3783,7 +3797,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" - INFO connInfo -> do + INFO _pqSupport connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -3807,7 +3821,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberCategory m of GCHostMember -> do toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupE2EEInfo $ E2EEInfo {pqEnabled = False}) Nothing + createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupE2EEInfo $ E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems gInfo m let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m @@ -3829,7 +3843,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo profileToSend = profileToSendOnAccept user profileMode True - void $ sendDirectMessage conn CR.PQEncOff (XGrpLinkMem profileToSend) (GroupId groupId) + void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId sendIntroductions members = do intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m shuffledIntros <- liftIO $ shuffleIntros intros @@ -3855,7 +3869,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMessage conn CR.PQEncOff (memberIntro $ reMember intro) (GroupId groupId) + void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId withStore' $ \db -> updateIntroStatus db introId GMIntroSent sendHistory = when (isCompatibleRange (memberChatVRange' m) batchSendVRange) $ do @@ -3954,12 +3968,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ (invitedByGroupMemberId membership) $ \hostId -> do host <- withStore $ \db -> getGroupMember db user groupId hostId forM_ (memberConn host) $ \hostConn -> - void $ sendDirectMessage hostConn CR.PQEncOff (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMemberMessage hostConn (XGrpMemCon memberId) groupId GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do im <- withStore $ \db -> getGroupMember db user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> - void $ sendDirectMessage imConn CR.PQEncOff (XGrpMemCon memberId) (GroupId groupId) + void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta @@ -4131,7 +4145,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case agentMsg of -- SMP CONF for SndFileConnection happens for direct file protocol -- when recipient of the file "joins" connection created by the sender - CONF confId _ connInfo -> do + CONF confId _pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -4192,7 +4206,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case activeConn of Just gMemberConn -> do sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectMessage gMemberConn CR.PQEncOff (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) $ GroupId groupId + void $ sendDirectMemberMessage gMemberConn (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) groupId _ -> throwChatError $ CECommandError "no GroupMember activeConn" _ -> throwChatError $ CECommandError "no grpMemberId" _ -> throwChatError $ CECommandError "unexpected cmdFunction" @@ -4200,7 +4214,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- SMP CONF for RcvFileConnection happens for group file protocol -- when sender of the file "joins" connection created by the recipient -- (sender doesn't create connections for all group members) - CONF confId _ connInfo -> do + CONF confId _pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -4261,7 +4275,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processUserContactRequest :: ACommand 'Agent e -> ConnectionEntity -> Connection -> UserContact -> m () processUserContactRequest agentMsg connEntity conn UserContact {userContactLinkId} = case agentMsg of - REQ invId _ connInfo -> do + REQ invId pqSupport _ connInfo -> do + -- TODO PQ this pqSupport needs to be combined with user's choice in toggle, then enable PQ support ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo case chatMsgEvent of XContact p xContactId_ -> profileContactRequest invId chatVRange p xContactId_ @@ -4289,8 +4304,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - enablePQ <- readTVarIO =<< asks pqExperimentalEnabled - ct <- acceptContactRequestAsync user cReq incognitoProfile True enablePQ + pqSup <- chatReadVar pqExperimentalEnabled + -- TODO PQ combine pqSup with pqSupport in REQ + ct <- acceptContactRequestAsync user cReq incognitoProfile True pqSup toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId @@ -4301,7 +4317,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo mem else do - ct <- acceptContactRequestAsync user cReq profileMode False False + -- TODO v5.7 remove old API (or v6.0?) + ct <- acceptContactRequestAsync user cReq profileMode False PQSupportOff toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> toView $ CRReceivedContactRequest user cReq @@ -4437,7 +4454,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () - sendProbe probe = void $ sendDirectMessage conn CR.PQEncOff (XInfoProbe probe) (GroupId groupId) + sendProbe probe = void $ sendDirectMemberMessage conn (XInfoProbe probe) groupId sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> m () sendProbeHashes cgms probe probeId = @@ -4451,7 +4468,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendProbeHash (COMGroupMember GroupMember {activeConn = Nothing}) = pure () sendProbeHash cgm@(COMGroupMember m@GroupMember {groupId, activeConn = Just conn}) = when (memberCurrent m) $ do - void $ sendDirectMessage conn CR.PQEncOff (XInfoProbeCheck probeHash) (GroupId groupId) + void $ sendDirectMemberMessage conn (XInfoProbeCheck probeHash) groupId withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> m () @@ -4808,7 +4825,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- receiving via a separate connection Just fileConnReq -> do subMode <- chatReadVar subscriptionMode - dm <- directMessage XOk + dm <- encodeConnInfo XOk connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode withStore' $ \db -> createSndDirectFTConnection db user fileId connIds subMode -- receiving inline @@ -4905,7 +4922,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode -- receiving via a separate connection -- [async agent commands] no continuation needed, but command should be asynchronous for stability - dm <- directMessage XOk + dm <- encodeConnInfo XOk connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m subMode (_, Just conn) -> do @@ -4939,7 +4956,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if sameGroupLinkId groupLinkId groupLinkId' then do subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XGrpAcpt membershipMemId + dm <- encodeConnInfo $ XGrpAcpt membershipMemId connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do setViaGroupLinkHash db groupId connId @@ -5136,7 +5153,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case cgm2 of COMContact c2@Contact {profile = p2} | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do - void $ sendDirectMessage conn CR.PQEncOff (XInfoProbeOk probe) (GroupId groupId) + void $ sendDirectMemberMessage conn (XInfoProbeOk probe) groupId COMContact <$$> associateMemberAndContact c2 m1 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing @@ -5392,7 +5409,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do hostConn <- withStore $ \db -> getConnectionById db user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} - void $ sendDirectMessage hostConn CR.PQEncOff msg (GroupId groupId) + void $ sendDirectMemberMessage hostConn msg groupId withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () @@ -5424,7 +5441,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito let membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership - dm <- directMessage $ XGrpMemInfo membershipMemId membershipProfile + dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode @@ -5628,7 +5645,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = joinConn subMode = do -- [incognito] send membership incognito profile let p = userProfileToSend user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing False - dm <- directMessage $ XInfo p + dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing @@ -5747,42 +5764,33 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () -createContactPQSndItem :: ChatMonad m => User -> Contact -> Connection -> PQFlag -> m (Contact, Connection) +createContactPQSndItem :: ChatMonad m => User -> Contact -> Connection -> PQEncryption -> m (Contact, Connection) createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = - -- TODO PQ refactor (?) check for pqSndEnabled change with updatePQSndEnabled in deliverMessagesB flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of - (Nothing, False) -> pure (ct, conn) - (Nothing, True) -> createPQItem $ CISndDirectE2EEInfo (E2EEInfo pqSndEnabled') - (Just b, b') - | b' /= b -> createPQItem $ CISndConnEvent (SCEPQEnabled pqSndEnabled') - | otherwise -> pure (ct, conn) + (Just b, b') | b' /= b -> createPQItem $ CISndConnEvent (SCEPqEnabled pqSndEnabled') + (Nothing, PQEncOn) -> createPQItem $ CISndDirectE2EEInfo (E2EInfo pqSndEnabled') + _ -> pure (ct, conn) where createPQItem ciContent = do - let cpqe = contactPQEnabled ct - conn' = conn {pqSndEnabled = Just pqSndEnabled'} :: Connection + let conn' = conn {pqSndEnabled = Just pqSndEnabled'} :: Connection ct' = ct {activeConn = Just conn'} :: Contact - cpqe' = contactPQEnabled ct' - when (cpqe' /= cpqe) $ do + when (contactPQEnabled ct /= contactPQEnabled ct') $ do createInternalChatItem user (CDDirectSnd ct') ciContent Nothing toView $ CRContactPQEnabled user ct' pqSndEnabled' pure (ct', conn') -updateContactPQRcv :: ChatMonad m => User -> Contact -> Connection -> PQFlag -> m (Contact, Connection) +updateContactPQRcv :: ChatMonad m => User -> Contact -> Connection -> PQEncryption -> m (Contact, Connection) updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' = flip catchChatError (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of - (Nothing, False) -> pure (ct, conn) - (Nothing, True) -> updatePQ $ CIRcvDirectE2EEInfo (E2EEInfo pqRcvEnabled') - (Just b, b') - | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPQEnabled pqRcvEnabled') - | otherwise -> pure (ct, conn) + (Just b, b') | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPqEnabled pqRcvEnabled') + (Nothing, PQEncOn) -> updatePQ $ CIRcvDirectE2EEInfo (E2EInfo pqRcvEnabled') + _ -> pure (ct, conn) where updatePQ ciContent = do withStore' $ \db -> updateConnPQRcvEnabled db connId pqRcvEnabled' - let cpqe = contactPQEnabled ct - conn' = conn {pqRcvEnabled = Just pqRcvEnabled'} :: Connection + let conn' = conn {pqRcvEnabled = Just pqRcvEnabled'} :: Connection ct' = ct {activeConn = Just conn'} :: Contact - cpqe' = contactPQEnabled ct' - when (cpqe' /= cpqe) $ do + when (contactPQEnabled ct /= contactPQEnabled ct') $ do createInternalChatItem user (CDDirectRcv ct') ciContent Nothing toView $ CRContactPQEnabled user ct' pqRcvEnabled' pure (ct', conn') @@ -5826,7 +5834,7 @@ sendDirectFileInline user ct ft sharedMsgId = do sendMemberFileInline :: ChatMonad m => GroupMember -> Connection -> FileTransferMeta -> SharedMsgId -> m () sendMemberFileInline m@GroupMember {groupId} conn ft sharedMsgId = do msgDeliveryId <- sendFileInline_ ft sharedMsgId $ \msg -> do - (sndMsg, msgDeliveryId, _) <- sendDirectMessage conn CR.PQEncOff msg $ GroupId groupId + (sndMsg, msgDeliveryId, _) <- sendDirectMemberMessage conn msg groupId pure (sndMsg, msgDeliveryId) withStore' $ \db -> updateSndGroupFTDelivery db m conn ft msgDeliveryId @@ -5854,7 +5862,7 @@ parseChatMessage conn s = do sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do - vr <- chatVersionRange PQEncOff + vr <- chatVersionRange PQSupportOff withStore' (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo Nothing -> do @@ -5869,7 +5877,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentCo sendFileChunkNo :: ChatMonad m => SndFileTransfer -> Integer -> m () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do chunkBytes <- readFileChunk ft chunkNo - (msgId, _) <- withAgent $ \a -> sendMessage a acId CR.PQEncOff SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} + (msgId, _) <- withAgent $ \a -> sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} withStore' $ \db -> updateSndFileChunkMsg db ft chunkNo msgId readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString @@ -5964,6 +5972,7 @@ cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do agentXFTPDeleteSndFileRemote user xsf fileId `catchChatError` (toView . CRChatError (Just user)) pure [] +-- TODO v6.0 remove cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> Bool -> m (Maybe ConnId) cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = if fileStatus == FSCancelled || fileStatus == FSComplete @@ -5977,8 +5986,8 @@ cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, age when sendCancel $ case fileInline of Just _ -> do (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db user connId - void . sendDirectMessage conn CR.PQEncOff (BFileChunk sharedMsgId FileChunkCancel) $ ConnectionId connId - _ -> withAgent $ \a -> void . sendMessage a acId CR.PQEncOff SMP.noMsgFlags $ smpEncode FileChunkCancel + void $ sendDirectMessage_ conn PQSupportOff (BFileChunk sharedMsgId FileChunkCancel) (ConnectionId connId) + _ -> withAgent $ \a -> void . sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunkCancel pure fileConnId fileConnId = if isNothing fileInline then Just acId else Nothing @@ -6017,11 +6026,11 @@ deleteOrUpdateMemberRecord user@User {userId} member = sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => User -> Contact -> ChatMsgEvent e -> m (SndMessage, Int64) sendDirectContactMessage user ct chatMsgEvent = do - conn@Connection {connId, enablePQ} <- liftEither $ contactSendConn_ ct - r <- sendDirectMessage conn (CR.PQEncryption enablePQ) chatMsgEvent (ConnectionId connId) - let (sndMessage, msgDeliveryId, CR.PQEncryption pqEnabled') = r + conn@Connection {connId, pqSupport} <- liftEither $ contactSendConn_ ct + r <- sendDirectMessage_ conn pqSupport chatMsgEvent (ConnectionId connId) + let (sndMessage, msgDeliveryId, pqEnc') = r -- TODO PQ use updated ct' and conn'? check downstream if it may affect something, maybe it's not necessary - (_ct', _conn') <- createContactPQSndItem user ct conn pqEnabled' + void $ createContactPQSndItem user ct conn pqEnc' -- (_ct', _conn') pure (sndMessage, msgDeliveryId) contactSendConn_ :: Contact -> Either ChatError Connection @@ -6035,38 +6044,44 @@ contactSendConn_ ct@Contact {activeConn} = case activeConn of where err = Left . ChatError -sendDirectMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> CR.PQEncryption -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64, CR.PQEncryption) -sendDirectMessage conn pqEnc chatMsgEvent connOrGroupId = do +-- unlike sendGroupMemberMessage, this function will not store message as pending +-- TODO v5.8 we could remove pending messages once all clients support forwarding +sendDirectMemberMessage :: (MsgEncodingI e, ChatMonad m) => Connection -> ChatMsgEvent e -> GroupId -> m (SndMessage, Int64, PQEncryption) +sendDirectMemberMessage conn chatMsgEvent groupId = sendDirectMessage_ conn PQSupportOff chatMsgEvent (GroupId groupId) + +sendDirectMessage_ :: (MsgEncodingI e, ChatMonad m) => Connection -> PQSupport -> ChatMsgEvent e -> ConnOrGroupId -> m (SndMessage, Int64, PQEncryption) +sendDirectMessage_ conn pqSup chatMsgEvent connOrGroupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId pqEnc - (msgDeliveryId, pqEnc') <- deliverMessage conn pqEnc (toCMEventTag chatMsgEvent) msgBody msgId + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId pqSup + -- TODO move compressed body to SndMessage and compress in createSndMessage + (msgDeliveryId, pqEnc') <- deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId pure (msg, msgDeliveryId, pqEnc') -createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> PQEncryption -> m SndMessage -createSndMessage chatMsgEvent connOrGroupId pqEnc = - liftEither . runIdentity =<< createSndMessages (Identity (connOrGroupId, pqEnc, chatMsgEvent)) +createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> PQSupport -> m SndMessage +createSndMessage chatMsgEvent connOrGroupId pqSup = + liftEither . runIdentity =<< createSndMessages (Identity (connOrGroupId, pqSup, chatMsgEvent)) -createSndMessages :: forall e m t. (MsgEncodingI e, ChatMonad' m, Traversable t) => t (ConnOrGroupId, PQEncryption, ChatMsgEvent e) -> m (t (Either ChatError SndMessage)) +createSndMessages :: forall e m t. (MsgEncodingI e, ChatMonad' m, Traversable t) => t (ConnOrGroupId, PQSupport, ChatMsgEvent e) -> m (t (Either ChatError SndMessage)) createSndMessages idsEvents = do g <- asks random ChatConfig {chatVRange = vr} <- asks config withStoreBatch $ \db -> fmap (createMsg db g vr) idsEvents where - createMsg :: DB.Connection -> TVar ChaChaDRG -> (PQEncryption -> VersionRangeChat) -> (ConnOrGroupId, PQEncryption, ChatMsgEvent e) -> IO (Either ChatError SndMessage) - createMsg db g vr (connOrGroupId, pqEnc, evnt) = runExceptT $ do + createMsg :: DB.Connection -> TVar ChaChaDRG -> (PQSupport -> VersionRangeChat) -> (ConnOrGroupId, PQSupport, ChatMsgEvent e) -> IO (Either ChatError SndMessage) + createMsg db g vr (connOrGroupId, pqSup, evnt) = runExceptT $ do withExceptT ChatErrorStore $ createNewSndMessage db g connOrGroupId evnt encodeMessage where encodeMessage sharedMsgId = - encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr pqEnc, msgId = Just sharedMsgId, chatMsgEvent = evnt} + encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr pqSup, msgId = Just sharedMsgId, chatMsgEvent = evnt} sendGroupMemberMessages :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> Connection -> NonEmpty (ChatMsgEvent e) -> GroupId -> m () sendGroupMemberMessages user conn events groupId = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - let idsEvts = L.map (GroupId groupId,PQEncOff,) events + let idsEvts = L.map (GroupId groupId,PQSupportOff,) events (errs, msgs) <- partitionEithers . L.toList <$> createSndMessages idsEvts unless (null errs) $ toView $ CRChatErrors (Just user) errs forM_ (L.nonEmpty msgs) $ \msgs' -> do - -- TODO PQ based on version (?) + -- TODO v5.7 based on version (?) -- let shouldCompress = False -- batched <- if shouldCompress then batchSndMessagesBinary msgs' else pure $ batchSndMessagesJSON msgs' let batched = batchSndMessagesJSON msgs' @@ -6078,12 +6093,13 @@ sendGroupMemberMessages user conn events groupId = do processSndMessageBatch :: ChatMonad m => Connection -> MsgBatch -> m () processSndMessageBatch conn@Connection {connId} (MsgBatch batchBody sndMsgs) = do - (agentMsgId, _pqEnc) <- withAgent $ \a -> sendMessage a (aConnId conn) CR.PQEncOff MsgFlags {notification = True} batchBody + (agentMsgId, _pqEnc) <- withAgent $ \a -> sendMessage a (aConnId conn) PQEncOff MsgFlags {notification = True} batchBody let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs +-- TODO v5.7 update batching for groups batchSndMessagesJSON :: NonEmpty SndMessage -> [Either ChatError MsgBatch] -batchSndMessagesJSON = batchMessages (maxEncodedMsgLength PQEncOff) . L.toList +batchSndMessagesJSON = batchMessages maxRawMsgLength . L.toList -- batchSndMessagesBinary :: forall m. ChatMonad m => NonEmpty SndMessage -> m [Either ChatError MsgBatch] -- batchSndMessagesBinary msgs = do @@ -6097,15 +6113,15 @@ batchSndMessagesJSON = batchMessages (maxEncodedMsgLength PQEncOff) . L.toList -- SMP.TBError tbe SndMessage {msgId} -> Left . ChatError $ CEInternalError (show tbe <> " " <> show msgId) -- SMP.TBTransmission {} -> Left . ChatError $ CEInternalError "batchTransmissions_ didn't produce a batch" -directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString -directMessage = directMessagePQ PQEncOff maxConnInfoLength +encodeConnInfo :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString +encodeConnInfo = encodeConnInfoPQ PQSupportOff -- TODO PQ check size after compression (in compressedBatchMsgBody_ ?) -directMessagePQ :: (MsgEncodingI e, ChatMonad m) => CR.PQEncryption -> (CR.PQEncryption -> Int) -> ChatMsgEvent e -> m ByteString -directMessagePQ pqEnc maxMsgSize chatMsgEvent = do - chatVRange <- chatVersionRange pqEnc +encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> ChatMsgEvent e -> m ByteString +encodeConnInfoPQ pqSup chatMsgEvent = do + chatVRange <- chatVersionRange pqSup let shouldCompress = maxVersion chatVRange >= compressedBatchingVersion - r = encodeChatMessage maxMsgSize ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} + r = encodeChatMessage maxConnInfoLength ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} case r of ECMEncoded encodedBody | shouldCompress -> compressedBatchMsgBody encodedBody @@ -6116,23 +6132,23 @@ directMessagePQ pqEnc maxMsgSize chatMsgEvent = do liftEitherError (ChatError . CEException . mappend "compressedBatchMsgBody: ") $ withCompressCtx (B.length msgBody) (`compressedBatchMsgBody_` msgBody) -deliverMessage :: ChatMonad m => Connection -> CR.PQEncryption -> CMEventTag e -> MsgBody -> MessageId -> m (Int64, CR.PQEncryption) -deliverMessage conn pqEnc cmEventTag msgBody msgId = do +deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m (Int64, PQEncryption) +deliverMessage conn cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} - deliverMessage' conn pqEnc msgFlags msgBody msgId + deliverMessage' conn msgFlags msgBody msgId -deliverMessage' :: ChatMonad m => Connection -> CR.PQEncryption -> MsgFlags -> MsgBody -> MessageId -> m (Int64, CR.PQEncryption) -deliverMessage' conn pqEnc msgFlags msgBody msgId = - deliverMessages ((conn, pqEnc, msgFlags, msgBody, msgId) :| []) >>= \case +deliverMessage' :: ChatMonad m => Connection -> MsgFlags -> MsgBody -> MessageId -> m (Int64, PQEncryption) +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) -type MsgReq = (Connection, CR.PQEncryption, MsgFlags, MsgBody, MessageId) +type MsgReq = (Connection, MsgFlags, MsgBody, MessageId) -deliverMessages :: ChatMonad' m => NonEmpty MsgReq -> m (NonEmpty (Either ChatError (Int64, CR.PQEncryption))) +deliverMessages :: ChatMonad' m => NonEmpty MsgReq -> m (NonEmpty (Either ChatError (Int64, PQEncryption))) deliverMessages msgs = deliverMessagesB $ L.map Right msgs -deliverMessagesB :: ChatMonad' m => NonEmpty (Either ChatError MsgReq) -> m (NonEmpty (Either ChatError (Int64, CR.PQEncryption))) +deliverMessagesB :: ChatMonad' m => NonEmpty (Either ChatError MsgReq) -> m (NonEmpty (Either ChatError (Int64, PQEncryption))) deliverMessagesB msgReqs = do msgReqs' <- compressBodies sent <- L.zipWith prepareBatch msgReqs' <$> withAgent' (`sendMessagesB` L.map toAgent msgReqs') @@ -6141,28 +6157,28 @@ deliverMessagesB msgReqs = do where compressBodies = liftIO $ withCompressCtx maxRawMsgLength $ \cctx -> forM msgReqs $ \case - mr@(Right (conn, pqEnc, msgFlags, msgBody, msgId)) - | pqEnc == CR.PQEncOn -> do - bimap (ChatError . CEException) (\cBody -> (conn, pqEnc, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody - | otherwise -> pure mr + -- TODO PQ combine pqSupport and pqEncryption to one type: + -- data PQMode = PQDisabled | PQSupported PQEncryption + mr@(Right (conn@Connection {pqSupport, pqEncryption}, msgFlags, msgBody, msgId)) -> case pqSupport `CR.pqSupportOrEnc` pqEncryption of + PQSupportOn -> + bimap (ChatError . CEException) (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody + PQSupportOff -> pure mr skip -> pure skip toAgent = \case - Right (conn, pqEnc, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEnc, msgFlags, msgBody) + Right (conn@Connection {pqEncryption}, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEncryption, msgFlags, msgBody) Left _ce -> Left (AP.INTERNAL "ChatError, skip") -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing - createDelivery :: DB.Connection -> (MsgReq, (AgentMsgId, CR.PQEncryption)) -> IO (Either ChatError (Int64, CR.PQEncryption)) - createDelivery db ((Connection {connId}, _, _, _, msgId), (agentMsgId, pqEnc')) = + createDelivery :: DB.Connection -> (MsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError (Int64, PQEncryption)) + createDelivery db ((Connection {connId}, _, _, msgId), (agentMsgId, pqEnc')) = Right . (,pqEnc') <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId - updatePQSndEnabled :: DB.Connection -> (MsgReq, (AgentMsgId, CR.PQEncryption)) -> IO () - updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _, _, _), (_, CR.PQEncryption pqSndEnabled')) = + updatePQSndEnabled :: DB.Connection -> (MsgReq, (AgentMsgId, PQEncryption)) -> IO () + updatePQSndEnabled db ((Connection {connId, pqSndEnabled}, _, _, _), (_, pqSndEnabled')) = case (pqSndEnabled, pqSndEnabled') of - (Nothing, False) -> pure () - (Nothing, True) -> updatePQ - (Just b, b') - | b' /= b -> updatePQ - | otherwise -> pure () + (Just b, b') | b' /= b -> updatePQ + (Nothing, PQEncOn) -> updatePQ + _ -> pure () where updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' @@ -6190,11 +6206,12 @@ sendGroupMessage user gInfo members chatMsgEvent = do 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) PQEncOff + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) PQSupportOff recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} (toSend, pending) = foldr addMember ([], []) recipientMembers - msgReqs = map (\(_, conn) -> (conn, CR.PQEncOff, msgFlags, msgBody, msgId)) toSend + -- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here + msgReqs = map (\(_, conn) -> (conn, msgFlags, msgBody, msgId)) toSend delivered <- maybe (pure []) (fmap L.toList . deliverMessages) $ L.nonEmpty msgReqs let errors = lefts delivered unless (null errors) $ toView $ CRChatErrors (Just user) errors @@ -6248,12 +6265,12 @@ memberSendAction chatMsgEvent members m@GroupMember {invitedByGroupMemberId} = c 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) PQEncOff + msg <- createSndMessage chatMsgEvent (GroupId groupId) PQSupportOff 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 CR.PQEncOff (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver + MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () @@ -6264,7 +6281,7 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn processPendingMessage pgm `catchChatError` (toView . CRChatError (Just user)) where processPendingMessage PendingGroupMessage {msgId, cmEventTag = ACMEventTag _ tag, msgBody, introId_} = do - void $ deliverMessage conn CR.PQEncOff tag msgBody msgId + void $ deliverMessage conn tag msgBody msgId withStore' $ \db -> deletePendingGroupMessage db groupMemberId msgId case tag of XGrpMemFwd_ -> case introId_ of @@ -6298,7 +6315,7 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMessage fmConn CR.PQEncOff (XGrpMemCon amMemId) (GroupId groupId) + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e _ -> throwError e pure (am', conn', msg) @@ -6314,7 +6331,7 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId if sameMemberId refMemberId am then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMessage fmConn CR.PQEncOff (XGrpMemCon amMemberId) (GroupId groupId) + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" throwError e _ -> throwError e @@ -6410,27 +6427,27 @@ cancelCIFile user file_ = createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction - connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode CR.IKPQOff subMode + connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode IKPQOff subMode pure (cmdId, connId) joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> m (CommandId, ConnId) joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo CR.PQEncOff subMode + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo PQSupportOff subMode pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () -allowAgentConnectionAsync user conn@Connection {connId, enablePQ} confId msg = do +allowAgentConnectionAsync user conn@Connection {connId, pqSupport} confId msg = do cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn - dm <- directMessagePQ (CR.PQEncryption enablePQ) maxConnInfoLength msg + dm <- encodeConnInfoPQ pqSupport msg withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> CR.PQEncryption -> m (CommandId, ConnId) -agentAcceptContactAsync user enableNtfs invId msg subMode pqEnc = do +agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> m (CommandId, ConnId) +agentAcceptContactAsync user enableNtfs invId msg subMode pqSup = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - dm <- directMessagePQ pqEnc maxConnInfoLength msg - connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqEnc subMode + dm <- encodeConnInfoPQ pqSup msg + connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqSup subMode pure (cmdId, connId) deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () @@ -6664,10 +6681,10 @@ waitChatStartedAndActivated = do activated <- readTVar chatActivated unless (isJust started && activated) retry -chatVersionRange :: ChatMonad' m => CR.PQEncryption -> m VersionRangeChat -chatVersionRange pqEnc = do +chatVersionRange :: ChatMonad' m => PQSupport -> m VersionRangeChat +chatVersionRange pq = do ChatConfig {chatVRange} <- asks config - pure $ chatVRange pqEnc + pure $ chatVRange pq chatCommandP :: Parser ChatCommand chatCommandP = @@ -6711,7 +6728,7 @@ chatCommandP = "/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), - "/_pq " *> (APISetPQEnabled <$> onOffP), + "/_pq " *> (APISetPQEnabled . PQSupport <$> onOffP), "/_pq allow " *> (APIAllowContactPQ <$> A.decimal), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a23d88a7b7..f46b0183c3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -72,8 +72,8 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..)) import Simplex.Messaging.Encoding.String -import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) @@ -122,7 +122,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, - chatVRange :: CR.PQEncryption -> VersionRangeChat, + chatVRange :: PQSupport -> VersionRangeChat, confirmMigrations :: MigrationConfirmation, defaultServers :: DefaultAgentServers, tbqSize :: Natural, @@ -207,7 +207,7 @@ data ChatController = ChatController tempDirectory :: TVar (Maybe FilePath), logFilePath :: Maybe FilePath, contactMergeEnabled :: TVar Bool, - pqExperimentalEnabled :: TVar PQFlag -- TODO remove in 5.7 + pqExperimentalEnabled :: TVar PQSupport -- TODO v5.7 remove } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase @@ -244,7 +244,7 @@ data ChatCommand | SetRemoteHostsFolder FilePath | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool - | APISetPQEnabled Bool + | APISetPQEnabled PQSupport | APIAllowContactPQ ContactId | APIExportArchive ArchiveConfig | ExportArchive @@ -701,7 +701,7 @@ data ChatResponse | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} | CRContactPQAllowed {user :: User, contact :: Contact} - | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: Bool} + | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index dfe3d0d043..b44090290e 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -6,6 +6,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} @@ -29,6 +30,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOn, pattern PQEncOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>)) @@ -139,10 +141,10 @@ data CIContent (d :: MsgDirection) where CISndModerated :: CIContent 'MDSnd CIRcvModerated :: CIContent 'MDRcv CIRcvBlocked :: CIContent 'MDRcv - CISndDirectE2EEInfo :: E2EEInfo -> CIContent 'MDSnd - CIRcvDirectE2EEInfo :: E2EEInfo -> CIContent 'MDRcv - CISndGroupE2EEInfo :: E2EEInfo -> CIContent 'MDSnd -- when new group is created - CIRcvGroupE2EEInfo :: E2EEInfo -> CIContent 'MDRcv -- when enabled with some member + CISndDirectE2EEInfo :: E2EInfo -> CIContent 'MDSnd + CIRcvDirectE2EEInfo :: E2EInfo -> CIContent 'MDRcv + CISndGroupE2EEInfo :: E2EInfo -> CIContent 'MDSnd -- when new group is created + CIRcvGroupE2EEInfo :: E2EInfo -> CIContent 'MDRcv -- when enabled with some member CIInvalidJSON :: Text -> CIContent d -- this is also used for logical database errors, e.g. SEBadChatItem -- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API @@ -151,9 +153,7 @@ data CIContent (d :: MsgDirection) where deriving instance Show (CIContent d) -data E2EEInfo = E2EEInfo - { pqEnabled :: Bool - } +data E2EInfo = E2EInfo {pqEnabled :: PQEncryption} deriving (Eq, Show) ciMsgContent :: CIContent d -> Maybe MsgContent @@ -262,22 +262,22 @@ ciContentToText = \case CISndModerated -> ciModeratedText CIRcvModerated -> ciModeratedText CIRcvBlocked -> "blocked" - CISndDirectE2EEInfo e2eeInfo -> directE2EEInfoToText e2eeInfo - CIRcvDirectE2EEInfo e2eeInfo -> directE2EEInfoToText e2eeInfo - CISndGroupE2EEInfo e2eeInfo -> groupE2EEInfoToText e2eeInfo - CIRcvGroupE2EEInfo e2eeInfo -> groupE2EEInfoToText e2eeInfo + CISndDirectE2EEInfo e2eeInfo -> directE2EInfoToText e2eeInfo + CIRcvDirectE2EEInfo e2eeInfo -> directE2EInfoToText e2eeInfo + CISndGroupE2EEInfo e2eeInfo -> groupE2EInfoToText e2eeInfo + CIRcvGroupE2EEInfo e2eeInfo -> groupE2EInfoToText e2eeInfo CIInvalidJSON _ -> "invalid content JSON" -directE2EEInfoToText :: E2EEInfo -> Text -directE2EEInfoToText E2EEInfo {pqEnabled} - | pqEnabled = "This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery." - | otherwise = e2eeInfoNoPQText +directE2EInfoToText :: E2EInfo -> Text +directE2EInfoToText E2EInfo {pqEnabled} = case pqEnabled of + PQEncOn -> "This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery." + PQEncOff -> e2eInfoNoPQText -groupE2EEInfoToText :: E2EEInfo -> Text -groupE2EEInfoToText _e2eeInfo = e2eeInfoNoPQText +groupE2EInfoToText :: E2EInfo -> Text +groupE2EInfoToText _e2eeInfo = e2eInfoNoPQText -e2eeInfoNoPQText :: Text -e2eeInfoNoPQText = +e2eInfoNoPQText :: Text +e2eInfoNoPQText = "This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery." ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text @@ -323,9 +323,9 @@ rcvConnEventToText = \case SPCompleted -> "changed address for you" RCERatchetSync syncStatus -> ratchetSyncStatusToText syncStatus RCEVerificationCodeReset -> "security code changed" - RCEPQEnabled enabled - | enabled -> "post-quantum encryption enabled" - | otherwise -> "post-quantum encryption disabled" + RCEPqEnabled pqEnc -> case pqEnc of + PQEncOn -> "post-quantum encryption enabled" + PQEncOff -> "post-quantum encryption disabled" ratchetSyncStatusToText :: RatchetSyncState -> Text ratchetSyncStatusToText = \case @@ -343,9 +343,9 @@ sndConnEventToText = \case SPSecured -> "secured new address" <> forMember m <> "..." SPCompleted -> "you changed address" <> forMember m SCERatchetSync syncStatus m -> ratchetSyncStatusToText syncStatus <> forMember m - SCEPQEnabled enabled - | enabled -> "post-quantum encryption enabled" - | otherwise -> "post-quantum encryption disabled" + SCEPqEnabled pqEnc -> case pqEnc of + PQEncOn -> "post-quantum encryption enabled" + PQEncOff -> "post-quantum encryption disabled" where forMember member_ = maybe "" (\GroupMemberRef {profile = Profile {displayName}} -> " for " <> displayName) member_ @@ -416,10 +416,10 @@ data JSONCIContent | JCISndModerated | JCIRcvModerated | JCIRcvBlocked - | JCISndDirectE2EEInfo {e2eeInfo :: E2EEInfo} - | JCIRcvDirectE2EEInfo {e2eeInfo :: E2EEInfo} - | JCISndGroupE2EEInfo {e2eeInfo :: E2EEInfo} - | JCIRcvGroupE2EEInfo {e2eeInfo :: E2EEInfo} + | JCISndDirectE2EEInfo {e2eeInfo :: E2EInfo} + | JCIRcvDirectE2EEInfo {e2eeInfo :: E2EInfo} + | JCISndGroupE2EEInfo {e2eeInfo :: E2EInfo} + | JCIRcvGroupE2EEInfo {e2eeInfo :: E2EInfo} | JCIInvalidJSON {direction :: MsgDirection, json :: Text} jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent @@ -519,10 +519,10 @@ data DBJSONCIContent | DBJCISndModerated | DBJCIRcvModerated | DBJCIRcvBlocked - | DBJCISndDirectE2EEInfo {e2eeInfo :: E2EEInfo} - | DBJCIRcvDirectE2EEInfo {e2eeInfo :: E2EEInfo} - | DBJCISndGroupE2EEInfo {e2eeInfo :: E2EEInfo} - | DBJCIRcvGroupE2EEInfo {e2eeInfo :: E2EEInfo} + | DBJCISndDirectE2EEInfo {e2eeInfo :: E2EInfo} + | DBJCIRcvDirectE2EEInfo {e2eeInfo :: E2EInfo} + | DBJCISndGroupE2EEInfo {e2eeInfo :: E2EInfo} + | DBJCIRcvGroupE2EEInfo {e2eeInfo :: E2EInfo} | DBJCIInvalidJSON {direction :: MsgDirection, json :: Text} dbJsonCIContent :: forall d. MsgDirectionI d => CIContent d -> DBJSONCIContent @@ -616,7 +616,7 @@ ciCallInfoText status duration = case status of CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" -$(JQ.deriveJSON defaultJSON ''E2EEInfo) +$(JQ.deriveJSON defaultJSON ''E2EInfo) $(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError) diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index f8a877187a..7ce5f73cde 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -9,6 +9,7 @@ import qualified Data.Aeson.TH as J import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Parsers (dropPrefix, singleFieldJSON, sumTypeJSON) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption) data RcvGroupEvent = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting @@ -42,13 +43,13 @@ data RcvConnEvent = RCESwitchQueue {phase :: SwitchPhase} | RCERatchetSync {syncStatus :: RatchetSyncState} | RCEVerificationCodeReset - | RCEPQEnabled {enabled :: Bool} + | RCEPqEnabled {enabled :: PQEncryption} deriving (Show) data SndConnEvent = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} - | SCEPQEnabled {enabled :: Bool} + | SCEPqEnabled {enabled :: PQEncryption} deriving (Show) data RcvDirectEvent diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index a4c3e0a4b5..aba09c4f0b 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -47,7 +47,7 @@ import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Util import Simplex.Messaging.Compression (CompressCtx, compress, decompressBatch) -import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOn, pattern PQEncOff) +import Simplex.Messaging.Crypto.Ratchet (PQSupport (..), pattern PQSupportOn, pattern PQSupportOff) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) @@ -63,10 +63,11 @@ currentChatVersion = VersionChat 7 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) -- TODO remove parameterization in 5.7 -supportedChatVRange :: PQEncryption -> VersionRangeChat +supportedChatVRange :: PQSupport -> VersionRangeChat supportedChatVRange pq = mkVersionRange (VersionChat 1) $ case pq of - PQEncOn -> compressedBatchingVersion - PQEncOff -> currentChatVersion + PQSupportOn -> compressedBatchingVersion + PQSupportOff -> currentChatVersion +{-# INLINE supportedChatVRange #-} -- version range that supports skipping establishing direct connections in a group groupNoDirectVRange :: VersionRangeChat @@ -522,24 +523,26 @@ $(JQ.deriveJSON defaultJSON ''QuotedMsg) maxRawMsgLength :: Int maxRawMsgLength = 15610 -maxEncodedMsgLength :: PQEncryption -> Int +maxEncodedMsgLength :: PQSupport -> Int maxEncodedMsgLength = \case - PQEncOn -> 13410 -- reduced by 2200 (original message should be compressed) - PQEncOff -> maxRawMsgLength + PQSupportOn -> 13410 -- reduced by 2200 (original message should be compressed) + PQSupportOff -> maxRawMsgLength +{-# INLINE maxEncodedMsgLength #-} -maxConnInfoLength :: PQEncryption -> Int +maxConnInfoLength :: PQSupport -> Int maxConnInfoLength = \case - PQEncOn -> 10902 -- reduced by 3700 - PQEncOff -> 14602 -- 15610 - delta in agent between MSG and INFO + PQSupportOn -> 10902 -- reduced by 3700 + PQSupportOff -> 14602 -- 15610 - delta in agent between MSG and INFO +{-# INLINE maxConnInfoLength #-} data EncodedChatMessage = ECMEncoded ByteString | ECMLarge -encodeChatMessage :: MsgEncodingI e => (PQEncryption -> Int) -> ChatMessage e -> EncodedChatMessage +encodeChatMessage :: MsgEncodingI e => (PQSupport -> Int) -> ChatMessage e -> EncodedChatMessage encodeChatMessage getMaxSize msg = do case chatToAppMessage msg of AMJson m -> do let body = LB.toStrict $ J.encode m - if B.length body > getMaxSize PQEncOff + if B.length body > getMaxSize PQSupportOff then ECMLarge else ECMEncoded body AMBinary m -> ECMEncoded $ strEncode m diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 4bfe87d5f1..a9e227b0e6 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -89,6 +89,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Version @@ -124,14 +125,14 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> PQFlag -> ExceptT StoreError IO Contact -createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode enablePQ = do - PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode enablePQ +createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> PQSupport -> ExceptT StoreError IO Contact +createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode pqSup = do + PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode pqSup liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId) getContact db user contactId -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> PQFlag -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode enablePQ = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> PQSupport -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -146,7 +147,7 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou |] ( (userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, enablePQ) + :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -188,8 +189,8 @@ getContactByConnReqHash db user@User {userId} cReqHash = |] (userId, cReqHash, CSActive) -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> PQFlag -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode enablePQ = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> PQSupport -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let contactConnInitiated = pccConnStatus == ConnNew @@ -202,7 +203,7 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile VALUES (?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, enablePQ) + :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -705,8 +706,8 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQFlag -> Bool -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode enablePQ contactUsed = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -718,7 +719,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "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 enablePQ + conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode pqSup let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn 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} diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index a6985f08c2..6192f5eda4 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -114,6 +114,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) import System.FilePath (takeFileName) @@ -427,10 +428,11 @@ lookupChatRefByFileId db User {userId} fileId = |] (userId, fileId) +-- TODO v6.0 remove createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection createSndFileConnection_ db userId fileId agentConnId subMode = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode False + createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c50ec4fbf7..7c1721908b 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -141,6 +142,7 @@ import Simplex.Messaging.Agent.Protocol (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.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version @@ -184,7 +186,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode False + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = @@ -896,7 +898,7 @@ createAcceptedMemberConnection groupMemberId subMode = do createdAt <- liftIO getCurrentTime - Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode False + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact @@ -1218,7 +1220,7 @@ createIntroReMember currentTs <- liftIO getCurrentTime newMember <- case directConnIds of Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode False + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff liftIO $ setCommandConnId db user directCmdId directConnId (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) @@ -1239,7 +1241,7 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode False + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff setCommandConnId db user directCmdId directConnId contactId <- createMemberContact_ directConnId currentTs updateMember_ contactId currentTs @@ -1271,7 +1273,7 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact connLevel currentTs subMode = - createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode False + createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = @@ -1932,7 +1934,8 @@ createMemberContact localAlias = "", createdAt = currentTs, connectionCode = Nothing, - enablePQ = False, + pqSupport = PQSupportOff, + pqEncryption = PQEncOff, pqSndEnabled = Nothing, pqRcvEnabled = Nothing, authErrCounter = 0 @@ -2063,7 +2066,8 @@ createMemberContactConn_ localAlias = "", createdAt = currentTs, connectionCode = Nothing, - enablePQ = False, + pqSupport = PQSupportOff, + pqEncryption = PQEncOff, pqSndEnabled = Nothing, pqRcvEnabled = Nothing, authErrCounter = 0 diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c4611f4b9e..3d7db3cf0e 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -85,6 +85,7 @@ import Simplex.Messaging.Agent.Protocol (ACorrId, 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 qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) @@ -323,7 +324,7 @@ createUserContactLink db User {userId} agentConnId cReq subMode = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode False + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] getUserAddressConnections db User {userId} = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 77fd56489f..1020cbeb14 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeOperators #-} @@ -36,6 +37,8 @@ import Simplex.Messaging.Agent.Protocol (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.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQSupportOff) +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (allFinally) @@ -148,12 +151,13 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Maybe PQFlag, Maybe PQFlag, Maybe PQFlag, Int, VersionChat, VersionChat) +-- TODO PQ nullable? +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, VersionChat, VersionChat) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQFlag, Maybe PQFlag, Maybe PQFlag, Maybe Int, Maybe VersionChat, Maybe VersionChat) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe VersionChat, Maybe VersionChat) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, enablePQ_, pqSndEnabled, pqRcvEnabled, authErrCounter, minVer, maxVer)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqEncryption_, pqSndEnabled, pqRcvEnabled, authErrCounter, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, @@ -170,7 +174,9 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup localAlias, entityId = entityId_ connType, connectionCode = SecurityCode <$> code_ <*> verifiedAt_, - enablePQ = fromMaybe False enablePQ_, + -- TODO PQ add field + pqSupport = maybe PQSupportOff CR.pqEncToSupport pqEncryption_, + pqEncryption = fromMaybe PQEncOff pqEncryption_, pqSndEnabled, pqRcvEnabled, authErrCounter, @@ -189,11 +195,12 @@ toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, v Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, enablePQ_, pqSndEnabled_, pqRcvEnabled_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQFlag -> IO Connection -createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode enablePQ = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection +createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode pqSup = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId + -- TODO PQ store pq_support DB.execute db [sql| @@ -205,7 +212,7 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (minV, maxV, subMode == SMOnlyCreate, enablePQ) + :. (minV, maxV, subMode == SMOnlyCreate, pqSup) ) connId <- insertedRowId db pure @@ -226,7 +233,8 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange localAlias = "", createdAt = currentTs, connectionCode = Nothing, - enablePQ, + pqSupport = pqSup, + pqEncryption = CR.pqSupportToEnc pqSup, pqSndEnabled = Nothing, pqRcvEnabled = Nothing, authErrCounter = 0 @@ -256,7 +264,8 @@ allowConnEnablePQ db connId = |] (Only connId) -updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQFlag -> IO () +-- TODO PQ possibly combine all functions +updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQEncryption -> IO () updateConnPQSndEnabled db connId pqSndEnabled = DB.execute db @@ -267,7 +276,7 @@ updateConnPQSndEnabled db connId pqSndEnabled = |] (pqSndEnabled, connId) -updateConnPQRcvEnabled :: DB.Connection -> Int64 -> PQFlag -> IO () +updateConnPQRcvEnabled :: DB.Connection -> Int64 -> PQEncryption -> IO () updateConnPQRcvEnabled db connId pqRcvEnabled = DB.execute db @@ -278,7 +287,7 @@ updateConnPQRcvEnabled db connId pqRcvEnabled = |] (pqRcvEnabled, connId) -updateConnPQEnabledCON :: DB.Connection -> Int64 -> PQFlag -> IO () +updateConnPQEnabledCON :: DB.Connection -> Int64 -> PQEncryption -> IO () updateConnPQEnabledCON db connId pqEnabled = DB.execute db diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 05b4bf46ed..adffb94ebf 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -49,6 +49,7 @@ import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) @@ -335,6 +336,8 @@ data UserContactRequest = UserContactRequest createdAt :: UTCTime, updatedAt :: UTCTime, xContactId :: Maybe XContactId + -- TODO PQ save pqSupport from REQ to database + -- pqSupport :: PQSupport } deriving (Eq, Show) @@ -1335,8 +1338,6 @@ type ConnReqInvitation = ConnectionRequestUri 'CMInvitation type ConnReqContact = ConnectionRequestUri 'CMContact -type PQFlag = Bool - data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, @@ -1353,9 +1354,10 @@ data Connection = Connection localAlias :: Text, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID connectionCode :: Maybe SecurityCode, - enablePQ :: PQFlag, - pqSndEnabled :: Maybe PQFlag, - pqRcvEnabled :: Maybe PQFlag, + pqSupport :: PQSupport, + pqEncryption :: PQEncryption, + pqSndEnabled :: Maybe PQEncryption, + pqRcvEnabled :: Maybe PQEncryption, authErrCounter :: Int, createdAt :: UTCTime } @@ -1391,8 +1393,8 @@ connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId connPQEnabled :: Connection -> Bool -connPQEnabled Connection {pqSndEnabled, pqRcvEnabled} = - pqSndEnabled == Just True && pqRcvEnabled == Just True +connPQEnabled Connection {pqSndEnabled = Just (PQEncryption s), pqRcvEnabled = Just (PQEncryption r)} = s && r +connPQEnabled _ = False data PendingContactConnection = PendingContactConnection { pccConnId :: Int64, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7783ac804a..ed0095c531 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -56,6 +56,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) @@ -340,7 +341,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] CRRemoteCtrlStopped {} -> ["remote controller stopped"] - CRContactPQEnabled u c pqOn -> ttyUser u [ttyContact' c <> ": post-quantum encryption " <> (if pqOn then "enabled" else "disabled")] + CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": post-quantum encryption " <> (if pqOn then "enabled" else "disabled")] CRContactPQAllowed u c -> ttyUser u [ttyContact' c <> ": post-quantum encryption allowed"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index e5e33761c5..d5a56ec91a 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -41,8 +41,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) -import Simplex.Messaging.Crypto.Ratchet (pattern VersionE2E) -import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange, pattern PQSupportOff, pattern VersionE2E) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport @@ -142,8 +141,8 @@ testAgentCfgVPrev :: AgentConfig testAgentCfgVPrev = testAgentCfg { smpClientVRange = prevRange $ smpClientVRange testAgentCfg, - smpAgentVRange = \_ -> prevRange $ supportedSMPAgentVRange CR.PQEncOff, - e2eEncryptVRange = \_ -> prevRange $ CR.supportedE2EEncryptVRange CR.PQEncOff, + smpAgentVRange = \_ -> prevRange $ supportedSMPAgentVRange PQSupportOff, + e2eEncryptVRange = \_ -> prevRange $ supportedE2EEncryptVRange PQSupportOff, smpCfg = (smpCfg testAgentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg testAgentCfg} } diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 3dc5500204..600a4bcc1f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -25,7 +25,7 @@ import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) +import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOff) import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) @@ -116,14 +116,14 @@ chatDirectTests = do it "should send delivery receipts depending on configuration" testConfigureDeliveryReceipts describe "negotiate connection peer chat protocol version range" $ do describe "peer version range correctly set for new connection via invitation" $ do - testInvVRange (supportedChatVRange PQEncOff) (supportedChatVRange PQEncOff) - testInvVRange (supportedChatVRange PQEncOff) vr11 - testInvVRange vr11 (supportedChatVRange PQEncOff) + testInvVRange (supportedChatVRange PQSupportOff) (supportedChatVRange PQSupportOff) + testInvVRange (supportedChatVRange PQSupportOff) vr11 + testInvVRange vr11 (supportedChatVRange PQSupportOff) testInvVRange vr11 vr11 describe "peer version range correctly set for new connection via contact request" $ do - testReqVRange (supportedChatVRange PQEncOff) (supportedChatVRange PQEncOff) - testReqVRange (supportedChatVRange PQEncOff) vr11 - testReqVRange vr11 (supportedChatVRange PQEncOff) + testReqVRange (supportedChatVRange PQSupportOff) (supportedChatVRange PQSupportOff) + testReqVRange (supportedChatVRange PQSupportOff) vr11 + testReqVRange vr11 (supportedChatVRange PQSupportOff) testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange describe "network statuses" $ do @@ -2700,7 +2700,7 @@ testUpdatePeerChatVRange tmp = contactInfoChatVRange alice vr11 bob ##> "/i alice" - contactInfoChatVRange bob (supportedChatVRange PQEncOff) + contactInfoChatVRange bob (supportedChatVRange PQSupportOff) withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2709,10 +2709,10 @@ testUpdatePeerChatVRange tmp = alice <# "bob> hello 1" alice ##> "/i bob" - contactInfoChatVRange alice (supportedChatVRange PQEncOff) + contactInfoChatVRange alice (supportedChatVRange PQSupportOff) bob ##> "/i alice" - contactInfoChatVRange bob (supportedChatVRange PQEncOff) + contactInfoChatVRange bob (supportedChatVRange PQSupportOff) withTestChatCfg tmp cfg11 "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" @@ -2724,7 +2724,7 @@ testUpdatePeerChatVRange tmp = contactInfoChatVRange alice vr11 bob ##> "/i alice" - contactInfoChatVRange bob (supportedChatVRange PQEncOff) + contactInfoChatVRange bob (supportedChatVRange PQSupportOff) where cfg11 = testCfg {chatVRange = const vr11} :: ChatConfig diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 088bc45969..bf9b445925 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -17,7 +17,7 @@ import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..), VersionRangeChat) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) +import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOff) import System.Directory (copyFile) import System.FilePath (()) import Test.Hspec hiding (it) @@ -149,19 +149,19 @@ chatGroupTests = do it "member was blocked before joining group" testBlockForAllBeforeJoining it "can't repeat block, unblock" testBlockForAllCantRepeat where - _0 = supportedChatVRange PQEncOff -- don't create direct connections + _0 = supportedChatVRange PQSupportOff -- don't create direct connections _1 = groupCreateDirectVRange -- having host configured with older version doesn't have effect in tests -- because host uses current code and sends version in MemberInfo testNoDirect vrMem2 vrMem3 noConns = it ( "host " - <> vRangeStr (supportedChatVRange PQEncOff) + <> vRangeStr (supportedChatVRange PQSupportOff) <> (", 2nd mem " <> vRangeStr vrMem2) <> (", 3rd mem " <> vRangeStr vrMem3) <> (if noConns then " : 2 3" else " : 2 <##> 3") ) - $ testNoGroupDirectConns (supportedChatVRange PQEncOff) vrMem2 vrMem3 noConns + $ testNoGroupDirectConns (supportedChatVRange PQSupportOff) vrMem2 vrMem3 noConns testGroup :: HasCallStack => FilePath -> IO () testGroup = diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 810cd58bfa..322a810d92 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -21,7 +21,7 @@ import Data.String import qualified Data.Text as T import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) -import Simplex.Chat.Messages.CIContent (e2eeInfoNoPQText) +import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText) import Simplex.Chat.Protocol import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) @@ -30,7 +30,7 @@ import Simplex.Chat.Types.Preferences import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) +import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) @@ -204,7 +204,7 @@ chatFeatures'' = ] e2eeInfoNoPQStr :: String -e2eeInfoNoPQStr = T.unpack e2eeInfoNoPQText +e2eeInfoNoPQStr = T.unpack e2eInfoNoPQText lastChatFeature :: String lastChatFeature = snd $ last chatFeatures @@ -584,7 +584,7 @@ checkActionDeletesFile file action = do currentChatVRangeInfo :: String currentChatVRangeInfo = - "peer chat protocol version range: " <> vRangeStr (supportedChatVRange PQEncOff) + "peer chat protocol version range: " <> vRangeStr (supportedChatVRange PQSupportOff) vRangeStr :: VersionRange v -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index ece24132e8..082af825e5 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} module ProtocolTests where @@ -14,7 +15,6 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet -import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (supportedSMPClientVRange) import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Version @@ -49,7 +49,7 @@ testDhPubKey :: C.PublicKeyX448 testDhPubKey = "MEIwBQYDK2VvAzkAmKuSYeQ/m0SixPDS8Wq8VBaTS1cW+Lp0n0h4Diu+kUpR+qXx4SDJ32YGEFoGFGSbGPry5Ychr6U=" testE2ERatchetParams :: RcvE2ERatchetParamsUri 'C.X448 -testE2ERatchetParams = E2ERatchetParamsUri (supportedE2EEncryptVRange CR.PQEncOn) testDhPubKey testDhPubKey Nothing +testE2ERatchetParams = E2ERatchetParamsUri (supportedE2EEncryptVRange PQSupportOn) testDhPubKey testDhPubKey Nothing testConnReq :: ConnectionRequestUri 'CMInvitation testConnReq = CRInvitationUri connReqData testE2ERatchetParams @@ -132,7 +132,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ "{\"v\":\"1-7\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - ##==## ChatMessage (supportedChatVRange PQEncOff) (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) + ##==## ChatMessage (supportedChatVRange PQSupportOff) (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage @@ -242,13 +242,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQEncOff, profile = testProfile} + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQSupportOff, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQEncOff, profile = testProfile} Nothing + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQSupportOff, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked}) @@ -263,7 +263,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQEncOff, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange $ supportedChatVRange PQSupportOff, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile From 9ff11f886ecb5da3231309d6cbc4ed1c3fbe74df Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 7 Mar 2024 15:40:06 +0000 Subject: [PATCH 31/64] website: add group link page --- docs/SERVER.md | 34 +++++++++++++++++----------------- docs/XFTP-SERVER.md | 2 +- website/src/finneyforum.html | 8 ++++++++ 3 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 website/src/finneyforum.html diff --git a/docs/SERVER.md b/docs/SERVER.md index 61d2a981d2..e476c7250c 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -17,7 +17,7 @@ _Please note_: when you change the servers in the app configuration, it only aff ## Installation -0. First, install `smp-server`: +1. First, install `smp-server`: - Manual deployment (see below) @@ -28,7 +28,7 @@ _Please note_: when you change the servers in the app configuration, it only aff Manual installation requires some preliminary actions: -0. Install binary: +1. Install binary: - Using offical binaries: @@ -40,20 +40,20 @@ Manual installation requires some preliminary actions: Please refer to [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution) -1. Create user and group for `smp-server`: +2. Create user and group for `smp-server`: ```sh sudo useradd -m smp ``` -2. Create necessary directories and assign permissions: +3. Create necessary directories and assign permissions: ```sh sudo mkdir -p /var/opt/simplex /etc/opt/simplex sudo chown smp:smp /var/opt/simplex /etc/opt/simplex ``` -3. Allow `smp-server` port in firewall: +4. Allow `smp-server` port in firewall: ```sh # For Ubuntu @@ -63,7 +63,7 @@ Manual installation requires some preliminary actions: sudo firewall-cmd --reload ``` -4. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/smp-server.service` file with the following content: +5. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/smp-server.service` file with the following content: ```sh [Unit] @@ -398,20 +398,20 @@ To import `csv` to `Grafana` one should: 2. Allow local mode by appending following: - ```sh - [plugin.marcusolsson-csv-datasource] - allow_local_mode = true - ``` + ```sh + [plugin.marcusolsson-csv-datasource] + allow_local_mode = true + ``` - ... to `/etc/grafana/grafana.ini` + ... to `/etc/grafana/grafana.ini` 3. Add a CSV data source: - - In the side menu, click the Configuration tab (cog icon) - - Click Add data source in the top-right corner of the Data Sources tab - - Enter "CSV" in the search box to find the CSV data source - - Click the search result that says "CSV" - - In URL, enter a file that points to CSV content + - In the side menu, click the Configuration tab (cog icon) + - Click Add data source in the top-right corner of the Data Sources tab + - Enter "CSV" in the search box to find the CSV data source + - Click the search result that says "CSV" + - In URL, enter a file that points to CSV content 4. You're done! You should be able to create your own dashboard with statistics. @@ -445,7 +445,7 @@ To update your smp-server to latest version, choose your installation method and - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) 1. Stop and remove the container: ```sh - docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="{{.ID}}")) + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="\{\{.ID\}\}")) ``` 2. Pull latest image: ```sh diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 8e2e03c19d..a0ad0e0cf7 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -448,7 +448,7 @@ To update your XFTP server to latest version, choose your installation method an - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) 1. Stop and remove the container: ```sh - docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/xftp-server --format="{{.ID}}")) + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/xftp-server --format="\{\{.ID\}\}")) ``` 2. Pull latest image: ```sh diff --git a/website/src/finneyforum.html b/website/src/finneyforum.html new file mode 100644 index 0000000000..06229e4b5d --- /dev/null +++ b/website/src/finneyforum.html @@ -0,0 +1,8 @@ +--- +layout: layouts/group_link.html +title: "SimpleX Chat - Finney Forum group" +description: "Join the group of attendees of Finney Forum 2024" +groupLink: "https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FTlom_0qzRaEWo_4cweE_hzj6KBmqXC8R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAZzyx3sm1tpGsYjXAOR2LxXD0ty1hlAR7Hg0fbCxEoig%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22IfdftVGf9odVOQImmz1I9A%3D%3D%22%7D" +groupLinkText: Open Finney Forum group link +templateEngineOverride: njk +--- From b403201310f3910d0859147b12bd1ccbb49ed8af Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 8 Mar 2024 11:40:55 +0400 Subject: [PATCH 32/64] core (pq): further integrate agent api (#3874) * core (pq): further integrate agent api * update both pq support and ecnryption * update * fix * corrections * corrections 2 * corrections 3 --- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 123 +++++++++++--------- src/Simplex/Chat/Migrations/M20240228_pq.hs | 4 + src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 4 +- src/Simplex/Chat/Store/Direct.hs | 25 ++-- src/Simplex/Chat/Store/Messages.hs | 2 +- src/Simplex/Chat/Store/Shared.hs | 14 +-- src/Simplex/Chat/Types.hs | 5 +- 11 files changed, 102 insertions(+), 82 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 20975dfe3c..7645a94035 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1834,7 +1834,7 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .contactPQEnabled(user, contact, _): if active(user) { await MainActor.run { - m.updateContact(contact) // or updateContactConnectionStats? + m.updateContact(contact) } } default: diff --git a/cabal.project b/cabal.project index f7f929a95b..a589035483 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 11288866f90bafb0892701b0e0679eddb030b5df + tag: 5e23fa6cfc60c5efd561f9131a9528b9ccb9782d source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2faa7a1dd4..ca49a9a013 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."00ae2cb6e134e3cd7c8089e30f95a9430d3c4e3d" = "1dvghlsrf0dw8g279gnb4m2s7jrj9bwdibcq61hkkb9h5975f93d"; + "https://github.com/simplex-chat/simplexmq.git"."5e23fa6cfc60c5efd561f9131a9528b9ccb9782d" = "1h2cxnyn2z2qscny7gsz0zpvmnpn1h668ic4za36l43swddwwb7s"; "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 8a5f88f45f..8411aabf9c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -604,11 +604,11 @@ processChatCommand' vr = \case Just conn@Connection {connId, pqEncryption} -> case pqEncryption of PQEncOn -> pure $ chatCmdError (Just user) "already allowed" PQEncOff -> do - -- TODO PQ add / change database field(s) - withStore' $ \db -> allowConnEnablePQ db connId - let conn' = conn {pqSupport = PQSupportOn, pqEncryption = PQEncOn} :: Connection - ct' = ct {activeConn = Just conn'} :: Contact - pure $ CRContactPQAllowed user ct' + -- TODO PQ add / change database field(s) + withStore' $ \db -> updateConnSupportPQ db connId PQSupportOn + let conn' = conn {pqSupport = PQSupportOn, pqEncryption = PQEncOn} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + pure $ CRContactPQAllowed user ct' Nothing -> throwChatError $ CEContactNotActive ct APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do @@ -1431,15 +1431,13 @@ processChatCommand' vr = \case incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing False pqSup <- chatReadVar pqExperimentalEnabled - -- TODO PQ connRequestPQSupport - -- connRequestPQSupport :: AgentMonad' m => PQSupport -> ConnectionRequestUri c -> m (Maybe PQSupport) - -- connRequestPQSupport pqSup cReq - -- or if you know support of another side alread (e.g. in REQ) use: - -- pqSupportAnd :: PQSupport -> PQSupport -> PQSupport - dm <- encodeConnInfoPQ pqSup $ XInfo profileToSend - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode pqSup - pure $ CRSentConfirmation user conn + withAgent' (\a -> connRequestPQSupport a pqSup cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just pqSup' -> do + dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup' subMode + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode pqSup' + pure $ CRSentConfirmation user conn APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do @@ -2166,32 +2164,36 @@ processChatCommand' vr = \case where connect' groupLinkId cReqHash xContactId inGroup = do pqSup <- if inGroup then pure PQSupportOff else chatReadVar pqExperimentalEnabled - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId inGroup pqSup - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode pqSup + (connId, incognitoProfile, subMode, pqSup') <- requestContact user incognito cReq xContactId inGroup pqSup + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode pqSup' pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse connectContactViaAddress user incognito ct cReq = withChatLock "connectViaContact" $ do newXContactId <- XContactId <$> drgRandomBytes 16 pqSup <- chatReadVar pqExperimentalEnabled - (connId, incognitoProfile, subMode) <- requestContact user incognito cReq newXContactId False pqSup + (connId, incognitoProfile, subMode, pqSup') <- requestContact user incognito cReq newXContactId False pqSup let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode pqSup + ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode pqSup' pure $ CRSentInvitationToContact user ct' incognitoProfile - requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQSupport -> m (ConnId, Maybe Profile, SubscriptionMode) + requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQSupport -> m (ConnId, Maybe Profile, SubscriptionMode, PQSupport) requestContact user incognito cReq xContactId inGroup pqSup = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing inGroup - -- TODO PQ connecting via address -- 0) toggle disabled - PQSupportOff -- 1) toggle enabled, address supports PQ (connRequestPQSupport returns Just True) - PQSupportOn, enable support with compression -- 2) toggle enabled, address doesn't support PQ - PQSupportOn but without compression, with version range indicating support - -- see joinContactInitialKeys: PQSupportOn -> IKUsePQ - I will change to IKNoPQ PQSupportOn - dm <- encodeConnInfoPQ pqSup (XContact profileToSend $ Just xContactId) - subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup subMode - pure (connId, incognitoProfile, subMode) + withAgent' (\a -> connRequestPQSupport a pqSup cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just pqCompress -> do + let (pqSup', pqCompress') = case pqSup of + PQSupportOff -> (PQSupportOff, PQSupportOff) + PQSupportOn -> (PQSupportOn, pqCompress) + dm <- encodeConnInfoPQ pqCompress' (XContact profileToSend $ Just xContactId) + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup' subMode + pure (connId, incognitoProfile, subMode, pqSup') contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -2883,14 +2885,14 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) 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 +acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId, pqSupport} incognitoProfile contactUsed = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False pqSup <- chatReadVar pqExperimentalEnabled - -- TODO combine pqSup with pqSupport from UserContactRequest - dm <- encodeConnInfoPQ pqSup $ XInfo profileToSend - acId <- withAgent $ \a -> acceptContact a True invId dm pqSup subMode - withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed + let pqSup' = pqSup `CR.pqSupportAnd` pqSupport + dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend + acId <- withAgent $ \a -> acceptContact a True invId dm pqSup' subMode + withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup' contactUsed acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> m Contact acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do @@ -2924,7 +2926,7 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - connIds <- agentAcceptContactAsync user True invId msg subMode (PQSupport False) + connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff withStore $ \db -> do liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode getGroupMemberById db user groupMemberId @@ -3510,20 +3512,33 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CON _ -> Just ConnReady _ -> Nothing + processCONFpqSupport :: Connection -> PQSupport -> m Connection + processCONFpqSupport conn@Connection {connId, pqSupport = pq} pq' + | pq == PQSupportOn && pq' == PQSupportOff = do + withStore' $ \db -> updateConnSupportPQ db connId pq' + pure (conn {pqSupport = pq', pqEncryption = CR.pqSupportToEnc pq'} :: Connection) + | pq /= pq' = do + messageWarning "processCONFpqSupport: unexpected pqSupport change" + pure conn + | otherwise = pure conn + + processINFOpqSupport :: Connection -> PQSupport -> m () + processINFOpqSupport Connection {pqSupport = pq} pq' = + when (pq /= pq') $ messageWarning "processINFOpqSupport: unexpected pqSupport change" + processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of - -- TODO PQ if connection was created with PQSupportOn and CONF has PQSupportOff, then disable it in connection (store in DB, update connection object, pass PQSupportOff) - -- if the opposite, ignore or log warning CONF confId pqSupport _ connInfo -> do + conn' <- processCONFpqSupport conn pqSupport -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing False - conn' <- saveConnInfo conn connInfo + conn'' <- saveConnInfo conn' connInfo -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId $ XInfo profileToSend - -- TODO PQ if connection has pqSupport different from pqSupport in INFO log warning, ignore + allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do + processINFOpqSupport conn pqSupport _conn' <- saveConnInfo conn connInfo pure () MSG meta _msgFlags msgBody -> do @@ -3601,27 +3616,27 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ directMsgReceived ct conn msgMeta msgRcpt - -- TODO PQ this will happen with members and with contact cards - same as above CONF confId pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updatePeerChatVRange conn chatVRange + conn' <- processCONFpqSupport conn pqSupport + ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn' connInfo + conn'' <- updatePeerChatVRange conn' chatVRange case chatMsgEvent of -- confirming direct connection with a member XGrpMemInfo _memId _memProfile -> do -- TODO check member ID -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId XOk + allowAgentConnectionAsync user conn'' confId XOk XInfo profile -> do ct' <- processContactProfileUpdate ct profile False `catchChatError` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId let p = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False - allowAgentConnectionAsync user conn' confId $ XInfo p + allowAgentConnectionAsync user conn'' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" INFO pqSupport connInfo -> do - -- TODO PQ log warning same above + processINFOpqSupport conn pqSupport ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of @@ -4276,11 +4291,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processUserContactRequest :: ACommand 'Agent e -> ConnectionEntity -> Connection -> UserContact -> m () processUserContactRequest agentMsg connEntity conn UserContact {userContactLinkId} = case agentMsg of REQ invId pqSupport _ connInfo -> do - -- TODO PQ this pqSupport needs to be combined with user's choice in toggle, then enable PQ support ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo case chatMsgEvent of - XContact p xContactId_ -> profileContactRequest invId chatVRange p xContactId_ - XInfo p -> profileContactRequest invId chatVRange p Nothing + XContact p xContactId_ -> profileContactRequest invId chatVRange p xContactId_ pqSupport + XInfo p -> profileContactRequest invId chatVRange p Nothing pqSupport -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do @@ -4292,9 +4306,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> m () - profileContactRequest invId chatVRange p xContactId_ = do - withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case + profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> m () + profileContactRequest invId chatVRange p xContactId_ reqPQSup = do + withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId @@ -4305,8 +4319,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing pqSup <- chatReadVar pqExperimentalEnabled - -- TODO PQ combine pqSup with pqSupport in REQ - ct <- acceptContactRequestAsync user cReq incognitoProfile True pqSup + let pqSup' = pqSup `CR.pqSupportAnd` reqPQSup + ct <- acceptContactRequestAsync user cReq incognitoProfile True pqSup' toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId @@ -6124,13 +6138,12 @@ encodeConnInfoPQ pqSup chatMsgEvent = do r = encodeChatMessage maxConnInfoLength ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} case r of ECMEncoded encodedBody - | shouldCompress -> compressedBatchMsgBody encodedBody + | shouldCompress -> liftIO $ compressedBatchMsgBody encodedBody | otherwise -> pure encodedBody ECMLarge -> throwChatError $ CEException "large message" where compressedBatchMsgBody msgBody = - liftEitherError (ChatError . CEException . mappend "compressedBatchMsgBody: ") $ - withCompressCtx (B.length msgBody) (`compressedBatchMsgBody_` msgBody) + withCompressCtx (toEnum $ B.length msgBody) (`compressedBatchMsgBody_` msgBody) deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do @@ -6155,13 +6168,13 @@ deliverMessagesB msgReqs = do void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where - compressBodies = liftIO $ withCompressCtx maxRawMsgLength $ \cctx -> + compressBodies = liftIO $ withCompressCtx (toEnum maxRawMsgLength) $ \cctx -> forM msgReqs $ \case -- TODO PQ combine pqSupport and pqEncryption to one type: -- data PQMode = PQDisabled | PQSupported PQEncryption mr@(Right (conn@Connection {pqSupport, pqEncryption}, msgFlags, msgBody, msgId)) -> case pqSupport `CR.pqSupportOrEnc` pqEncryption of PQSupportOn -> - bimap (ChatError . CEException) (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody + Right . (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody PQSupportOff -> pure mr skip -> pure skip toAgent = \case diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Migrations/M20240228_pq.hs index 4f3ca3b743..d6b4eefe21 100644 --- a/src/Simplex/Chat/Migrations/M20240228_pq.hs +++ b/src/Simplex/Chat/Migrations/M20240228_pq.hs @@ -11,11 +11,15 @@ m20240228_pq = ALTER TABLE connections ADD COLUMN enable_pq INTEGER; ALTER TABLE connections ADD COLUMN pq_snd_enabled INTEGER; ALTER TABLE connections ADD COLUMN pq_rcv_enabled INTEGER; + +ALTER TABLE contact_requests ADD COLUMN pq_support INTEGER NOT NULL DEFAULT 0; |] down_m20240228_pq :: Query down_m20240228_pq = [sql| +ALTER TABLE contact_requests DROP COLUMN pq_support; + ALTER TABLE connections DROP COLUMN enable_pq; ALTER TABLE connections DROP COLUMN pq_snd_enabled; ALTER TABLE connections DROP COLUMN pq_rcv_enabled; diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index ad5dbe1620..25d06c4c94 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -315,6 +315,7 @@ CREATE TABLE contact_requests( xcontact_id BLOB, peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + pq_support INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON UPDATE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index aba09c4f0b..624de31f41 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -564,8 +564,8 @@ parseChatMessages s = case B.head s of Left e -> [Left e] Right compressed -> concatMap (either (pure . Left) parseChatMessages) . L.toList $ decompressBatch maxRawMsgLength compressed -compressedBatchMsgBody_ :: CompressCtx -> MsgBody -> IO (Either String ByteString) -compressedBatchMsgBody_ ctx msgBody = markCompressedBatch . smpEncode . (L.:| []) <$$> compress ctx msgBody +compressedBatchMsgBody_ :: CompressCtx -> MsgBody -> IO ByteString +compressedBatchMsgBody_ ctx msgBody = markCompressedBatch . smpEncode . (L.:| []) <$> compress ctx msgBody markCompressedBatch :: ByteString -> ByteString markCompressedBatch = B.cons 'X' diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index a9e227b0e6..49e2bc25e0 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -531,8 +531,8 @@ getUserContacts db user@User {userId} = do contacts <- rights <$> mapM (runExceptT . getContact db user) contactIds pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts -createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest -createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = +createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> ExceptT StoreError IO ContactOrRequest +createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ pqSup = liftIO (maybeM getContact' xContactId_) >>= \case Just contact -> pure $ CORContact contact Nothing -> CORRequest <$> createOrUpdate_ @@ -561,10 +561,13 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers db [sql| INSERT INTO contact_requests - (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) - VALUES (?,?,?,?,?,?,?,?,?,?) + (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, + created_at, updated_at, xcontact_id, pq_support) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (userContactLinkId, invId, minV, maxV, profileId, ldn, userId, currentTs, currentTs, xContactId_) + ( (userContactLinkId, invId, minV, maxV, profileId, ldn, userId) + :. (currentTs, currentTs, xContactId_, pqSup) + ) insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = @@ -596,7 +599,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers [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, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, 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 USING (user_contact_link_id) @@ -617,20 +620,20 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers db [sql| UPDATE contact_requests - SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, minV, maxV, currentTs, userId, cReqId) + (invId, pqSup, minV, maxV, currentTs, userId, cReqId) else withLocalDisplayName db userId displayName $ \ldn -> Right <$> do DB.execute db [sql| UPDATE contact_requests - SET agent_invitation_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, minV, maxV, ldn, currentTs, userId, cReqId) + (invId, pqSup, minV, maxV, ldn, currentTs, userId, cReqId) safeDeleteLDN db user oldLdn where updateProfile currentTs = @@ -665,7 +668,7 @@ getContactRequest db User {userId} contactRequestId = [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, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, 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 USING (user_contact_link_id) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index c7e25e3b96..1a69e16e27 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -856,7 +856,7 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of ( [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, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at as ts, cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 1020cbeb14..9b2005e5fe 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -253,16 +253,16 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag (displayName, fullName, image, userId, Just True, createdAt, createdAt) insertedRowId db -allowConnEnablePQ :: DB.Connection -> Int64 -> IO () -allowConnEnablePQ db connId = +updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> IO () +updateConnSupportPQ db connId pqSup = DB.execute db [sql| UPDATE connections - SET enable_pq = 1 + SET enable_pq = ? WHERE connection_id = ? |] - (Only connId) + (pqSup, connId) -- TODO PQ possibly combine all functions updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQEncryption -> IO () @@ -396,13 +396,13 @@ getProfileById db userId profileId = toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, contactLink, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, PQSupport, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, preferences, createdAt, updatedAt, minVer, maxVer)) = do +toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, pqSupport, preferences, createdAt, updatedAt, minVer, maxVer)) = do let profile = Profile {displayName, fullName, image, contactLink, preferences} cReqChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, cReqChatVRange, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, createdAt, updatedAt} userQuery :: Query userQuery = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index adffb94ebf..3bfe3b8577 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -335,9 +335,8 @@ data UserContactRequest = UserContactRequest profile :: Profile, createdAt :: UTCTime, updatedAt :: UTCTime, - xContactId :: Maybe XContactId - -- TODO PQ save pqSupport from REQ to database - -- pqSupport :: PQSupport + xContactId :: Maybe XContactId, + pqSupport :: PQSupport } deriving (Eq, Show) From 109b6e0cffe2de78185386d05e1d5e2e7fd40b6f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 8 Mar 2024 12:24:27 +0400 Subject: [PATCH 33/64] core (pq): add pq_support field (#3877) --- src/Simplex/Chat.hs | 14 ++++------ src/Simplex/Chat/Migrations/M20240228_pq.hs | 6 +++-- src/Simplex/Chat/Migrations/chat_schema.sql | 3 ++- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 22 +++++++-------- src/Simplex/Chat/Store/Groups.hs | 8 +++--- src/Simplex/Chat/Store/Profiles.hs | 4 +-- src/Simplex/Chat/Store/Shared.hs | 30 +++++++++------------ 8 files changed, 42 insertions(+), 47 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8411aabf9c..644404955e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -451,8 +451,8 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand cmd = - chatVersionRange PQSupportOff -- TODO PQ this is only used to set membership version range (?) - >>= (`processChatCommand'` cmd) + -- TODO PQ this is only used to set membership version range (?) + chatVersionRange PQSupportOff >>= (`processChatCommand'` cmd) {-# INLINE processChatCommand #-} processChatCommand' :: forall m. ChatMonad m => VersionRangeChat -> ChatCommand -> m ChatResponse @@ -599,12 +599,10 @@ processChatCommand' vr = \case APISetPQEnabled onOff -> chatWriteVar pqExperimentalEnabled onOff >> ok_ APIAllowContactPQ contactId -> withUser $ \user -> do ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId - -- TODO PQ check different flag? case activeConn of - Just conn@Connection {connId, pqEncryption} -> case pqEncryption of - PQEncOn -> pure $ chatCmdError (Just user) "already allowed" - PQEncOff -> do - -- TODO PQ add / change database field(s) + Just conn@Connection {connId, pqSupport} -> case pqSupport of + PQSupportOn -> pure $ chatCmdError (Just user) "already allowed" + PQSupportOff -> do withStore' $ \db -> updateConnSupportPQ db connId PQSupportOn let conn' = conn {pqSupport = PQSupportOn, pqEncryption = PQEncOn} :: Connection ct' = ct {activeConn = Just conn'} :: Contact @@ -6170,8 +6168,6 @@ deliverMessagesB msgReqs = do where compressBodies = liftIO $ withCompressCtx (toEnum maxRawMsgLength) $ \cctx -> forM msgReqs $ \case - -- TODO PQ combine pqSupport and pqEncryption to one type: - -- data PQMode = PQDisabled | PQSupported PQEncryption mr@(Right (conn@Connection {pqSupport, pqEncryption}, msgFlags, msgBody, msgId)) -> case pqSupport `CR.pqSupportOrEnc` pqEncryption of PQSupportOn -> Right . (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Migrations/M20240228_pq.hs index d6b4eefe21..1b1c173faa 100644 --- a/src/Simplex/Chat/Migrations/M20240228_pq.hs +++ b/src/Simplex/Chat/Migrations/M20240228_pq.hs @@ -8,7 +8,8 @@ import Database.SQLite.Simple.QQ (sql) m20240228_pq :: Query m20240228_pq = [sql| -ALTER TABLE connections ADD COLUMN enable_pq INTEGER; +ALTER TABLE connections ADD COLUMN pq_support INTEGER NOT NULL DEFAULT 0; +ALTER TABLE connections ADD COLUMN pq_encryption INTEGER NOT NULL DEFAULT 0; ALTER TABLE connections ADD COLUMN pq_snd_enabled INTEGER; ALTER TABLE connections ADD COLUMN pq_rcv_enabled INTEGER; @@ -20,7 +21,8 @@ down_m20240228_pq = [sql| ALTER TABLE contact_requests DROP COLUMN pq_support; -ALTER TABLE connections DROP COLUMN enable_pq; +ALTER TABLE connections DROP COLUMN pq_support; +ALTER TABLE connections DROP COLUMN pq_encryption; ALTER TABLE connections DROP COLUMN pq_snd_enabled; ALTER TABLE connections DROP COLUMN pq_rcv_enabled; |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 25d06c4c94..ea59a94d1f 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -277,7 +277,8 @@ CREATE TABLE connections( peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, contact_conn_initiated INTEGER NOT NULL DEFAULT 0, - enable_pq INTEGER, + pq_support INTEGER NOT NULL DEFAULT 0, + pq_encryption INTEGER NOT NULL DEFAULT 0, pq_snd_enabled INTEGER, pq_rcv_enabled INTEGER, FOREIGN KEY(snd_file_id, connection_id) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 61ed54416b..311dba6579 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -60,7 +60,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, - created_at, security_code, security_code_verified_at, enable_pq, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, + created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 49e2bc25e0..3128908225 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -142,12 +142,12 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, - created_at, updated_at, to_subscribe, enable_pq - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup) + :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -178,7 +178,7 @@ getContactByConnReqHash db user@User {userId} cReqHash = 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -199,11 +199,11 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile [sql| INSERT INTO connections (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, - created_at, updated_at, to_subscribe, enable_pq) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, to_subscribe, pq_support, pq_encryption) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup) + :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -581,7 +581,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -746,7 +746,7 @@ getContact_ db user@User {userId} contactId deleted = 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -800,7 +800,7 @@ getContactConnections db userId Contact {contactId} = [sql| SELECT 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id @@ -818,7 +818,7 @@ getConnectionById db User {userId} connId = ExceptT $ do [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, - created_at, security_code, security_code_verified_at, enable_pq, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, + created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 7c1721908b..0385d96c69 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -196,7 +196,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = [sql| SELECT 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -282,7 +282,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = 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, 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -690,7 +690,7 @@ groupMemberQuery = 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, 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -1296,7 +1296,7 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = 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, 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 3d7db3cf0e..06029b913d 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -339,7 +339,7 @@ getUserAddressConnections db User {userId} = do [sql| SELECT 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -355,7 +355,7 @@ getUserContactLinks db User {userId} = [sql| SELECT 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.enable_pq, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 9b2005e5fe..6824c94f0f 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -37,7 +37,7 @@ import Simplex.Messaging.Agent.Protocol (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.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) @@ -151,13 +151,12 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) --- TODO PQ nullable? -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, VersionChat, VersionChat) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, VersionChat, VersionChat) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe VersionChat, Maybe VersionChat) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe VersionChat, Maybe VersionChat) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqEncryption_, pqSndEnabled, pqRcvEnabled, authErrCounter, minVer, maxVer)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, @@ -174,9 +173,8 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup localAlias, entityId = entityId_ connType, connectionCode = SecurityCode <$> code_ <*> verifiedAt_, - -- TODO PQ add field - pqSupport = maybe PQSupportOff CR.pqEncToSupport pqEncryption_, - pqEncryption = fromMaybe PQEncOff pqEncryption_, + pqSupport, + pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, @@ -191,8 +189,8 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, enablePQ_, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, enablePQ_, pqSndEnabled_, pqRcvEnabled_, authErrCounter, minVer, maxVer)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection @@ -200,19 +198,18 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId - -- TODO PQ store pq_support DB.execute db [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version, to_subscribe, enable_pq - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (minV, maxV, subMode == SMOnlyCreate, pqSup) + :. (minV, maxV, subMode == SMOnlyCreate, pqSup, pqSup) ) connId <- insertedRowId db pure @@ -259,12 +256,11 @@ updateConnSupportPQ db connId pqSup = db [sql| UPDATE connections - SET enable_pq = ? + SET pq_support = ?, pq_encryption = ? WHERE connection_id = ? |] - (pqSup, connId) + (pqSup, pqSup, connId) --- TODO PQ possibly combine all functions updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQEncryption -> IO () updateConnPQSndEnabled db connId pqSndEnabled = DB.execute From 405348732b339d8e88f9d6ab5206950af185e717 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:39:15 +0400 Subject: [PATCH 34/64] android: pq support; ios: fixes (#3878) --- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 8 +-- .../chat/simplex/common/model/ChatModel.kt | 47 ++++++++++++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 29 +++++++++++ .../chat/simplex/common/platform/Core.kt | 1 + .../simplex/common/views/chat/ChatInfoView.kt | 50 +++++++++++++++++++ .../common/views/chat/item/ChatItemView.kt | 5 ++ .../views/usersettings/DeveloperView.kt | 8 +++ .../commonMain/resources/MR/base/strings.xml | 5 ++ 9 files changed, 151 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 07d83ac475..bc4b6947ab 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -172,13 +172,13 @@ struct ChatInfoView: View { let conn = contact.activeConn { Section { infoRow(Text(String("PQ E2E encryption")), conn.connPQEnabled ? "Enabled" : "Disabled") - if !conn.enablePQ { + if !conn.pqSupport { allowPQButton() } } header: { Text(String("Post-quantum E2E encryption")) } footer: { - if !conn.enablePQ { + if !conn.pqSupport { Text(String("After allowing post-quantum encryption, it will be enabled after several messages if your contact also allows it.")) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 0125973d14..267c254be1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1532,14 +1532,15 @@ public struct Connection: Decodable { public var viaGroupLink: Bool public var customUserProfileId: Int64? public var connectionCode: SecurityCode? - public var enablePQ: Bool + public var pqSupport: Bool + public var pqEncryption: Bool public var pqSndEnabled: Bool? public var pqRcvEnabled: Bool? public var connectionStats: ConnectionStats? = nil private enum CodingKeys: String, CodingKey { - case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, enablePQ, pqSndEnabled, pqRcvEnabled + case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled } public var id: ChatId { get { ":\(connId)" } } @@ -1555,7 +1556,8 @@ public struct Connection: Decodable { connStatus: .ready, connLevel: 0, viaGroupLink: false, - enablePQ: false + pqSupport: false, + pqEncryption: false ) } 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 21bfb1daa3..df1dec330d 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 @@ -1123,11 +1123,19 @@ data class Connection( val viaGroupLink: Boolean, val customUserProfileId: Long? = null, val connectionCode: SecurityCode? = null, + val pqSupport: Boolean, + val pqEncryption: Boolean, + val pqSndEnabled: Boolean? = null, + val pqRcvEnabled: Boolean? = null, val connectionStats: ConnectionStats? = null ) { val id: ChatId get() = ":$connId" + + val connPQEnabled: Boolean + get() = pqSndEnabled == true && pqRcvEnabled == true + companion object { - val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null) + val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false) } } @@ -1853,6 +1861,10 @@ data class ChatItem ( is CIContent.SndModerated -> false is CIContent.RcvModerated -> false is CIContent.RcvBlocked -> false + is CIContent.SndDirectE2EEInfo -> false + is CIContent.RcvDirectE2EEInfo -> false + is CIContent.SndGroupE2EEInfo -> false + is CIContent.RcvGroupE2EEInfo -> false is CIContent.InvalidJSON -> false } @@ -2283,6 +2295,10 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvModerated") object RcvModerated: CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvBlocked") object RcvBlocked: CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndDirectE2EEInfo") class SndDirectE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvDirectE2EEInfo") class RcvDirectE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndGroupE2EEInfo") class SndGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvGroupE2EEInfo") class RcvGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null } override val text: String get() = when (this) { @@ -2312,6 +2328,10 @@ sealed class CIContent: ItemContent { is SndModerated -> generalGetString(MR.strings.moderated_description) is RcvModerated -> generalGetString(MR.strings.moderated_description) is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description) + is SndDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) + is RcvDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) + is SndGroupE2EEInfo -> e2eeInfoNoPQText + is RcvGroupE2EEInfo -> e2eeInfoNoPQText is InvalidJSON -> "invalid data" } @@ -2330,6 +2350,15 @@ sealed class CIContent: ItemContent { } companion object { + fun directE2EEInfoToText(e2EEInfo: E2EEInfo): String = + if (e2EEInfo.pqEnabled) { + generalGetString(MR.strings.e2ee_info_pq) + } else { + e2eeInfoNoPQText + } + + private val e2eeInfoNoPQText: String = generalGetString(MR.strings.e2ee_info_no_pq) + fun featureText(feature: Feature, enabled: String, param: Int?): String = if (feature.hasParam) { "${feature.text}: ${timeText(param)}" @@ -2744,6 +2773,9 @@ enum class CIGroupInvitationStatus { @SerialName("expired") Expired; } +@Serializable +class E2EEInfo (val pqEnabled: Boolean) {} + object MsgContentSerializer : KSerializer { override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { element("MCText", buildClassSerialDescriptor("MCText") { @@ -3097,6 +3129,7 @@ sealed class RcvConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase): RcvConnEvent() @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState): RcvConnEvent() @Serializable @SerialName("verificationCodeReset") object VerificationCodeReset: RcvConnEvent() + @Serializable @SerialName("pqEnabled") class PQEnabled(val enabled: Boolean): RcvConnEvent() val text: String get() = when (this) { is SwitchQueue -> when (phase) { @@ -3105,6 +3138,11 @@ sealed class RcvConnEvent { } is RatchetSync -> ratchetSyncStatusToText(syncStatus) is VerificationCodeReset -> generalGetString(MR.strings.rcv_conn_event_verification_code_reset) + is PQEnabled -> if (enabled) { + generalGetString(MR.strings.conn_event_enabled_pq) + } else { + generalGetString(MR.strings.conn_event_disabled_pq) + } } } @@ -3122,6 +3160,7 @@ fun ratchetSyncStatusToText(ratchetSyncStatus: RatchetSyncState): String { sealed class SndConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase, val member: GroupMemberRef? = null): SndConnEvent() @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState, val member: GroupMemberRef? = null): SndConnEvent() + @Serializable @SerialName("pqEnabled") class PQEnabled(val enabled: Boolean): SndConnEvent() val text: String get() = when (this) { @@ -3150,6 +3189,12 @@ sealed class SndConnEvent { } ratchetSyncStatusToText(syncStatus) } + + is PQEnabled -> if (enabled) { + generalGetString(MR.strings.conn_event_enabled_pq) + } else { + generalGetString(MR.strings.conn_event_disabled_pq) + } } } 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 1a20449695..9e3ce480df 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 @@ -156,6 +156,7 @@ class AppPreferences { val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false) val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false) val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null) + val pqExperimentalEnabled = mkBoolPreference(SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED, false) val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name) val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.name) @@ -312,6 +313,7 @@ class AppPreferences { private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct" private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName" + private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme" private const val SHARED_PREFS_SYSTEM_DARK_THEME = "SystemDarkTheme" private const val SHARED_PREFS_THEMES = "Themes" @@ -633,6 +635,15 @@ object ChatController { suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiSetPQEnabled(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEnabled(enable)) + + suspend fun apiAllowContactPQ(rh: Long?, contactId: Long): Contact? { + val r = sendCmd(rh, CC.ApiAllowContactPQ(contactId)) + if (r is CR.ContactPQAllowed) return r.contact + apiErrorAlert("apiAllowContactPQ", "Error allowing contact PQ", r) + return null + } + suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(null, CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -2016,6 +2027,10 @@ object ChatController { } } } + is CR.ContactPQEnabled -> + if (active(r.user)) { + chatModel.updateContact(rhId, r.contact) + } is CR.ChatCmdError -> when { r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> { chatModel.processedCriticalError.newError(r.chatError.agentError, r.chatError.agentError.offerRestart) @@ -2274,6 +2289,8 @@ sealed class CC { class SetFilesFolder(val filesFolder: String): CC() class SetRemoteHostsFolder(val remoteHostsFolder: String): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC() + class ApiSetPQEnabled(val enable: Boolean): CC() + class ApiAllowContactPQ(val contactId: Long): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -2403,6 +2420,8 @@ sealed class CC { is SetFilesFolder -> "/_files_folder $filesFolder" is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" + is ApiSetPQEnabled -> "/_pq ${onOff(enable)}" + is ApiAllowContactPQ -> "/_pq allow $contactId" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -2537,6 +2556,8 @@ sealed class CC { is SetFilesFolder -> "setFilesFolder" is SetRemoteHostsFolder -> "setRemoteHostsFolder" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" + is ApiSetPQEnabled -> "apiSetPQEnabled" + is ApiAllowContactPQ -> "apiAllowContactPQ" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" @@ -4002,6 +4023,10 @@ sealed class CR { @Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR() @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(val rcsState: RemoteCtrlSessionState, val rcStopReason: RemoteCtrlStopReason): CR() + // pq + @Serializable @SerialName("contactPQAllowed") class ContactPQAllowed(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("contactPQEnabled") class ContactPQEnabled(val user: UserRef, val contact: Contact, val pqEnabled: Boolean): CR() + // misc @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @@ -4151,6 +4176,8 @@ sealed class CR { is RemoteCtrlSessionCode -> "remoteCtrlSessionCode" is RemoteCtrlConnected -> "remoteCtrlConnected" is RemoteCtrlStopped -> "remoteCtrlStopped" + is ContactPQAllowed -> "contactPQAllowed" + is ContactPQEnabled -> "contactPQEnabled" is VersionInfo -> "versionInfo" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" @@ -4315,6 +4342,8 @@ sealed class CR { "\nsessionCode: $sessionCode" is RemoteCtrlConnected -> json.encodeToString(remoteCtrl) is RemoteCtrlStopped -> noDetails() + is ContactPQAllowed -> withUser(user, "contact: ${contact.id}") + is ContactPQEnabled -> withUser(user, "contact: ${contact.id}\npqEnabled: $pqEnabled") is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" + "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + "agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}" 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 7a7c2d7f24..6e30a89810 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 @@ -92,6 +92,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat controller.apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) } controller.apiSetEncryptLocalFiles(controller.appPrefs.privacyEncryptLocalFiles.get()) + controller.apiSetPQEnabled(controller.appPrefs.pqExperimentalEnabled.get()) // If we migrated successfully means previous re-encryption process on database level finished successfully too if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index f195c723f6..97ec502f35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -58,6 +58,7 @@ fun ChatInfoView( val currentUser = remember { chatModel.currentUser }.value val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() + val pqExperimentalEnabled = chatModel.controller.appPrefs.pqExperimentalEnabled.get() if (chat != null && currentUser != null) { val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { mutableStateOf(chatModel.contactNetworkStatus(contact)) @@ -80,6 +81,7 @@ fun ChatInfoView( localAlias, connectionCode, developerTools, + pqExperimentalEnabled, onLocalAliasChanged = { setContactAlias(chat, it, chatModel) }, @@ -138,6 +140,17 @@ fun ChatInfoView( } }) }, + allowContactPQ = { + showAllowContactPQAlert(allowContactPQ = { + withBGApi { + val ct = chatModel.controller.apiAllowContactPQ(chatRh, contact.contactId) + if (ct != null) { + chatModel.updateContact(chatRh, contact) + } + close.invoke() + } + }) + }, verifyClicked = { ModalManager.end.showModalCloseable { close -> remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct -> @@ -288,6 +301,7 @@ fun ChatInfoLayout( localAlias: String, connectionCode: String?, developerTools: Boolean, + pqExperimentalEnabled: Boolean, onLocalAliasChanged: (String) -> Unit, openPreferences: () -> Unit, deleteContact: () -> Unit, @@ -296,6 +310,7 @@ fun ChatInfoLayout( abortSwitchContactAddress: () -> Unit, syncContactConnection: () -> Unit, syncContactConnectionForce: () -> Unit, + allowContactPQ: () -> Unit, verifyClicked: () -> Unit, ) { val cStats = connStats.value @@ -345,6 +360,18 @@ fun ChatInfoLayout( SectionDividerSpaced() } + val conn = contact.activeConn + if (pqExperimentalEnabled && conn != null) { + SectionView("Post-quantum E2E encryption") { + InfoRow("PQ E2E encryption", if (conn.connPQEnabled) "Enabled" else "Disabled") + if (!conn.pqSupport) { + AllowContactPQButton(allowContactPQ) + SectionTextFooter("After allowing post-quantum encryption, it will be enabled after several messages if your contact also allows it.") + } + SectionDividerSpaced() + } + } + if (contact.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { SimpleXLinkQRCode(contact.contactLink) @@ -601,6 +628,17 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) { ) } +@Composable +fun AllowContactPQButton(allowContactPQ: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_warning), + "Allow PQ encryption", + click = allowContactPQ, + textColor = WarningOrange, + iconColor = WarningOrange + ) +} + @Composable fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) { SettingsActionItem( @@ -704,6 +742,16 @@ fun showSyncConnectionForceAlert(syncConnectionForce: () -> Unit) { ) } +fun showAllowContactPQAlert(allowContactPQ: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = "Allow post-quantum encryption?", + text = "This is an experimental feature, it is not recommended to enable it for high importance communications. It may result in connection errors!", + confirmText = "Allow", + onConfirm = allowContactPQ, + destructive = true, + ) +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -721,6 +769,7 @@ fun PreviewChatInfoLayout() { localAlias = "", connectionCode = "123", developerTools = false, + pqExperimentalEnabled = false, connStats = remember { mutableStateOf(null) }, contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, @@ -732,6 +781,7 @@ fun PreviewChatInfoLayout() { abortSwitchContactAddress = {}, syncContactConnection = {}, syncContactConnectionForce = {}, + allowContactPQ = {}, verifyClicked = {}, ) } 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 ccb9683240..cce4307d1f 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 @@ -452,6 +452,11 @@ fun ChatItemView( is CIContent.SndModerated -> DeletedItem() is CIContent.RcvModerated -> DeletedItem() is CIContent.RcvBlocked -> DeletedItem() + // TODO proper items + is CIContent.SndDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.RcvDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.SndGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.RcvGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index cc268e9a9d..5dca1527f2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -58,6 +58,14 @@ fun DeveloperView( SettingsPreferenceItem(painterResource(MR.images.ic_report), stringResource(MR.strings.show_internal_errors), appPreferences.showInternalErrors) SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) } + + SectionSpacer() + SectionView("Experimental".uppercase()) { + SettingsPreferenceItem(painterResource(MR.images.ic_vpn_key_filled), "Post-quantum E2EE", m.controller.appPrefs.pqExperimentalEnabled, onChange = { enable -> + withBGApi { m.controller.apiSetPQEnabled(enable) } + }) + SectionTextFooter("In this version applies only to new contacts.") + } } SectionBottomSpacer() } 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 7288d88431..7abd5c8a49 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,6 +54,9 @@ Decryption error Encryption re-negotiation error + This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery. + This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery. + Private notes @@ -1239,6 +1242,8 @@ agreeing encryption for %s… encryption agreed for %s security code changed + enabled post-quantum encryption + disabled post-quantum encryption observer From 1f93d91af51653a7c1429159a8a1c0b1ad828764 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 8 Mar 2024 13:36:09 +0000 Subject: [PATCH 35/64] core: simplify feature versions (#3879) * core: simplify feature versions * update version agreement * fix * remove EmptyCase --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 62 ++++++++++++++++---------------- src/Simplex/Chat/Protocol.hs | 40 ++++++++++----------- src/Simplex/Chat/Store/Groups.hs | 32 ++++++++--------- src/Simplex/Chat/Store/Shared.hs | 6 ++-- src/Simplex/Chat/Types.hs | 31 ++++++---------- src/Simplex/Chat/View.hs | 4 +-- 8 files changed, 83 insertions(+), 96 deletions(-) diff --git a/cabal.project b/cabal.project index a589035483..0318aa7de4 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 5e23fa6cfc60c5efd561f9131a9528b9ccb9782d + tag: b4e55146b8a910add95d0756734ca5ba3f0850fc source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ca49a9a013..c4e16fe399 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5e23fa6cfc60c5efd561f9131a9528b9ccb9782d" = "1h2cxnyn2z2qscny7gsz0zpvmnpn1h668ic4za36l43swddwwb7s"; + "https://github.com/simplex-chat/simplexmq.git"."b4e55146b8a910add95d0756734ca5ba3f0850fc" = "0hwrzn02284myqcv4gdxabk5dw0zisyy13vym5h2k461005jl6sb"; "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 644404955e..754eae464c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1653,7 +1653,7 @@ processChatCommand' vr = \case dm <- encodeConnInfo $ XGrpAcpt membershipMemId agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm PQSupportOff subMode withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode + createMemberConnection db userId fromMember agentConnId peerChatVRange subMode updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` \_ -> pure () @@ -1811,7 +1811,7 @@ processChatCommand' vr = \case unless (groupFeatureAllowed SGFDirectMessages g) $ throwChatError $ CECommandError "direct messages not allowed" case memberConn m of Just mConn@Connection {peerChatVRange} -> do - unless (isCompatibleRange (fromJVersionRange peerChatVRange) xGrpDirectInvVRange) $ throwChatError CEPeerChatVRangeIncompatible + unless (maxVersion peerChatVRange >= groupDirectInvVersion) $ throwChatError CEPeerChatVRangeIncompatible when (isJust $ memberContactId m) $ throwChatError $ CECommandError "member contact already exists" subMode <- chatReadVar subscriptionMode (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode @@ -2890,7 +2890,7 @@ acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId inv let pqSup' = pqSup `CR.pqSupportAnd` pqSupport dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend acId <- withAgent $ \a -> acceptContact a True invId dm pqSup' subMode - withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup' contactUsed + withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup' contactUsed acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> m Contact acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do @@ -2898,7 +2898,7 @@ acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvI let profileToSend = profileToSendOnAccept user incognitoProfile False (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup withStore' $ \db -> do - ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed + ct@Contact {activeConn} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct @@ -3677,7 +3677,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds peerChatVRange subMode Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct @@ -3744,7 +3744,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] XGrpMemIntro continuation on receiving INV CFCreateConnGrpMemInv - | isCompatibleRange (fromJVersionRange $ peerChatVRange conn) groupNoDirectVRange -> sendWithoutDirectCReq + | maxVersion (peerChatVRange conn) >= groupDirectInvVersion -> sendWithoutDirectCReq | otherwise -> sendWithDirectCReq where sendWithoutDirectCReq = do @@ -3840,9 +3840,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = memberConnectedChatItem gInfo m unless expectHistory $ forM_ description $ groupDescriptionChatItem gInfo m where - expectHistory = - groupFeatureAllowed SGFHistory gInfo - && isCompatibleRange (memberChatVRange' m) groupHistoryIncludeWelcomeVRange + expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} @@ -3860,7 +3858,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendIntroductions members = do intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m shuffledIntros <- liftIO $ shuffleIntros intros - if isCompatibleRange (memberChatVRange' m) batchSendVRange + if m `supportsVersion` batchSendVersion then do let events = map (memberIntro . reMember) shuffledIntros forM_ (L.nonEmpty events) $ \events' -> @@ -3885,7 +3883,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId withStore' $ \db -> updateIntroStatus db introId GMIntroSent sendHistory = - when (isCompatibleRange (memberChatVRange' m) batchSendVRange) $ do + when (m `supportsVersion` batchSendVersion) $ do (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo 100) (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items let errors = map ChatErrorStore errs <> errs' @@ -3895,7 +3893,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendGroupMemberMessages user conn events'' groupId descrEvent_ :: Maybe (ChatMsgEvent 'Json) descrEvent_ - | isCompatibleRange (memberChatVRange' m) groupHistoryIncludeWelcomeVRange = do + | m `supportsVersion` groupHistoryIncludeWelcomeVersion = do let GroupInfo {groupProfile = GroupProfile {description}} = gInfo fmap (\descr -> XMsgNew $ MCSimple $ extMsgContent (MCText descr) Nothing) description | otherwise = Nothing @@ -4323,7 +4321,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if isCompatibleRange chatVRange groupLinkNoContactVRange + if maxVersion chatVRange >= groupFastLinkJoinVersion then do mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing @@ -4972,7 +4970,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do setViaGroupLinkHash db groupId connId - createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode + createMemberConnectionAsync db user hostId connIds peerChatVRange subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) @@ -5408,8 +5406,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createConn subMode directConnIds <- case memChatVRange of Nothing -> Just <$> createConn subMode - Just mcvr - | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> pure Nothing + Just (ChatVersionRange mcvr) + | maxVersion mcvr >= groupDirectInvVersion -> pure Nothing | otherwise -> Just <$> createConn subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode @@ -5815,7 +5813,7 @@ sameMemberId memId GroupMember {memberId} = memId == memberId updatePeerChatVRange :: ChatMonad m => Connection -> VersionRangeChat -> m Connection updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do - let jMsgChatVRange = JVersionRange msgChatVRange + let jMsgChatVRange = msgChatVRange if jMsgChatVRange /= peerChatVRange then do withStore' $ \db -> setPeerChatVRange db connId msgChatVRange @@ -5824,7 +5822,7 @@ updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRangeChat -> m (GroupMember, Connection) updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, peerChatVRange} msgChatVRange = do - let jMsgChatVRange = JVersionRange msgChatVRange + let jMsgChatVRange = msgChatVRange if jMsgChatVRange /= peerChatVRange then do withStore' $ \db -> do @@ -6132,7 +6130,7 @@ encodeConnInfo = encodeConnInfoPQ PQSupportOff encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> ChatMsgEvent e -> m ByteString encodeConnInfoPQ pqSup chatMsgEvent = do chatVRange <- chatVersionRange pqSup - let shouldCompress = maxVersion chatVRange >= compressedBatchingVersion + let shouldCompress = maxVersion chatVRange >= pqEncryptionCompressionVersion r = encodeChatMessage maxConnInfoLength ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} case r of ECMEncoded encodedBody @@ -6166,12 +6164,18 @@ deliverMessagesB msgReqs = do void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where - compressBodies = liftIO $ withCompressCtx (toEnum maxRawMsgLength) $ \cctx -> + compressBodies = liftIO $ withCompressCtx (toEnum maxRawMsgLength) $ \cctx -> do forM msgReqs $ \case - mr@(Right (conn@Connection {pqSupport, pqEncryption}, msgFlags, msgBody, msgId)) -> case pqSupport `CR.pqSupportOrEnc` pqEncryption of - PQSupportOn -> - Right . (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody - PQSupportOff -> pure mr + mr@(Right (conn@Connection {pqSupport, pqEncryption, peerChatVRange}, msgFlags, msgBody, msgId)) + | shouldCompress pqSupport pqEncryption -> + Right . (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody + | otherwise -> pure mr + where + --- TODO PQ + -- This version agreement is ephemeral and in case of peer downgrade it will get reduced, and pqSupport may be turned off in the result + -- We probably should store agreed version on Connection and do not allow reducing it. + chatV = maybe currentChatVersion (\(Compatible v') -> v') $ supportedChatVRange pqSupport `compatibleVersion` peerChatVRange + shouldCompress (PQSupport sup) (PQEncryption enc) = sup && (chatV >= pqEncryptionCompressionVersion && enc) skip -> pure skip toAgent = \case Right (conn@Connection {pqEncryption}, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEncryption, msgFlags, msgBody) @@ -6207,7 +6211,7 @@ sendGroupMessage user gInfo members chatMsgEvent = do (Nothing, Just _) -> True _ -> False sendProfileUpdate = do - let members' = filter (\m -> isCompatibleRange (memberChatVRange' m) memberProfileUpdateVRange) members + let members' = filter (`supportsVersion` memberProfileUpdateVersion) members profileUpdateEvent = XInfo $ redactedMemberProfile $ fromLocalProfile p void $ sendGroupMessage' user gInfo members' profileUpdateEvent currentTs <- liftIO getCurrentTime @@ -6256,16 +6260,12 @@ memberSendAction chatMsgEvent members m@GroupMember {invitedByGroupMemberId} = c | isXGrpMsgForward chatMsgEvent = Nothing | otherwise = Just MSAPending where - forwardSupported = - let mcvr = memberChatVRange' m - in isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward + forwardSupported = m `supportsVersion` groupForwardVersion && invitingMemberSupportsForward invitingMemberSupportsForward = case invitedByGroupMemberId of Just invMemberId -> -- can be optimized for large groups by replacing [GroupMember] with Map GroupMemberId GroupMember case find (\m' -> groupMemberId' m' == invMemberId) members of - Just invitingMember -> do - let mcvr = memberChatVRange' invitingMember - isCompatibleRange mcvr groupForwardVRange + Just invitingMember -> invitingMember `supportsVersion` groupForwardVersion Nothing -> False Nothing -> False isXGrpMsgForward ev = case ev of diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 624de31f41..703850c85d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -52,7 +52,7 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (MsgBody) -import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$$>), (<$?>)) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. @@ -65,41 +65,37 @@ currentChatVersion = VersionChat 7 -- TODO remove parameterization in 5.7 supportedChatVRange :: PQSupport -> VersionRangeChat supportedChatVRange pq = mkVersionRange (VersionChat 1) $ case pq of - PQSupportOn -> compressedBatchingVersion + PQSupportOn -> pqEncryptionCompressionVersion PQSupportOff -> currentChatVersion {-# INLINE supportedChatVRange #-} --- version range that supports skipping establishing direct connections in a group -groupNoDirectVRange :: VersionRangeChat -groupNoDirectVRange = mkVersionRange (VersionChat 2) currentChatVersion - --- version range that supports establishing direct connection via x.grp.direct.inv with a group member -xGrpDirectInvVRange :: VersionRangeChat -xGrpDirectInvVRange = mkVersionRange (VersionChat 2) currentChatVersion +-- version range that supports skipping establishing direct connections in a group and establishing direct connection via x.grp.direct.inv +groupDirectInvVersion :: VersionChat +groupDirectInvVersion = VersionChat 2 -- version range that supports joining group via group link without creating direct contact -groupLinkNoContactVRange :: VersionRangeChat -groupLinkNoContactVRange = mkVersionRange (VersionChat 3) currentChatVersion +groupFastLinkJoinVersion :: VersionChat +groupFastLinkJoinVersion = VersionChat 3 -- version range that supports group forwarding -groupForwardVRange :: VersionRangeChat -groupForwardVRange = mkVersionRange (VersionChat 4) currentChatVersion +groupForwardVersion :: VersionChat +groupForwardVersion = VersionChat 4 -- version range that supports batch sending in groups -batchSendVRange :: VersionRangeChat -batchSendVRange = mkVersionRange (VersionChat 5) currentChatVersion +batchSendVersion :: VersionChat +batchSendVersion = VersionChat 5 -- version range that supports sending group welcome message in group history -groupHistoryIncludeWelcomeVRange :: VersionRangeChat -groupHistoryIncludeWelcomeVRange = mkVersionRange (VersionChat 6) currentChatVersion +groupHistoryIncludeWelcomeVersion :: VersionChat +groupHistoryIncludeWelcomeVersion = VersionChat 6 -- version range that supports sending member profile updates to groups -memberProfileUpdateVRange :: VersionRangeChat -memberProfileUpdateVRange = mkVersionRange (VersionChat 7) currentChatVersion +memberProfileUpdateVersion :: VersionChat +memberProfileUpdateVersion = VersionChat 7 --- version range that supports compressing messages -compressedBatchingVersion :: VersionChat -compressedBatchingVersion = VersionChat 8 +-- version range that supports compressing messages and PQ e2e encryption +pqEncryptionCompressionVersion :: VersionChat +pqEncryptionCompressionVersion = VersionChat 8 data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 0385d96c69..4a87893e58 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -133,7 +133,7 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (groupForwardVRange) +import Simplex.Chat.Protocol (groupForwardVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -156,7 +156,7 @@ type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe Ver toGroupInfo :: VersionRangeChat -> Int64 -> GroupInfoRow -> GroupInfo toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = JVersionRange vr} + let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} @@ -169,7 +169,7 @@ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing - memberChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember @@ -393,7 +393,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ |] (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) insertedRowId db - let JVersionRange hostVRange = peerChatVRange + let hostVRange = peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} @@ -444,7 +444,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberContactId = Just $ contactId' userOrContact, memberContactProfileId = localProfileId (profile' userOrContact), activeConn = Nothing, - memberChatVRange = JVersionRange memberChatVRange + memberChatVRange } where insertMember_ :: IO ContactName @@ -789,10 +789,10 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId (fromJVersionRange peerChatVRange) Nothing 0 createdAt subMode + void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode pure member where - JVersionRange (VersionRange minV maxV) = peerChatVRange + VersionRange minV maxV = peerChatVRange invitedByGroupMemberId = groupMemberId' membership createMember_ memberId createdAt = do insertMember_ @@ -873,7 +873,7 @@ createAcceptedMember groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) where - JVersionRange (VersionRange minV maxV) = cReqChatVRange + VersionRange minV maxV = cReqChatVRange insertMember_ memberId createdAt = DB.execute db @@ -898,7 +898,7 @@ createAcceptedMemberConnection groupMemberId subMode = do createdAt <- liftIO getCurrentTime - Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact @@ -1002,7 +1002,7 @@ createNewMember_ createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing - mcvr@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange + memberChatVRange@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange DB.execute db [sql| @@ -1034,7 +1034,7 @@ createNewMember_ memberContactId, memberContactProfileId, activeConn, - memberChatVRange = JVersionRange mcvr + memberChatVRange } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1174,7 +1174,7 @@ getForwardIntroducedMembers db user invitee highlyAvailable = do DB.query db (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) q = [sql| SELECT re_group_member_id @@ -1194,7 +1194,7 @@ getForwardInvitedMembers db user forwardMember highlyAvailable = do DB.query db (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) q = [sql| SELECT to_group_member_id @@ -1882,7 +1882,7 @@ createMemberContact cReq gInfo GroupMember {groupMemberId, localDisplayName, memberProfile, memberContactProfileId} - Connection {connLevel, peerChatVRange = peerChatVRange@(JVersionRange (VersionRange minV maxV))} + Connection {connLevel, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} subMode = do currentTs <- getCurrentTime let incognitoProfile = incognitoMembershipProfile gInfo @@ -2030,7 +2030,7 @@ createMemberContactConn_ user@User {userId} (cmdId, acId) gInfo - _memberConn@Connection {connLevel, peerChatVRange = peerChatVRange@(JVersionRange (VersionRange minV maxV))} + _memberConn@Connection {connLevel, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} contactId subMode = do currentTs <- liftIO getCurrentTime @@ -2169,7 +2169,7 @@ updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@ ) getGroupMemberById db user groupMemberId where - VersionRange minV maxV = maybe (fromJVersionRange memberChatVRange) fromChatVRange v + VersionRange minV maxV = maybe memberChatVRange fromChatVRange v updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs = diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6824c94f0f..1a5b41be68 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -160,7 +160,7 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup Connection { connId, agentConnId = AgentConnId acId, - peerChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer, + peerChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer, connLevel, viaContact, viaUserContactLink, @@ -216,7 +216,7 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange Connection { connId, agentConnId = AgentConnId acId, - peerChatVRange = JVersionRange peerChatVRange, + peerChatVRange, connType, contactConnInitiated = False, entityId, @@ -397,7 +397,7 @@ type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, In toContactRequest :: ContactRequestRow -> UserContactRequest toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, pqSupport, preferences, createdAt, updatedAt, minVer, maxVer)) = do let profile = Profile {displayName, fullName, image, contactLink, preferences} - cReqChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, createdAt, updatedAt} userQuery :: Query diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 3bfe3b8577..ec7ac736da 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -329,7 +329,7 @@ data UserContactRequest = UserContactRequest agentInvitationId :: AgentInvId, userContactLinkId :: Int64, agentContactConnId :: AgentConnId, -- connection id of user contact - cReqChatVRange :: JVersionRange, + cReqChatVRange :: VersionRangeChat, localDisplayName :: ContactName, profileId :: Int64, profile :: Profile, @@ -660,7 +660,7 @@ memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = MemberInfo { memberId, memberRole, - v = ChatVersionRange . fromJVersionRange . peerChatVRange <$> activeConn, + v = ChatVersionRange . peerChatVRange <$> activeConn, profile = redactedMemberProfile $ fromLocalProfile memberProfile } @@ -742,7 +742,7 @@ data GroupMember = GroupMember -- member chat protocol version range; if member has active connection, its version range is preferred; -- for membership current supportedChatVRange is set, it's not updated on protocol version increase in database, -- but it's correctly set on read (see toGroupInfo) - memberChatVRange :: JVersionRange + memberChatVRange :: VersionRangeChat } deriving (Eq, Show) @@ -760,10 +760,12 @@ memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn memberChatVRange' :: GroupMember -> VersionRangeChat -memberChatVRange' GroupMember {activeConn, memberChatVRange} = - fromJVersionRange $ case activeConn of - Just Connection {peerChatVRange} -> peerChatVRange - Nothing -> memberChatVRange +memberChatVRange' GroupMember {activeConn, memberChatVRange} = case activeConn of + Just Connection {peerChatVRange} -> peerChatVRange + Nothing -> memberChatVRange + +supportsVersion :: GroupMember -> VersionChat -> Bool +supportsVersion m v = maxVersion (memberChatVRange' m) >= v groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId @@ -1340,7 +1342,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, - peerChatVRange :: JVersionRange, + peerChatVRange :: VersionRangeChat, connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" @@ -1694,6 +1696,7 @@ type VersionRangeChat = VersionRange ChatVersion pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v +-- this newtype exists to have a concise JSON encoding of version ranges in chat protocol messages in the form of "1-2" or just "1" newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRangeChat} deriving (Eq, Show) initialChatVersion :: VersionChat @@ -1709,18 +1712,6 @@ instance ToJSON ChatVersionRange where toJSON (ChatVersionRange vr) = strToJSON vr toEncoding (ChatVersionRange vr) = strToJEncoding vr -newtype JVersionRange = JVersionRange {fromJVersionRange :: VersionRangeChat} deriving (Eq, Show) - -instance FromJSON JVersionRange where - parseJSON = J.withObject "JVersionRange" $ \o -> do - minv <- o .: "minVersion" - maxv <- o .: "maxVersion" - maybe (fail "bad version range") (pure . JVersionRange) $ safeVersionRange minv maxv - -instance ToJSON JVersionRange where - toJSON (JVersionRange (VersionRange minV maxV)) = J.object ["minVersion" .= minV, "maxVersion" .= maxV] - toEncoding (JVersionRange (VersionRange minV maxV)) = J.pairs $ "minVersion" .= minV <> "maxVersion" .= maxV - $(JQ.deriveJSON defaultJSON ''UserContact) $(JQ.deriveJSON defaultJSON ''Profile) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ed0095c531..276550a490 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1200,8 +1200,8 @@ viewConnectionVerified :: Maybe SecurityCode -> StyledString viewConnectionVerified (Just _) = "connection verified" -- TODO show verification time? viewConnectionVerified _ = "connection not verified, use " <> highlight' "/code" <> " command to see security code" -viewPeerChatVRange :: JVersionRange -> StyledString -viewPeerChatVRange (JVersionRange (VersionRange minVer maxVer)) = "peer chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" +viewPeerChatVRange :: VersionRangeChat -> StyledString +viewPeerChatVRange (VersionRange minVer maxVer) = "peer chat protocol version range: (" <> sShow minVer <> ", " <> sShow maxVer <> ")" viewConnectionStats :: ConnectionStats -> [StyledString] viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = From 435ea9a45356d4eb48879c541149efbf0532dccd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 8 Mar 2024 13:38:48 +0000 Subject: [PATCH 36/64] core: api to pass additional information with standalone file URI (#3873) * xftp: redirect for descriptions with more than one chunk * handle errors * core: api to pass additional information with standalone file URI * cleanup * test info with large file * Apply suggestions from code review Co-authored-by: Evgeny Poberezkin * remove db-mediated client data * refactor * fix --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- src/Simplex/Chat.hs | 38 ++++++++-------- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Files.hs | 80 +++++++++++++++++++++++++++++++--- 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2f6eb0c910..21be1b1f86 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2008,6 +2008,7 @@ processChatCommand' vr = \case when (fileSize > toInteger maxFileSizeHard) $ throwChatError $ CEFileSize filePath (_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing pure CRSndStandaloneFileCreated {user, fileTransferMeta} + APIStandaloneFileInfo FileDescriptionURI {clientData} -> pure . CRStandaloneFileInfo $ clientData >>= J.decodeStrict . encodeUtf8 APIDownloadStandaloneFile userId uri file -> withUserId userId $ \user -> do ft <- receiveViaURI user uri file pure $ CRRcvStandaloneFileCreated user ft @@ -3271,13 +3272,15 @@ processAgentMsgSndFile _corrId aFileId msg = Nothing -> do withAgent (`xftpDeleteSndFileInternal` aFileId) withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText rfds) - case mapMaybe fileDescrURI rfds of - [] -> case rfds of - [] -> logError "File sent without receiver descriptions" -- should not happen - (rfd : _) -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft - uris -> do - ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor - toView $ CRSndStandaloneFileComplete user ft' uris + case rfds of + [] -> sendFileError "no receiver descriptions" fileId vr ft + rfd : _ -> case [fd | fd@(FD.ValidFileDescription FD.FileDescription {chunks = [_]}) <- rfds] of + [] -> case xftpRedirectFor of + Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft + Just _ -> sendFileError "Prohibit chaining redirects" fileId vr ft + rfds' -> do -- we have 1 chunk - use it as URI whether it is redirect or not + ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor + toView $ CRSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) -> case (msgId_, itemDeleted) of (Just sharedMsgId, Nothing) -> do @@ -3319,19 +3322,11 @@ processAgentMsgSndFile _corrId aFileId msg = SFERR e | temporaryAgentError e -> throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e - | otherwise -> do - ci <- withStore $ \db -> do - liftIO $ updateFileCancelled db user fileId CIFSSndError - lookupChatItemByFileId db vr user fileId - withAgent (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileError user ci ft + | otherwise -> + sendFileError (tshow e) fileId vr ft where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode - fileDescrURI :: ValidFileDescription 'FRecipient -> Maybe T.Text - fileDescrURI vfd = if T.length uri < FD.qrSizeLimit then Just uri else Nothing - where - uri = decodeLatin1 . strEncode $ FD.fileDescriptionURI vfd sendFileDescription :: SndFileTransfer -> ValidFileDescription 'FRecipient -> SharedMsgId -> (ChatMsgEvent 'Json -> m (SndMessage, Int64)) -> m Int64 sendFileDescription sft rfd msgId sendMsg = do let rfdText = fileDescrText rfd @@ -3346,6 +3341,14 @@ processAgentMsgSndFile _corrId aFileId msg = case L.nonEmpty fds of Just fds' -> loopSend fds' Nothing -> pure msgDeliveryId + sendFileError :: Text -> Int64 -> VersionRange -> FileTransferMeta -> m () + sendFileError err fileId vr ft = do + logError $ "Sent file error: " <> err + ci <- withStore $ \db -> do + liftIO $ updateFileCancelled db user fileId CIFSSndError + lookupChatItemByFileId db vr user fileId + withAgent (`xftpDeleteSndFileInternal` aFileId) + toView $ CRSndFileError user ci ft splitFileDescr :: ChatMonad m => RcvFileDescrText -> m (NonEmpty FileDescr) splitFileDescr rfdText = do @@ -6783,6 +6786,7 @@ chatCommandP = "/stop remote ctrl" $> StopRemoteCtrl, "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), "/_upload " *> (APIUploadStandaloneFile <$> A.decimal <* A.space <*> cryptoFileP), + "/_download info " *> (APIStandaloneFileInfo <$> strP), "/_download " *> (APIDownloadStandaloneFile <$> A.decimal <* A.space <*> strP_ <*> cryptoFileP), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c482825e18..9c85d90b43 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -455,6 +455,7 @@ data ChatCommand | DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session | APIUploadStandaloneFile UserId CryptoFile | APIDownloadStandaloneFile UserId FileDescriptionURI CryptoFile + | APIStandaloneFileInfo FileDescriptionURI | QuitChat | ShowVersion | DebugLocks @@ -594,6 +595,7 @@ data ChatResponse | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} | CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} | CRRcvFileDescrNotReady {user :: User, chatItem :: AChatItem} + | CRStandaloneFileInfo {fileMeta :: Maybe J.Value} | CRRcvStandaloneFileCreated {user :: User, rcvFileTransfer :: RcvFileTransfer} -- returned by _download | CRRcvFileStart {user :: User, chatItem :: AChatItem} -- sent by chats | CRRcvFileProgressXFTP {user :: User, chatItem_ :: Maybe AChatItem, receivedSize :: Int64, totalSize :: Int64, rcvFileTransfer :: RcvFileTransfer} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 667613ba6a..7648cba32a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -218,6 +218,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] + CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [plain . LB.toStrict $ J.encode j]) info_ CRContactConnecting u _ -> ttyUser u [] CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 3aa345773e..1e72df9156 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -13,6 +13,7 @@ import Control.Logger.Simple import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import Network.HTTP.Types.URI (urlEncode) import Simplex.Chat (roundedFDCount) import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Mobile.File @@ -52,7 +53,9 @@ chatFileTests = do it "should prohibit file transfers in groups based on preference" testProhibitFiles describe "file transfer over XFTP without chat items" $ do it "send and receive small standalone file" testXFTPStandaloneSmall + it "send and receive small standalone file with extra information" testXFTPStandaloneSmallInfo it "send and receive large standalone file" testXFTPStandaloneLarge + it "send and receive large standalone file with extra information" testXFTPStandaloneLargeInfo it "send and receive large standalone file using relative paths" testXFTPStandaloneRelativePaths xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests it "removes received temporary files" testXFTPStandaloneCancelRcv @@ -848,11 +851,11 @@ testXFTPStandaloneSmall :: HasCallStack => FilePath -> IO () testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" - src ##> "/_upload 1 ./tests/fixtures/test.jpg" - src <## "started standalone uploading file 1 (test.jpg)" + src ##> "/_upload 1 ./tests/fixtures/logo.jpg" + src <## "started standalone uploading file 1 (logo.jpg)" -- silent progress events threadDelay 250000 - src <## "file 1 (test.jpg) upload complete. download with:" + src <## "file 1 (logo.jpg) upload complete. download with:" -- file description fits, enjoy the direct URIs _uri1 <- getTermLine src _uri2 <- getTermLine src @@ -860,13 +863,43 @@ testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst _uri4 <- getTermLine src logNote "receiving" - let dstFile = "./tests/tmp/test.jpg" + let dstFile = "./tests/tmp/logo.jpg" dst ##> ("/_download 1 " <> uri3 <> " " <> dstFile) - dst <## "started standalone receiving file 1 (test.jpg)" + dst <## "started standalone receiving file 1 (logo.jpg)" -- silent progress events threadDelay 250000 - dst <## "completed standalone receiving file 1 (test.jpg)" - srcBody <- B.readFile "./tests/fixtures/test.jpg" + dst <## "completed standalone receiving file 1 (logo.jpg)" + srcBody <- B.readFile "./tests/fixtures/logo.jpg" + B.readFile dstFile `shouldReturn` srcBody + +testXFTPStandaloneSmallInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + logNote "sending" + src ##> "/_upload 1 ./tests/fixtures/logo.jpg" + src <## "started standalone uploading file 1 (logo.jpg)" + -- silent progress events + threadDelay 250000 + src <## "file 1 (logo.jpg) upload complete. download with:" + -- file description fits, enjoy the direct URIs + _uri1 <- getTermLine src + _uri2 <- getTermLine src + uri3 <- getTermLine src + _uri4 <- getTermLine src + let uri = uri3 <> "&data=" <> B.unpack (urlEncode False . LB.toStrict . J.encode $ J.object ["secret" J..= J.String "*********"]) + + logNote "info" + dst ##> ("/_download info " <> uri) + dst <## "{\"secret\":\"*********\"}" + + logNote "receiving" + let dstFile = "./tests/tmp/logo.jpg" + dst ##> ("/_download 1 " <> uri <> " " <> dstFile) -- download sucessfully discarded extra info + dst <## "started standalone receiving file 1 (logo.jpg)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (logo.jpg)" + srcBody <- B.readFile "./tests/fixtures/logo.jpg" B.readFile dstFile `shouldReturn` srcBody testXFTPStandaloneLarge :: HasCallStack => FilePath -> IO () @@ -896,6 +929,39 @@ testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst srcBody <- B.readFile "./tests/tmp/testfile.in" B.readFile dstFile `shouldReturn` srcBody +testXFTPStandaloneLargeInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do + withXFTPServer $ do + xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + + logNote "sending" + src ##> "/_upload 1 ./tests/tmp/testfile.in" + src <## "started standalone uploading file 1 (testfile.in)" + + -- silent progress events + threadDelay 250000 + src <## "file 1 (testfile.in) uploaded, preparing redirect file 2" + src <## "file 1 (testfile.in) upload complete. download with:" + uri1 <- getTermLine src + _uri2 <- getTermLine src + _uri3 <- getTermLine src + _uri4 <- getTermLine src + let uri = uri1 <> "&data=" <> B.unpack (urlEncode False . LB.toStrict . J.encode $ J.object ["secret" J..= J.String "*********"]) + + logNote "info" + dst ##> ("/_download info " <> uri) + dst <## "{\"secret\":\"*********\"}" + + logNote "receiving" + let dstFile = "./tests/tmp/testfile.out" + dst ##> ("/_download 1 " <> uri <> " " <> dstFile) + dst <## "started standalone receiving file 1 (testfile.out)" + -- silent progress events + threadDelay 250000 + dst <## "completed standalone receiving file 1 (testfile.out)" + srcBody <- B.readFile "./tests/tmp/testfile.in" + B.readFile dstFile `shouldReturn` srcBody + testXFTPStandaloneCancelSnd :: HasCallStack => FilePath -> IO () testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do From 19ca4f7447681c3b98e321e33efc270b64b63145 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sat, 9 Mar 2024 01:06:51 +0200 Subject: [PATCH 37/64] core: remove duplicate Eq orphans (#3880) * core: remove duplicate Eq orphans * bump nix --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Types.hs | 51 --------------------------------------- 3 files changed, 2 insertions(+), 53 deletions(-) diff --git a/cabal.project b/cabal.project index 0318aa7de4..9f72b40aea 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: b4e55146b8a910add95d0756734ca5ba3f0850fc + tag: 8cdd49b91256aee56427f8b8e351cf415045e9c7 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index c4e16fe399..21de83d0ab 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b4e55146b8a910add95d0756734ca5ba3f0850fc" = "0hwrzn02284myqcv4gdxabk5dw0zisyy13vym5h2k461005jl6sb"; + "https://github.com/simplex-chat/simplexmq.git"."8cdd49b91256aee56427f8b8e351cf415045e9c7" = "0wgj9ypr6ry414bb15ixyg75cpivwycyh4icy33xm5whksvwy93r"; "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/Types.hs b/src/Simplex/Chat/Types.hs index ec7ac736da..9f24e7007d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -57,57 +57,6 @@ import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal --- TODO PQ replace with actual instances -instance Eq (ConnectionRequestUri m) where _ == _ = True - -instance Eq (APartyCmdTag p) where - t1 == t2 = case (t1, t2) of - (APCT SAEConn NEW_, APCT SAEConn NEW_) -> True - (APCT SAEConn INV_, APCT SAEConn INV_) -> True - (APCT SAEConn JOIN_, APCT SAEConn JOIN_) -> True - (APCT SAEConn CONF_, APCT SAEConn CONF_) -> True - (APCT SAEConn LET_, APCT SAEConn LET_) -> True - (APCT SAEConn REQ_, APCT SAEConn REQ_) -> True - (APCT SAEConn ACPT_, APCT SAEConn ACPT_) -> True - (APCT SAEConn RJCT_, APCT SAEConn RJCT_) -> True - (APCT SAEConn INFO_, APCT SAEConn INFO_) -> True - (APCT SAEConn CON_, APCT SAEConn CON_) -> True - (APCT SAEConn SUB_, APCT SAEConn SUB_) -> True - (APCT SAEConn END_, APCT SAEConn END_) -> True - (APCT SAENone CONNECT_, APCT SAENone CONNECT_) -> True - (APCT SAENone DISCONNECT_, APCT SAENone DISCONNECT_) -> True - (APCT SAENone DOWN_, APCT SAENone DOWN_) -> True - (APCT SAENone UP_, APCT SAENone UP_) -> True - (APCT SAEConn SWITCH_, APCT SAEConn SWITCH_) -> True - (APCT SAEConn RSYNC_, APCT SAEConn RSYNC_) -> True - (APCT SAEConn SEND_, APCT SAEConn SEND_) -> True - (APCT SAEConn MID_, APCT SAEConn MID_) -> True - (APCT SAEConn SENT_, APCT SAEConn SENT_) -> True - (APCT SAEConn MERR_, APCT SAEConn MERR_) -> True - (APCT SAEConn MERRS_, APCT SAEConn MERRS_) -> True - (APCT SAEConn MSG_, APCT SAEConn MSG_) -> True - (APCT SAEConn MSGNTF_, APCT SAEConn MSGNTF_) -> True - (APCT SAEConn ACK_, APCT SAEConn ACK_) -> True - (APCT SAEConn RCVD_, APCT SAEConn RCVD_) -> True - (APCT SAEConn SWCH_, APCT SAEConn SWCH_) -> True - (APCT SAEConn OFF_, APCT SAEConn OFF_) -> True - (APCT SAEConn DEL_, APCT SAEConn DEL_) -> True - (APCT SAEConn DEL_RCVQ_, APCT SAEConn DEL_RCVQ_) -> True - (APCT SAEConn DEL_CONN_, APCT SAEConn DEL_CONN_) -> True - (APCT SAENone DEL_USER_, APCT SAENone DEL_USER_) -> True - (APCT SAEConn CHK_, APCT SAEConn CHK_) -> True - (APCT SAEConn STAT_, APCT SAEConn STAT_) -> True - (APCT SAEConn OK_, APCT SAEConn OK_) -> True - (APCT SAEConn ERR_, APCT SAEConn ERR_) -> True - (APCT SAENone SUSPENDED_, APCT SAENone SUSPENDED_) -> True - (APCT SAERcvFile RFDONE_, APCT SAERcvFile RFDONE_) -> True - (APCT SAERcvFile RFPROG_, APCT SAERcvFile RFPROG_) -> True - (APCT SAERcvFile RFERR_, APCT SAERcvFile RFERR_) -> True - (APCT SAESndFile SFPROG_, APCT SAESndFile SFPROG_) -> True - (APCT SAESndFile SFDONE_, APCT SAESndFile SFDONE_) -> True - (APCT SAESndFile SFERR_, APCT SAESndFile SFERR_) -> True - _ -> False - class IsContact a where contactId' :: a -> ContactId profile' :: a -> LocalProfile From 191d83394704a45c6b9c988c7bfa2fab7413e86f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 9 Mar 2024 03:09:12 +0400 Subject: [PATCH 38/64] core (pq): tests (#3882) * core (pq): tests * rename * move * test allow * mute test output * pq combinators * refactor --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat/Messages/CIContent.hs | 6 +- src/Simplex/Chat/Types.hs | 12 +- src/Simplex/Chat/View.hs | 2 +- tests/ChatClient.hs | 1 + tests/ChatTests/Direct.hs | 150 ++++++++++++++++++++++++- tests/ChatTests/Utils.hs | 66 ++++++++++- 6 files changed, 225 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index b44090290e..8640cf23d3 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -270,7 +270,7 @@ ciContentToText = \case directE2EInfoToText :: E2EInfo -> Text directE2EInfoToText E2EInfo {pqEnabled} = case pqEnabled of - PQEncOn -> "This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery." + PQEncOn -> e2eInfoPQText PQEncOff -> e2eInfoNoPQText groupE2EInfoToText :: E2EInfo -> Text @@ -280,6 +280,10 @@ e2eInfoNoPQText :: Text e2eInfoNoPQText = "This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery." +e2eInfoPQText :: Text +e2eInfoPQText = + "This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery." + ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role = "invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9f24e7007d..d4a3a8029e 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -49,7 +49,7 @@ import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) -import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) @@ -214,8 +214,8 @@ contactDeleted Contact {contactStatus} = contactStatus == CSDeleted contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode =<< activeConn -contactPQEnabled :: Contact -> Bool -contactPQEnabled Contact {activeConn} = maybe False connPQEnabled activeConn +contactPQEnabled :: Contact -> PQEncryption +contactPQEnabled Contact {activeConn} = maybe PQEncOff connPQEnabled activeConn data ContactStatus = CSActive @@ -1342,9 +1342,9 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId -connPQEnabled :: Connection -> Bool -connPQEnabled Connection {pqSndEnabled = Just (PQEncryption s), pqRcvEnabled = Just (PQEncryption r)} = s && r -connPQEnabled _ = False +connPQEnabled :: Connection -> PQEncryption +connPQEnabled Connection {pqSndEnabled = Just (PQEncryption s), pqRcvEnabled = Just (PQEncryption r)} = PQEncryption $ s && r +connPQEnabled _ = PQEncOff data PendingContactConnection = PendingContactConnection { pccConnId :: Int64, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e355da7a2d..d953b9183f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1177,7 +1177,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] - <> ["post-quantum encryption enabled" | contactPQEnabled ct] + <> ["post-quantum encryption enabled" | contactPQEnabled ct == CR.PQEncOn] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d5a56ec91a..691a4101a7 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -5,6 +5,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 600a4bcc1f..cc1a7791b0 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -10,7 +10,7 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Monad (forM_) +import Control.Monad (forM_, when) import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B @@ -25,7 +25,7 @@ import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQSupportOff, pattern PQEncOn, pattern PQEncOff) import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) @@ -128,6 +128,10 @@ chatDirectTests = do it "update peer version range on received messages" testUpdatePeerChatVRange describe "network statuses" $ do it "should get network statuses" testGetNetworkStatuses + describe "PQ tests" $ do + describe "enable PQ before connection, connect via invitation link" $ pqMatrix2 runTestPQConnectViaLink + describe "enable PQ before connection, connect via contact address" $ pqMatrix2 runTestPQConnectViaAddress + it "should enable PQ after several messages in connection without PQ" testPQAllowContact where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -2753,3 +2757,145 @@ contactInfoChatVRange cc (VersionRange minVer maxVer) = do cc <## "you've shared main profile with this contact" cc <## "connection not verified, use /code command to see security code" cc <## ("peer chat protocol version range: (" <> show minVer <> ", " <> show maxVer <> ")") + +runTestPQConnectViaLink :: HasCallStack => (TestCC, PQEnabled) -> (TestCC, PQEnabled) -> IO () +runTestPQConnectViaLink (alice, aPQ) (bob, bPQ) = do + when aPQ $ pqOn alice + when bPQ $ pqOn bob + + connectUsers alice bob + + (alice, "hi") `pqSend` bob + (bob, "hey") `pqSend` alice + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + where + pqEnabled = aPQ && bPQ + pqSend = if pqEnabled then (+#>) else (\#>) + e2eeInfo = if pqEnabled then e2eeInfoPQStr else e2eeInfoNoPQStr + +pqOn :: TestCC -> IO () +pqOn cc = do + cc ##> "/_pq on" + cc <## "ok" + +runTestPQConnectViaAddress :: HasCallStack => (TestCC, PQEnabled) -> (TestCC, PQEnabled) -> IO () +runTestPQConnectViaAddress (alice, aPQ) (bob, bPQ) = do + when aPQ $ pqOn alice + when bPQ $ pqOn bob + + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + (alice, "hi") `pqSend` bob + (bob, "hey") `pqSend` alice + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqEnabled + where + pqEnabled = aPQ && bPQ + pqSend = if pqEnabled then (+#>) else (\#>) + e2eeInfo = if pqEnabled then e2eeInfoPQStr else e2eeInfoNoPQStr + +testPQAllowContact :: HasCallStack => FilePath -> IO () +testPQAllowContact = + testChat2 aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + (alice, "hi") \#> bob + (bob, "hey") \#> alice + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfoNoPQStr)] + PQEncOff <- alice `pqForContact` 2 + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfoNoPQStr)] + PQEncOff <- bob `pqForContact` 2 + + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + -- enabling experimental flags doesn't enable PQ in previously created connection + pqOn alice + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + pqOn bob + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + -- if only one contact allows PQ, it's not enabled + alice ##> "/_pq allow 2" + alice <## "bob: post-quantum encryption allowed" + sendMany PQEncOff alice bob + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + -- both contacts have to allow PQ to enable it + bob ##> "/_pq allow 2" + bob <## "alice: post-quantum encryption allowed" + + (alice, "1") \#> bob + (bob, "2") \#> alice + (alice, "3") \#> bob + (bob, "4") \#> alice + (alice, "5") +#> bob + + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + + (bob, "6") ++#> alice + -- equivalent to: + -- bob `send` "@alice 6" + -- bob <## "alice: post-quantum encryption enabled" + -- bob <# "@alice 6" + -- alice <## "bob: post-quantum encryption enabled" + -- alice <# "bob> 6" + + PQEncOn <- alice `pqForContact` 2 + alice #$> ("/_get chat @2 count=2", chat, [(0, "post-quantum encryption enabled"), (0, "6")]) + + PQEncOn <- bob `pqForContact` 2 + bob #$> ("/_get chat @2 count=2", chat, [(1, "post-quantum encryption enabled"), (1, "6")]) + + (alice, "6") +#> bob + (bob, "7") +#> alice + + sendMany PQEncOn alice bob + + PQEncOn <- alice `pqForContact` 2 + PQEncOn <- bob `pqForContact` 2 + pure () + where + sendMany pqEnc alice bob = + forM_ [(1 :: Int) .. 10] $ \i -> do + sndRcv pqEnc False (alice, show i) bob + sndRcv pqEnc False (bob, show i) alice diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 322a810d92..387dd95dd9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -21,8 +21,9 @@ import Data.String import qualified Data.Text as T import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) -import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText) +import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol +import Simplex.Chat.Store.Direct (getContact) import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types @@ -30,7 +31,7 @@ import Simplex.Chat.Types.Preferences import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) @@ -114,6 +115,17 @@ runTestCfg3 aliceCfg bobCfg cathCfg runTest tmp = withNewTestChatCfg tmp cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath +type PQEnabled = Bool + +pqMatrix2 :: (HasCallStack => (TestCC, PQEnabled) -> (TestCC, PQEnabled) -> IO ()) -> SpecWith FilePath +pqMatrix2 runTest = do + it "PQ: off, off" $ test False False + it "PQ: on, off" $ test False True + it "PQ: off, on" $ test True False + it "PQ: on, on" $ test True True + where + test aPQ bPQ = testChat2 aliceProfile bobProfile $ \a b -> runTest (a, aPQ) (b, bPQ) + withTestChatGroup3Connected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatGroup3Connected tmp dbPrefix action = do withTestChat tmp dbPrefix $ \cc -> do @@ -172,6 +184,32 @@ cc #$> (cmd, f, res) = do cc ##> cmd (f <$> getTermLine cc) `shouldReturn` res +-- / PQ combinators + +(\#>) :: HasCallStack => (TestCC, String) -> TestCC -> IO () +(\#>) = sndRcv PQEncOff False + +(+#>) :: HasCallStack => (TestCC, String) -> TestCC -> IO () +(+#>) = sndRcv PQEncOn False + +(++#>) :: HasCallStack => (TestCC, String) -> TestCC -> IO () +(++#>) = sndRcv PQEncOn True + +sndRcv :: HasCallStack => PQEncryption -> Bool -> (TestCC, String) -> TestCC -> IO () +sndRcv pqEnc enabled (cc1, msg) cc2 = do + name1 <- userName cc1 + name2 <- userName cc2 + let cmd = "@" <> name2 <> " " <> msg + cc1 `send` cmd + when enabled $ cc1 <## (name2 <> ": post-quantum encryption enabled") + cc1 <# cmd + cc1 `pqSndForContact` 2 `shouldReturn` pqEnc + when enabled $ cc2 <## (name1 <> ": post-quantum encryption enabled") + cc2 <# (name1 <> "> " <> msg) + cc2 `pqRcvForContact` 2 `shouldReturn` pqEnc + +-- PQ combinators / + chat :: String -> [(Int, String)] chat = map (\(a, _, _) -> a) . chat'' @@ -206,6 +244,9 @@ chatFeatures'' = e2eeInfoNoPQStr :: String e2eeInfoNoPQStr = T.unpack e2eInfoNoPQText +e2eeInfoPQStr :: String +e2eeInfoPQStr = T.unpack e2eInfoPQText + lastChatFeature :: String lastChatFeature = snd $ last chatFeatures @@ -476,6 +517,27 @@ getProfilePictureByName cc displayName = maybeFirstRow fromOnly $ DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName) +pqSndForContact :: TestCC -> ContactId -> IO PQEncryption +pqSndForContact = pqForContact_ pqSndEnabled + +pqRcvForContact :: TestCC -> ContactId -> IO PQEncryption +pqRcvForContact = pqForContact_ pqRcvEnabled + +pqForContact :: TestCC -> ContactId -> IO PQEncryption +pqForContact = pqForContact_ (Just . connPQEnabled) + +pqForContact_ :: (Connection -> Maybe PQEncryption) -> TestCC -> ContactId -> IO PQEncryption +pqForContact_ pqSel cc contactId = + getTestCCContact cc contactId >>= \ct -> case contactConn ct of + Just conn -> pure $ fromMaybe PQEncOff $ pqSel conn + Nothing -> fail "no connection" + +getTestCCContact :: TestCC -> ContactId -> IO Contact +getTestCCContact cc contactId = + withCCTransaction cc $ \db -> + withCCUser cc $ \user -> + runExceptT (getContact db user contactId) >>= either (fail . show) pure + lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" From 7fb3c4abdba7ed33284bbe966e9a26cce0942114 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 9 Mar 2024 23:03:13 +0000 Subject: [PATCH 39/64] 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 c3851802b7..e36cc54730 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -61,6 +61,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 */; }; + 5C777BEC2B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BE72B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N-ghc9.6.3.a */; }; + 5C777BED2B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BE82B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N.a */; }; + 5C777BEE2B9D21A900C72EFF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BE92B9D21A900C72EFF /* libffi.a */; }; + 5C777BEF2B9D21A900C72EFF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BEA2B9D21A900C72EFF /* libgmpxx.a */; }; + 5C777BF02B9D21A900C72EFF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BEB2B9D21A900C72EFF /* libgmp.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 */; }; @@ -139,11 +144,6 @@ 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 */; }; - 5CF4416D2B8E14EF00C52786 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF441682B8E14EF00C52786 /* libgmpxx.a */; }; - 5CF4416E2B8E14EF00C52786 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF441692B8E14EF00C52786 /* libffi.a */; }; - 5CF4416F2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */; }; - 5CF441702B8E14EF00C52786 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416B2B8E14EF00C52786 /* libgmp.a */; }; - 5CF441712B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */; }; 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; }; 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; }; @@ -325,6 +325,11 @@ 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; + 5C777BE72B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N-ghc9.6.3.a"; sourceTree = ""; }; + 5C777BE82B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N.a"; sourceTree = ""; }; + 5C777BE92B9D21A900C72EFF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C777BEA2B9D21A900C72EFF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C777BEB2B9D21A900C72EFF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -426,11 +431,6 @@ 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 = ""; }; - 5CF441682B8E14EF00C52786 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF441692B8E14EF00C52786 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a"; sourceTree = ""; }; - 5CF4416B2B8E14EF00C52786 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a"; sourceTree = ""; }; 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = ""; }; 5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = ""; }; @@ -514,13 +514,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF4416D2B8E14EF00C52786 /* libgmpxx.a in Frameworks */, - 5CF441712B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a in Frameworks */, - 5CF4416F2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a in Frameworks */, + 5C777BEF2B9D21A900C72EFF /* libgmpxx.a in Frameworks */, + 5C777BF02B9D21A900C72EFF /* libgmp.a in Frameworks */, + 5C777BEC2B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CF441702B8E14EF00C52786 /* libgmp.a in Frameworks */, - 5CF4416E2B8E14EF00C52786 /* libffi.a in Frameworks */, + 5C777BEE2B9D21A900C72EFF /* libffi.a in Frameworks */, + 5C777BED2B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -582,11 +582,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF441692B8E14EF00C52786 /* libffi.a */, - 5CF4416B2B8E14EF00C52786 /* libgmp.a */, - 5CF441682B8E14EF00C52786 /* libgmpxx.a */, - 5CF4416A2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7-ghc9.6.3.a */, - 5CF4416C2B8E14EF00C52786 /* libHSsimplex-chat-5.5.6.0-LWDDfwd7rJr1QA7wWhj0K7.a */, + 5C777BE92B9D21A900C72EFF /* libffi.a */, + 5C777BEB2B9D21A900C72EFF /* libgmp.a */, + 5C777BEA2B9D21A900C72EFF /* libgmpxx.a */, + 5C777BE72B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N-ghc9.6.3.a */, + 5C777BE82B9D21A900C72EFF /* libHSsimplex-chat-5.5.6.0-9o3DFFEYPI8LImicClpr6N.a */, ); path = Libraries; sourceTree = ""; From 60a73a539eb81eb3981ebc8fd26e1a27094eedfd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 10 Mar 2024 11:31:14 +0000 Subject: [PATCH 40/64] core: add agreed connection version field (#3881) * core: add agreed connection version field * fix * progress * use pqSupport and version to decide compression in messages * pass version to encodeConnInfoPQ * update pq enable/disable api * remove TestConfig * update nix dependencies * update texts * corrections * create e2ee info items when connection switches from off to on first time * corrections Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * comment * increase test timeout --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 12 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 18 +- .../Views/UserSettings/DeveloperView.swift | 4 +- apps/ios/SimpleXChat/APITypes.swift | 16 +- apps/ios/SimpleXChat/ChatTypes.swift | 8 +- .../chat/simplex/common/model/SimpleXAPI.kt | 24 +-- .../chat/simplex/common/platform/Core.kt | 2 +- .../simplex/common/views/chat/ChatInfoView.kt | 14 +- .../views/usersettings/DeveloperView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 4 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 187 ++++++++++-------- src/Simplex/Chat/Controller.hs | 7 +- src/Simplex/Chat/Help.hs | 3 + src/Simplex/Chat/Messages/CIContent.hs | 8 +- src/Simplex/Chat/Migrations/M20240228_pq.hs | 2 + src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 17 +- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 42 ++-- src/Simplex/Chat/Store/Files.hs | 3 +- src/Simplex/Chat/Store/Groups.hs | 76 +++---- src/Simplex/Chat/Store/Profiles.hs | 6 +- src/Simplex/Chat/Store/Shared.hs | 36 ++-- src/Simplex/Chat/Types.hs | 3 +- src/Simplex/Chat/View.hs | 6 +- tests/ChatTests/Direct.hs | 72 +++++-- tests/ChatTests/Utils.hs | 58 ++++-- 30 files changed, 380 insertions(+), 259 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 986287fca0..a5a07a8722 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -270,7 +270,7 @@ jobs: - name: Unix test if: matrix.os != 'windows-latest' - timeout-minutes: 30 + timeout-minutes: 40 shell: bash run: cabal test --test-show-details=direct diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7645a94035..57dab12a87 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -258,15 +258,15 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { throw r } -func apiSetPQEnabled(_ enable: Bool) throws { - let r = chatSendCmdSync(.apiSetPQEnabled(enable: enable)) +func apiSetPQEncryption(_ enable: Bool) throws { + let r = chatSendCmdSync(.apiSetPQEncryption(enable: enable)) if case .cmdOk = r { return } throw r } -func apiAllowContactPQ(_ contactId: Int64) async throws -> Contact { - let r = await chatSendCmd(.apiAllowContactPQ(contactId: contactId)) - if case let .contactPQAllowed(_, contact) = r { return contact } +func apiSetContactPQ(_ contactId: Int64, _ enable: Bool) async throws -> Contact { + let r = await chatSendCmd(.apiSetContactPQ(contactId: contactId, enable: enable)) + if case let .contactPQAllowed(_, contact, _) = r { return contact } throw r } @@ -1256,7 +1256,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) - try apiSetPQEnabled(pqExperimentalEnabledDefault.get()) + try apiSetPQEncryption(pqExperimentalEnabledDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index bc4b6947ab..86532605db 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -171,15 +171,15 @@ struct ChatInfoView: View { if pqExperimentalEnabled, let conn = contact.activeConn { Section { - infoRow(Text(String("PQ E2E encryption")), conn.connPQEnabled ? "Enabled" : "Disabled") - if !conn.pqSupport { + infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") + if !conn.pqEncryption { allowPQButton() } } header: { - Text(String("Post-quantum E2E encryption")) + Text(String("Quantum resistant E2E encryption")) } footer: { - if !conn.pqSupport { - Text(String("After allowing post-quantum encryption, it will be enabled after several messages if your contact also allows it.")) + if !conn.pqEncryption { + Text(String("After allowing quantum resistant encryption, it will be enabled after several messages if your contact also allows it.")) } } } @@ -576,14 +576,14 @@ struct ChatInfoView: View { private func allowContactPQEncryption() { Task { do { - let ct = try await apiAllowContactPQ(contact.apiId) + let ct = try await apiSetContactPQ(contact.apiId, true) contact = ct await MainActor.run { chatModel.updateContact(contact) dismiss() } } catch let error { - logger.error("allowContactPQEncryption apiAllowContactPQ error: \(responseError(error))") + logger.error("allowContactPQEncryption apiSetContactPQ error: \(responseError(error))") let a = getErrorAlert(error, "Error allowing contact PQ encryption") await MainActor.run { alert = .error(title: a.title, error: a.message) @@ -594,8 +594,8 @@ struct ChatInfoView: View { func allowContactPQEncryptionAlert() -> Alert { Alert( - title: Text(String("Allow post-quantum encryption?")), - message: Text(String("This is an experimental feature, it is not recommended to enable it for high importance communications. It may result in connection errors!")), + title: Text(String("Allow quantum resistant encryption?")), + message: Text(String("This is an experimental feature, it is not recommended to enable it for important chats.")), primaryButton: .destructive(Text(String("Allow")), action: allowContactPQEncryption), secondaryButton: .cancel() ) diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 816b46c54f..9b11c6d0f7 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -64,10 +64,10 @@ struct DeveloperView: View { private func setPQExperimentalEnabled(_ enable: Bool) { do { - try apiSetPQEnabled(enable) + try apiSetPQEncryption(enable) } catch let error { let err = responseError(error) - logger.error("apiSetPQEnabled \(err)") + logger.error("apiSetPQEncryption \(err)") } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 6bb3fbb3c2..4df419ffef 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -32,8 +32,8 @@ public enum ChatCommand { case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) case apiSetEncryptLocalFiles(enable: Bool) - case apiSetPQEnabled(enable: Bool) - case apiAllowContactPQ(contactId: Int64) + case apiSetPQEncryption(enable: Bool) + case apiSetContactPQ(contactId: Int64, enable: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -164,8 +164,8 @@ public enum ChatCommand { case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" - case let .apiSetPQEnabled(enable): return "/_pq \(onOff(enable))" - case let .apiAllowContactPQ(contactId): return "/_pq allow \(contactId)" + case let .apiSetPQEncryption(enable): return "/pq \(onOff(enable))" + case let .apiSetContactPQ(contactId, enable): return "/_pq @\(contactId) \(onOff(enable))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -310,8 +310,8 @@ public enum ChatCommand { case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" - case .apiSetPQEnabled: return "apiSetPQEnabled" - case .apiAllowContactPQ: return "apiAllowContactPQ" + case .apiSetPQEncryption: return "apiSetPQEncryption" + case .apiSetContactPQ: return "apiSetContactPQ" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -624,7 +624,7 @@ public enum ChatResponse: Decodable, Error { case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) // pq - case contactPQAllowed(user: UserRef, contact: Contact) + case contactPQAllowed(user: UserRef, contact: Contact, pqEncryption: Bool) case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) @@ -926,7 +926,7 @@ public enum ChatResponse: Decodable, Error { case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case .remoteCtrlStopped: return noDetails - case let .contactPQAllowed(u, contact): return withUser(u, "contact: \(String(describing: contact))") + case let .contactPQAllowed(u, contact, pqEncryption): return withUser(u, "contact: \(String(describing: contact))\npqEncryption: \(pqEncryption)") case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" case .cmdOk: return noDetails diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 267c254be1..3463bfca18 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3625,9 +3625,9 @@ public enum RcvConnEvent: Decodable { return NSLocalizedString("security code changed", comment: "chat item text") case let .pqEnabled(enabled): if enabled { - return NSLocalizedString("enabled post-quantum encryption", comment: "chat item text") + return NSLocalizedString("quantum resistant e2e encryption", comment: "chat item text") } else { - return NSLocalizedString("disabled post-quantum encryption", comment: "chat item text") + return NSLocalizedString("standard end-to-end encryption", comment: "chat item text") } } } @@ -3672,9 +3672,9 @@ public enum SndConnEvent: Decodable { return ratchetSyncStatusToText(syncStatus) case let .pqEnabled(enabled): if enabled { - return NSLocalizedString("enabled post-quantum encryption", comment: "chat item text") + return NSLocalizedString("quantum resistant e2e encryption", comment: "chat item text") } else { - return NSLocalizedString("disabled post-quantum encryption", comment: "chat item text") + return NSLocalizedString("standard end-to-end encryption", comment: "chat item text") } } } 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 9e3ce480df..d695b2c608 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 @@ -635,12 +635,12 @@ object ChatController { suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) - suspend fun apiSetPQEnabled(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEnabled(enable)) + suspend fun apiSetPQEncryption(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEncryption(enable)) - suspend fun apiAllowContactPQ(rh: Long?, contactId: Long): Contact? { - val r = sendCmd(rh, CC.ApiAllowContactPQ(contactId)) + suspend fun apiSetContactPQ(rh: Long?, contactId: Long, enable: Boolean): Contact? { + val r = sendCmd(rh, CC.ApiSetContactPQ(contactId, enable)) if (r is CR.ContactPQAllowed) return r.contact - apiErrorAlert("apiAllowContactPQ", "Error allowing contact PQ", r) + apiErrorAlert("apiSetContactPQ", "Error allowing contact PQ", r) return null } @@ -2289,8 +2289,8 @@ sealed class CC { class SetFilesFolder(val filesFolder: String): CC() class SetRemoteHostsFolder(val remoteHostsFolder: String): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC() - class ApiSetPQEnabled(val enable: Boolean): CC() - class ApiAllowContactPQ(val contactId: Long): CC() + class ApiSetPQEncryption(val enable: Boolean): CC() + class ApiSetContactPQ(val contactId: Long, val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -2420,8 +2420,8 @@ sealed class CC { is SetFilesFolder -> "/_files_folder $filesFolder" is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" - is ApiSetPQEnabled -> "/_pq ${onOff(enable)}" - is ApiAllowContactPQ -> "/_pq allow $contactId" + is ApiSetPQEncryption -> "/pq ${onOff(enable)}" + is ApiSetContactPQ -> "/_pq @$contactId ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -2556,8 +2556,8 @@ sealed class CC { is SetFilesFolder -> "setFilesFolder" is SetRemoteHostsFolder -> "setRemoteHostsFolder" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" - is ApiSetPQEnabled -> "apiSetPQEnabled" - is ApiAllowContactPQ -> "apiAllowContactPQ" + is ApiSetPQEncryption -> "apiSetPQEncryption" + is ApiSetContactPQ -> "apiSetContactPQ" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" @@ -4024,7 +4024,7 @@ sealed class CR { @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(val rcsState: RemoteCtrlSessionState, val rcStopReason: RemoteCtrlStopReason): CR() // pq - @Serializable @SerialName("contactPQAllowed") class ContactPQAllowed(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("contactPQAllowed") class ContactPQAllowed(val user: UserRef, val contact: Contact, val pqEncryption: Boolean): CR() @Serializable @SerialName("contactPQEnabled") class ContactPQEnabled(val user: UserRef, val contact: Contact, val pqEnabled: Boolean): CR() // misc @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @@ -4342,7 +4342,7 @@ sealed class CR { "\nsessionCode: $sessionCode" is RemoteCtrlConnected -> json.encodeToString(remoteCtrl) is RemoteCtrlStopped -> noDetails() - is ContactPQAllowed -> withUser(user, "contact: ${contact.id}") + is ContactPQAllowed -> withUser(user, "contact: ${contact.id}\npqEncryption: $pqEncryption") is ContactPQEnabled -> withUser(user, "contact: ${contact.id}\npqEnabled: $pqEnabled") is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" + "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + 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 6e30a89810..7e2ba462c9 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 @@ -92,7 +92,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat controller.apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) } controller.apiSetEncryptLocalFiles(controller.appPrefs.privacyEncryptLocalFiles.get()) - controller.apiSetPQEnabled(controller.appPrefs.pqExperimentalEnabled.get()) + controller.apiSetPQEncryption(controller.appPrefs.pqExperimentalEnabled.get()) // If we migrated successfully means previous re-encryption process on database level finished successfully too if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 97ec502f35..bbd5d93018 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -143,7 +143,7 @@ fun ChatInfoView( allowContactPQ = { showAllowContactPQAlert(allowContactPQ = { withBGApi { - val ct = chatModel.controller.apiAllowContactPQ(chatRh, contact.contactId) + val ct = chatModel.controller.apiSetContactPQ(chatRh, contact.contactId, true) if (ct != null) { chatModel.updateContact(chatRh, contact) } @@ -362,11 +362,11 @@ fun ChatInfoLayout( val conn = contact.activeConn if (pqExperimentalEnabled && conn != null) { - SectionView("Post-quantum E2E encryption") { - InfoRow("PQ E2E encryption", if (conn.connPQEnabled) "Enabled" else "Disabled") - if (!conn.pqSupport) { + SectionView("Quantum resistant E2E encryption") { + InfoRow("E2E encryption", if (conn.connPQEnabled) "Quantum resistant" else "Standard") + if (!conn.pqEncryption) { AllowContactPQButton(allowContactPQ) - SectionTextFooter("After allowing post-quantum encryption, it will be enabled after several messages if your contact also allows it.") + SectionTextFooter("After allowing quantum resistant e2e encryption, it will be enabled after several messages if your contact also allows it.") } SectionDividerSpaced() } @@ -744,8 +744,8 @@ fun showSyncConnectionForceAlert(syncConnectionForce: () -> Unit) { fun showAllowContactPQAlert(allowContactPQ: () -> Unit) { AlertManager.shared.showAlertDialog( - title = "Allow post-quantum encryption?", - text = "This is an experimental feature, it is not recommended to enable it for high importance communications. It may result in connection errors!", + title = "Allow quantum resistant encryption?", + text = "This is an experimental feature, it is not recommended to enable it for important chats.", confirmText = "Allow", onConfirm = allowContactPQ, destructive = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index 5dca1527f2..421d4feec3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -62,7 +62,7 @@ fun DeveloperView( SectionSpacer() SectionView("Experimental".uppercase()) { SettingsPreferenceItem(painterResource(MR.images.ic_vpn_key_filled), "Post-quantum E2EE", m.controller.appPrefs.pqExperimentalEnabled, onChange = { enable -> - withBGApi { m.controller.apiSetPQEnabled(enable) } + withBGApi { m.controller.apiSetPQEncryption(enable) } }) SectionTextFooter("In this version applies only to new contacts.") } 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 7abd5c8a49..49552a592d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1242,8 +1242,8 @@ agreeing encryption for %s… encryption agreed for %s security code changed - enabled post-quantum encryption - disabled post-quantum encryption + quantum resistant e2e encryption + standard end-to-end encryption observer diff --git a/cabal.project b/cabal.project index 9f72b40aea..05e9af91ff 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8cdd49b91256aee56427f8b8e351cf415045e9c7 + tag: dab55e0a9b03577f643af7922afa061801d82ed5 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 21de83d0ab..7ca6c8c074 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8cdd49b91256aee56427f8b8e351cf415045e9c7" = "0wgj9ypr6ry414bb15ixyg75cpivwycyh4icy33xm5whksvwy93r"; + "https://github.com/simplex-chat/simplexmq.git"."dab55e0a9b03577f643af7922afa061801d82ed5" = "0dzqsvzxby83nla0rpx3xzj2y18lvmgs5ldjv5i1yp52npc88s1m"; "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 27a38cb292..068d3197fa 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -596,18 +596,20 @@ processChatCommand' vr = \case ok_ APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ SetContactMergeEnabled onOff -> chatWriteVar contactMergeEnabled onOff >> ok_ - APISetPQEnabled onOff -> chatWriteVar pqExperimentalEnabled onOff >> ok_ - APIAllowContactPQ contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + APISetPQEncryption onOff -> chatWriteVar pqExperimentalEnabled onOff >> ok_ + APISetContactPQ ctId pqEnc -> withUser $ \user -> do + ct@Contact {activeConn} <- withStore $ \db -> getContact db user ctId case activeConn of - Just conn@Connection {connId, pqSupport} -> case pqSupport of - PQSupportOn -> pure $ chatCmdError (Just user) "already allowed" - PQSupportOff -> do - withStore' $ \db -> updateConnSupportPQ db connId PQSupportOn - let conn' = conn {pqSupport = PQSupportOn, pqEncryption = PQEncOn} :: Connection - ct' = ct {activeConn = Just conn'} :: Contact - pure $ CRContactPQAllowed user ct' + Just conn@Connection {connId, pqSupport, pqEncryption} + | pqEncryption == pqEnc -> pure $ CRContactPQAllowed user ct pqEnc + | otherwise -> do + let pqSup = PQSupport $ pqEnc == PQEncOn || pqSupport == PQSupportOn + conn' = conn {pqSupport = pqSup, pqEncryption = pqEnc} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + withStore' $ \db -> updateConnSupportPQ db connId pqSup pqEnc + pure $ CRContactPQAllowed user ct' pqEnc Nothing -> throwChatError $ CEContactNotActive ct + SetContactPQ cName pqEnc -> withContactName cName (`APISetContactPQ` pqEnc) APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -1401,7 +1403,8 @@ processChatCommand' vr = \case subMode <- chatReadVar subscriptionMode pqSup <- chatReadVar pqExperimentalEnabled (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing (IKNoPQ pqSup) subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode pqSup + -- TODO PQ pass minVersion from the current range + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode initialChatVersion pqSup pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> processChatCommand $ APIAddContact userId incognito @@ -1431,10 +1434,12 @@ processChatCommand' vr = \case pqSup <- chatReadVar pqExperimentalEnabled withAgent' (\a -> connRequestPQSupport a pqSup cReq) >>= \case Nothing -> throwChatError CEInvalidConnReq - Just pqSup' -> do - dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend + -- TODO PQ the error above should be CEIncompatibleConnReqVersion, also the same API should be called in Plan + Just (agentV, pqSup') -> do + let chatV = agentToChatVersion agentV + dm <- encodeConnInfoPQ pqSup' (Just chatV) $ XInfo profileToSend connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup' subMode - conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode pqSup' + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode chatV pqSup' pure $ CRSentConfirmation user conn APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq @@ -1652,8 +1657,9 @@ processChatCommand' vr = \case subMode <- chatReadVar subscriptionMode dm <- encodeConnInfo $ XGrpAcpt membershipMemId agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm PQSupportOff subMode + let chatV = vr `compatibleChatVersion` peerChatVRange withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId peerChatVRange subMode + createMemberConnection db userId fromMember agentConnId chatV peerChatVRange subMode updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` \_ -> pure () @@ -2163,19 +2169,19 @@ processChatCommand' vr = \case where connect' groupLinkId cReqHash xContactId inGroup = do pqSup <- if inGroup then pure PQSupportOff else chatReadVar pqExperimentalEnabled - (connId, incognitoProfile, subMode, pqSup') <- requestContact user incognito cReq xContactId inGroup pqSup - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode pqSup' + (connId, incognitoProfile, subMode, chatV) <- requestContact user incognito cReq xContactId inGroup pqSup + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse connectContactViaAddress user incognito ct cReq = withChatLock "connectViaContact" $ do newXContactId <- XContactId <$> drgRandomBytes 16 pqSup <- chatReadVar pqExperimentalEnabled - (connId, incognitoProfile, subMode, pqSup') <- requestContact user incognito cReq newXContactId False pqSup + (connId, incognitoProfile, subMode, chatV) <- requestContact user incognito cReq newXContactId False pqSup let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode pqSup' + ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode chatV pqSup pure $ CRSentInvitationToContact user ct' incognitoProfile - requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQSupport -> m (ConnId, Maybe Profile, SubscriptionMode, PQSupport) + requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQSupport -> m (ConnId, Maybe Profile, SubscriptionMode, VersionChat) requestContact user incognito cReq xContactId inGroup pqSup = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -2185,14 +2191,12 @@ processChatCommand' vr = \case -- 2) toggle enabled, address doesn't support PQ - PQSupportOn but without compression, with version range indicating support withAgent' (\a -> connRequestPQSupport a pqSup cReq) >>= \case Nothing -> throwChatError CEInvalidConnReq - Just pqCompress -> do - let (pqSup', pqCompress') = case pqSup of - PQSupportOff -> (PQSupportOff, PQSupportOff) - PQSupportOn -> (PQSupportOn, pqCompress) - dm <- encodeConnInfoPQ pqCompress' (XContact profileToSend $ Just xContactId) + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + dm <- encodeConnInfoPQ pqSup (Just chatV) (XContact profileToSend $ Just xContactId) subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup' subMode - pure (connId, incognitoProfile, subMode, pqSup') + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup subMode + pure (connId, incognitoProfile, subMode, chatV) contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -2889,17 +2893,19 @@ acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId inv let profileToSend = profileToSendOnAccept user incognitoProfile False pqSup <- chatReadVar pqExperimentalEnabled let pqSup' = pqSup `CR.pqSupportAnd` pqSupport - dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend + chatV <- pqCompatibleVersion pqSup cReqChatVRange + dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend acId <- withAgent $ \a -> acceptContact a True invId dm pqSup' subMode - withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup' contactUsed + withStore' $ \db -> createAcceptedContact db user acId chatV cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup' contactUsed acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> m Contact acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False - (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup + chatV <- chatReadVar pqExperimentalEnabled >>= (`pqCompatibleVersion` cReqChatVRange) + (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup chatV withStore' $ \db -> do - ct@Contact {activeConn} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed + ct@Contact {activeConn} <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct @@ -2907,7 +2913,7 @@ acceptGroupJoinRequestAsync :: ChatMonad m => User -> GroupInfo -> UserContactRe acceptGroupJoinRequestAsync user gInfo@GroupInfo {groupProfile, membership} - ucr@UserContactRequest {agentInvitationId = AgentInvId invId} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} gLinkMemRole incognitoProfile = do gVar <- asks random @@ -2925,9 +2931,10 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff + chatV <- chatReadVar pqExperimentalEnabled >>= (`pqCompatibleVersion` cReqChatVRange) + connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do - liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode + liftIO $ createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode getGroupMemberById db user groupMemberId profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Bool -> Profile @@ -3516,8 +3523,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processCONFpqSupport :: Connection -> PQSupport -> m Connection processCONFpqSupport conn@Connection {connId, pqSupport = pq} pq' | pq == PQSupportOn && pq' == PQSupportOff = do - withStore' $ \db -> updateConnSupportPQ db connId pq' - pure (conn {pqSupport = pq', pqEncryption = CR.pqSupportToEnc pq'} :: Connection) + let pqEnc' = CR.pqSupportToEnc pq' + withStore' $ \db -> updateConnSupportPQ db connId pq' pqEnc' + pure (conn {pqSupport = pq', pqEncryption = pqEnc'} :: Connection) | pq /= pq' = do messageWarning "processCONFpqSupport: unexpected pqSupport change" pure conn @@ -3528,7 +3536,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (pq /= pq') $ messageWarning "processINFOpqSupport: unexpected pqSupport change" processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, connChatVersion, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId pqSupport _ connInfo -> do conn' <- processCONFpqSupport conn pqSupport @@ -3652,7 +3660,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CON pqEnc -> withStore' (\db -> getViaGroupMember db vr user ct) >>= \case Nothing -> do - withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc + when (pqEnc == PQEncOn) $ withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc let conn' = conn {pqSndEnabled = Just pqEnc, pqRcvEnabled = Just pqEnc} :: Connection ct' = ct {activeConn = Just conn'} :: Contact -- [incognito] print incognito profile used for this contact @@ -3680,7 +3688,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds peerChatVRange subMode + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct @@ -4960,7 +4968,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processGroupInvitation ct inv msg msgMeta = do let Contact {localDisplayName = c, activeConn} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv - forM_ activeConn $ \Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do + forM_ activeConn $ \Connection {connId, connChatVersion, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile @@ -4973,7 +4981,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do setViaGroupLinkHash db groupId connId - createMemberConnectionAsync db user hostId connIds peerChatVRange subMode + createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) @@ -5413,7 +5421,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | maxVersion mcvr >= groupDirectInvVersion -> pure Nothing | otherwise -> Just <$> createConn subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode + chatV = (vr `compatibleChatVersion` ) . fromChatVRange =<< memChatVRange + void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" where createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode @@ -5460,7 +5469,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memChatVRange - withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode + chatV = vr `compatibleChatVersion` mcvr + withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> m () xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg brokerTs @@ -5814,27 +5824,36 @@ metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs sameMemberId :: MemberId -> GroupMember -> Bool sameMemberId memId GroupMember {memberId} = memId == memberId +-- TODO v5.7 for contacts only version upgrade should trigger enabling PQ support/encryption updatePeerChatVRange :: ChatMonad m => Connection -> VersionRangeChat -> m Connection -updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do - let jMsgChatVRange = msgChatVRange - if jMsgChatVRange /= peerChatVRange +updatePeerChatVRange conn@Connection {connId, connChatVersion = v, peerChatVRange, pqSupport} msgVRange = do + v' <- upgradedConnVersion pqSupport v msgVRange + if msgVRange /= peerChatVRange || v' /= v then do - withStore' $ \db -> setPeerChatVRange db connId msgChatVRange - pure conn {peerChatVRange = jMsgChatVRange} + withStore' $ \db -> setPeerChatVRange db connId v' msgVRange + pure conn {connChatVersion = v', peerChatVRange = msgVRange} else pure conn updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRangeChat -> m (GroupMember, Connection) -updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, peerChatVRange} msgChatVRange = do - let jMsgChatVRange = msgChatVRange - if jMsgChatVRange /= peerChatVRange +updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, connChatVersion = v, peerChatVRange} msgVRange = do + v' <- upgradedConnVersion PQSupportOff v msgVRange + if msgVRange /= peerChatVRange || v' /= v then do withStore' $ \db -> do - setPeerChatVRange db connId msgChatVRange - setMemberChatVRange db groupMemberId msgChatVRange - let conn' = conn {peerChatVRange = jMsgChatVRange} - pure (mem {memberChatVRange = jMsgChatVRange, activeConn = Just conn'}, conn') + setPeerChatVRange db connId v' msgVRange + setMemberChatVRange db groupMemberId msgVRange + let conn' = conn {connChatVersion = v', peerChatVRange = msgVRange} + pure (mem {memberChatVRange = msgVRange, activeConn = Just conn'}, conn') else pure (mem, conn) +upgradedConnVersion :: ChatMonad' m => PQSupport -> Maybe VersionChat -> VersionRangeChat -> m (Maybe VersionChat) +upgradedConnVersion pqSup v_ vr = do + v_' <- pqCompatibleVersion pqSup vr + pure $ case (v_, v_') of + (Just v, Just v') -> Just $ max v v' + (Nothing, v'@Just {}) -> v' + (v, Nothing) -> v + parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) @@ -6127,18 +6146,19 @@ batchSndMessagesJSON = batchMessages maxRawMsgLength . L.toList -- SMP.TBTransmission {} -> Left . ChatError $ CEInternalError "batchTransmissions_ didn't produce a batch" encodeConnInfo :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString -encodeConnInfo = encodeConnInfoPQ PQSupportOff +encodeConnInfo chatMsgEvent = do + vr <- chatVersionRange PQSupportOff + encodeConnInfoPQ PQSupportOff (Just $ maxVersion vr) chatMsgEvent -- TODO PQ check size after compression (in compressedBatchMsgBody_ ?) -encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> ChatMsgEvent e -> m ByteString -encodeConnInfoPQ pqSup chatMsgEvent = do +encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> Maybe VersionChat -> ChatMsgEvent e -> m ByteString +encodeConnInfoPQ pqSup v chatMsgEvent = do chatVRange <- chatVersionRange pqSup - let shouldCompress = maxVersion chatVRange >= pqEncryptionCompressionVersion - r = encodeChatMessage maxConnInfoLength ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} - case r of - ECMEncoded encodedBody - | shouldCompress -> liftIO $ compressedBatchMsgBody encodedBody - | otherwise -> pure encodedBody + let msg = ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} + case encodeChatMessage maxConnInfoLength msg of + ECMEncoded encodedBody -> case pqSup of + PQSupportOn | maybe False (>= pqEncryptionCompressionVersion) v -> liftIO $ compressedBatchMsgBody encodedBody + _ -> pure encodedBody ECMLarge -> throwChatError $ CEException "large message" where compressedBatchMsgBody msgBody = @@ -6160,7 +6180,7 @@ type MsgReq = (Connection, MsgFlags, MsgBody, MessageId) deliverMessages :: ChatMonad' m => NonEmpty MsgReq -> m (NonEmpty (Either ChatError (Int64, PQEncryption))) deliverMessages msgs = deliverMessagesB $ L.map Right msgs -deliverMessagesB :: ChatMonad' m => NonEmpty (Either ChatError MsgReq) -> m (NonEmpty (Either ChatError (Int64, PQEncryption))) +deliverMessagesB :: forall m. ChatMonad' m => NonEmpty (Either ChatError MsgReq) -> m (NonEmpty (Either ChatError (Int64, PQEncryption))) deliverMessagesB msgReqs = do msgReqs' <- compressBodies sent <- L.zipWith prepareBatch msgReqs' <$> withAgent' (`sendMessagesB` L.map toAgent msgReqs') @@ -6168,18 +6188,10 @@ deliverMessagesB msgReqs = do withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where compressBodies = liftIO $ withCompressCtx (toEnum maxRawMsgLength) $ \cctx -> do - forM msgReqs $ \case - mr@(Right (conn@Connection {pqSupport, pqEncryption, peerChatVRange}, msgFlags, msgBody, msgId)) - | shouldCompress pqSupport pqEncryption -> - Right . (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody - | otherwise -> pure mr - where - --- TODO PQ - -- This version agreement is ephemeral and in case of peer downgrade it will get reduced, and pqSupport may be turned off in the result - -- We probably should store agreed version on Connection and do not allow reducing it. - chatV = maybe currentChatVersion (\(Compatible v') -> v') $ supportedChatVRange pqSupport `compatibleVersion` peerChatVRange - shouldCompress (PQSupport sup) (PQEncryption enc) = sup && (chatV >= pqEncryptionCompressionVersion && enc) - skip -> pure skip + forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion}, msgFlags, msgBody, msgId) -> Right <$> case pqSupport of + PQSupportOn | maybe False (>= pqEncryptionCompressionVersion) connChatVersion -> + (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody + _ -> pure mr toAgent = \case Right (conn@Connection {pqEncryption}, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEncryption, msgFlags, msgBody) Left _ce -> Left (AP.INTERNAL "ChatError, skip") -- as long as it is Left, the agent batchers should just step over it @@ -6449,16 +6461,16 @@ joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () -allowAgentConnectionAsync user conn@Connection {connId, pqSupport} confId msg = do +allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersion} confId msg = do cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn - dm <- encodeConnInfoPQ pqSupport msg + dm <- encodeConnInfoPQ pqSupport connChatVersion msg withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> m (CommandId, ConnId) -agentAcceptContactAsync user enableNtfs invId msg subMode pqSup = do +agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> Maybe VersionChat -> m (CommandId, ConnId) +agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - dm <- encodeConnInfoPQ pqSup msg + dm <- encodeConnInfoPQ pqSup chatV msg connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm pqSup subMode pure (cmdId, connId) @@ -6698,6 +6710,12 @@ chatVersionRange pq = do ChatConfig {chatVRange} <- asks config pure $ chatVRange pq +compatibleChatVersion :: VersionRangeChat -> VersionRangeChat -> Maybe VersionChat +compatibleChatVersion vr vr' = (\(Compatible v) -> v) <$> (vr `compatibleVersion` vr') + +pqCompatibleVersion :: ChatMonad' m => PQSupport -> VersionRangeChat -> m (Maybe VersionChat) +pqCompatibleVersion pq vr' = (`compatibleChatVersion` vr') <$> chatVersionRange pq + chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -6740,8 +6758,9 @@ chatCommandP = "/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), - "/_pq " *> (APISetPQEnabled . PQSupport <$> onOffP), - "/_pq allow " *> (APIAllowContactPQ <$> A.decimal), + "/_pq @" *> (APISetContactPQ <$> A.decimal <* A.space <*> (PQEncryption <$> onOffP)), + "/pq @" *> (SetContactPQ <$> displayName <* A.space <*> (PQEncryption <$> onOffP)), + "/pq " *> (APISetPQEncryption . PQSupport <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 691fe64623..b2d82a0243 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -244,8 +244,9 @@ data ChatCommand | SetRemoteHostsFolder FilePath | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool - | APISetPQEnabled PQSupport - | APIAllowContactPQ ContactId + | APISetPQEncryption PQSupport + | APISetContactPQ ContactId PQEncryption + | SetContactPQ ContactName PQEncryption | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -702,7 +703,7 @@ data ChatResponse | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} - | CRContactPQAllowed {user :: User, contact :: Contact} + | CRContactPQAllowed {user :: User, contact :: Contact, pqEncryption :: PQEncryption} | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index ac93e05533..adb77b9557 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -185,6 +185,8 @@ contactsHelpInfo = indent <> highlight "/verify @ " <> " - clear security code verification", indent <> highlight "/info @ " <> " - info about contact connection", indent <> highlight "/switch @ " <> " - switch receiving messages to another SMP relay", + indent <> highlight "/pq @ on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for a contact", + indent <> " " <> " (both have to enable for quantum resistance)", "", green "Contact chat preferences:", indent <> highlight "/set voice @ yes/no/always " <> " - allow/prohibit voice messages with the contact", @@ -320,6 +322,7 @@ settingsInfo = map styleMarkdown [ green "Chat settings:", + indent <> highlight "/pq on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for the new contacts", indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/smp " <> " - show / set configured SMP servers", indent <> highlight "/xftp " <> " - show / set configured XFTP servers", diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 8640cf23d3..0e95570b85 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -328,8 +328,8 @@ rcvConnEventToText = \case RCERatchetSync syncStatus -> ratchetSyncStatusToText syncStatus RCEVerificationCodeReset -> "security code changed" RCEPqEnabled pqEnc -> case pqEnc of - PQEncOn -> "post-quantum encryption enabled" - PQEncOff -> "post-quantum encryption disabled" + PQEncOn -> "quantum resistant e2e encryption" + PQEncOff -> "standard end-to-end encryption" ratchetSyncStatusToText :: RatchetSyncState -> Text ratchetSyncStatusToText = \case @@ -348,8 +348,8 @@ sndConnEventToText = \case SPCompleted -> "you changed address" <> forMember m SCERatchetSync syncStatus m -> ratchetSyncStatusToText syncStatus <> forMember m SCEPqEnabled pqEnc -> case pqEnc of - PQEncOn -> "post-quantum encryption enabled" - PQEncOff -> "post-quantum encryption disabled" + PQEncOn -> "quantum resistant e2e encryption" + PQEncOff -> "standard end-to-end encryption" where forMember member_ = maybe "" (\GroupMemberRef {profile = Profile {displayName}} -> " for " <> displayName) member_ diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Migrations/M20240228_pq.hs index 1b1c173faa..c496d33b4b 100644 --- a/src/Simplex/Chat/Migrations/M20240228_pq.hs +++ b/src/Simplex/Chat/Migrations/M20240228_pq.hs @@ -8,6 +8,7 @@ import Database.SQLite.Simple.QQ (sql) m20240228_pq :: Query m20240228_pq = [sql| +ALTER TABLE connections ADD COLUMN conn_chat_version INTEGER; ALTER TABLE connections ADD COLUMN pq_support INTEGER NOT NULL DEFAULT 0; ALTER TABLE connections ADD COLUMN pq_encryption INTEGER NOT NULL DEFAULT 0; ALTER TABLE connections ADD COLUMN pq_snd_enabled INTEGER; @@ -21,6 +22,7 @@ down_m20240228_pq = [sql| ALTER TABLE contact_requests DROP COLUMN pq_support; +ALTER TABLE connections DROP COLUMN conn_chat_version; ALTER TABLE connections DROP COLUMN pq_support; ALTER TABLE connections DROP COLUMN pq_encryption; ALTER TABLE connections DROP COLUMN pq_snd_enabled; diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index ea59a94d1f..19c6ba24d0 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -277,6 +277,7 @@ CREATE TABLE connections( peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, contact_conn_initiated INTEGER NOT NULL DEFAULT 0, + conn_chat_version INTEGER, pq_support INTEGER NOT NULL DEFAULT 0, pq_encryption INTEGER NOT NULL DEFAULT 0, pq_snd_enabled INTEGER, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 703850c85d..1af6d676ab 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -46,6 +46,7 @@ import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Util +import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Compression (CompressCtx, compress, decompressBatch) import Simplex.Messaging.Crypto.Ratchet (PQSupport (..), pattern PQSupportOn, pattern PQSupportOff) import Simplex.Messaging.Encoding @@ -55,6 +56,15 @@ import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) +-- Chat version history: +-- 1 - support chat versions in connections (9/1/2023) +-- 2 - create contacts for group members only via x.grp.direct.inv (9/16/2023) +-- 3 - faster joining via group links without creating contact (10/30/2023) +-- 4 - group message forwarding (11/18/2023) +-- 5 - batch sending messages (12/23/2023) +-- 6 - send group welcome message after history (12/29/2023) +-- 7 - update member profiles (1/15/2024) + -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. @@ -64,7 +74,7 @@ currentChatVersion = VersionChat 7 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) -- TODO remove parameterization in 5.7 supportedChatVRange :: PQSupport -> VersionRangeChat -supportedChatVRange pq = mkVersionRange (VersionChat 1) $ case pq of +supportedChatVRange pq = mkVersionRange initialChatVersion $ case pq of PQSupportOn -> pqEncryptionCompressionVersion PQSupportOff -> currentChatVersion {-# INLINE supportedChatVRange #-} @@ -97,6 +107,11 @@ memberProfileUpdateVersion = VersionChat 7 pqEncryptionCompressionVersion :: VersionChat pqEncryptionCompressionVersion = VersionChat 8 +agentToChatVersion :: VersionSMPA -> VersionChat +agentToChatVersion v + | v < pqdrSMPAgentVersion = initialChatVersion + | otherwise = pqEncryptionCompressionVersion + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 311dba6579..3bb4f3bf45 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -61,7 +61,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, - peer_chat_min_version, peer_chat_max_version + conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? |] diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 3128908225..d3806fe34c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -125,14 +125,14 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> PQSupport -> ExceptT StoreError IO Contact -createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode pqSup = do - PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode pqSup +createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO Contact +createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode chatV pqSup = do + PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode chatV pqSup liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId) getContact db user contactId -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> PQSupport -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode pqSup = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -142,12 +142,12 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, - created_at, updated_at, to_subscribe, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup, pqSup) + :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -179,7 +179,7 @@ getContactByConnReqHash db user@User {userId} cReqHash = -- 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id @@ -189,8 +189,8 @@ getContactByConnReqHash db user@User {userId} cReqHash = |] (userId, cReqHash, CSActive) -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> PQSupport -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode pqSup = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let contactConnInitiated = pccConnStatus == ConnNew @@ -199,11 +199,11 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile [sql| INSERT INTO connections (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, - created_at, updated_at, to_subscribe, pq_support, pq_encryption) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, pqSup, pqSup) + :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -582,7 +582,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers -- 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version 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 @@ -709,8 +709,8 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO Contact -createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed = do +createAcceptedContact :: DB.Connection -> User -> ConnId -> Maybe VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO Contact +createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId connChatVersion cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case @@ -722,7 +722,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "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 pqSup + conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId connChatVersion cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode pqSup let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn 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} @@ -747,7 +747,7 @@ getContact_ db user@User {userId} contactId deleted = -- 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version 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 @@ -801,7 +801,7 @@ getContactConnections db userId Contact {contactId} = SELECT 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? @@ -819,7 +819,7 @@ getConnectionById db User {userId} connId = ExceptT $ do SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, - peer_chat_min_version, peer_chat_max_version + conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? |] diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 6192f5eda4..0965150605 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -432,7 +432,8 @@ lookupChatRefByFileId db User {userId} fileId = createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection createSndFileConnection_ db userId fileId agentConnId subMode = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff + -- TODO PQ use range from minVersion of the current range? + createConnection_ db userId ConnSndFile (Just fileId) agentConnId (Just initialChatVersion) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4a87893e58..3b81d5b242 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -186,7 +186,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId (Just initialChatVersion) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = @@ -197,7 +197,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = SELECT 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? @@ -283,7 +283,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -691,7 +691,7 @@ groupMemberQuery = 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.connection_id = ( @@ -785,11 +785,11 @@ getGroupInvitation db vr user groupId = createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName -createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {peerChatVRange}} memberRole agentConnId connRequest subMode = +createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {connChatVersion, peerChatVRange}} memberRole agentConnId connRequest subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode pure member where VersionRange minV maxV = peerChatVRange @@ -832,13 +832,13 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, :. (minV, maxV) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode = +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> Maybe VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () +createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode setCommandConnId db user cmdId connId where VersionRange minV maxV = peerChatVRange @@ -889,16 +889,17 @@ createAcceptedMember :. (minV, maxV) ) -createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> Maybe VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () createAcceptedMemberConnection db user@User {userId} (cmdId, agentConnId) + chatV UserContactRequest {cReqChatVRange, userContactLinkId} groupMemberId subMode = do createdAt <- liftIO getCurrentTime - Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatV cReqChatVRange Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact @@ -928,15 +929,15 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) -createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRangeChat -> SubscriptionMode -> IO () -createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange subMode = do +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> Maybe VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () +createMemberConnection db userId GroupMember {groupMemberId} agentConnId chatV peerChatVRange subMode = do currentTs <- getCurrentTime - void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode + void $ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode -createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRangeChat -> SubscriptionMode -> IO () -createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange subMode = do +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> Maybe VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () +createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) chatV peerChatVRange subMode = do currentTs <- getCurrentTime - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode setCommandConnId db user cmdId connId updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () @@ -1202,12 +1203,13 @@ getForwardInvitedMembers db user forwardMember highlyAvailable = do WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> Maybe VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} + chatV memInfo@(MemberInfo _ _ memChatVRange memberProfile) memRestrictions_ (groupCmdId, groupAgentConnId) @@ -1220,7 +1222,7 @@ createIntroReMember currentTs <- liftIO getCurrentTime newMember <- case directConnIds of Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId chatV mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff liftIO $ setCommandConnId db user directCmdId directConnId (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) @@ -1230,18 +1232,18 @@ createIntroReMember pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs subMode + conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode liftIO $ setCommandConnId db user groupCmdId groupConnId pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () -createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> Maybe VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () +createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- getCurrentTime - Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs subMode + Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId chatV mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId chatV mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff setCommandConnId db user directCmdId directConnId contactId <- createMemberContact_ directConnId currentTs updateMember_ contactId currentTs @@ -1271,9 +1273,9 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = |] [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact connLevel currentTs subMode = - createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection +createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = + createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = @@ -1297,7 +1299,7 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -1882,7 +1884,7 @@ createMemberContact cReq gInfo GroupMember {groupMemberId, localDisplayName, memberProfile, memberContactProfileId} - Connection {connLevel, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} + Connection {connLevel, connChatVersion, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} subMode = do currentTs <- getCurrentTime let incognitoProfile = incognitoMembershipProfile gInfo @@ -1909,11 +1911,11 @@ createMemberContact [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, - peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, True, contactId, customUserProfileId) - :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db let ctConn = @@ -1921,6 +1923,7 @@ createMemberContact { connId, agentConnId = AgentConnId acId, peerChatVRange, + connChatVersion, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, @@ -2030,7 +2033,7 @@ createMemberContactConn_ user@User {userId} (cmdId, acId) gInfo - _memberConn@Connection {connLevel, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} + _memberConn@Connection {connLevel, connChatVersion, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} contactId subMode = do currentTs <- liftIO getCurrentTime @@ -2040,11 +2043,11 @@ createMemberContactConn_ [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, - peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) - :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db setCommandConnId db user cmdId connId @@ -2052,6 +2055,7 @@ createMemberContactConn_ Connection { connId, agentConnId = AgentConnId acId, + connChatVersion, peerChatVRange, connType = ConnContact, contactConnInitiated = False, diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 06029b913d..0d47982aca 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -324,7 +324,7 @@ createUserContactLink db User {userId} agentConnId cReq subMode = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId (Just initialChatVersion) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] getUserAddressConnections db User {userId} = do @@ -340,7 +340,7 @@ getUserAddressConnections db User {userId} = do SELECT 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL @@ -356,7 +356,7 @@ getUserContactLinks db User {userId} = SELECT 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.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 1a5b41be68..0650dd23de 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -151,15 +151,16 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, VersionChat, VersionChat) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Maybe VersionChat, VersionChat, VersionChat) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe VersionChat, Maybe VersionChat) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, minVer, maxVer)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, connChatVersion, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, + connChatVersion, -- TODO we could avoid maybe here by computing compatible version, but it would require passing current version range here as well peerChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer, connLevel, viaContact, @@ -189,12 +190,12 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, minVer, maxVer)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, connChatVersion, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, connChatVersion, minVer, maxVer)) toMaybeConnection _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection -createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode pqSup = do +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection +createConnection_ db userId connType entityId acId connChatVersion peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode pqSup = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) let viaGroupLink = isJust viaLinkGroupId @@ -204,18 +205,19 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (minV, maxV, subMode == SMOnlyCreate, pqSup, pqSup) + :. (connChatVersion, minV, maxV, subMode == SMOnlyCreate, pqSup, pqSup) ) connId <- insertedRowId db pure Connection { connId, agentConnId = AgentConnId acId, + connChatVersion, peerChatVRange, connType, contactConnInitiated = False, @@ -250,8 +252,8 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag (displayName, fullName, image, userId, Just True, createdAt, createdAt) insertedRowId db -updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> IO () -updateConnSupportPQ db connId pqSup = +updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO () +updateConnSupportPQ db connId pqSup pqEnc = DB.execute db [sql| @@ -259,7 +261,7 @@ updateConnSupportPQ db connId pqSup = SET pq_support = ?, pq_encryption = ? WHERE connection_id = ? |] - (pqSup, pqSup, connId) + (pqSup, pqEnc, connId) updateConnPQSndEnabled :: DB.Connection -> Int64 -> PQEncryption -> IO () updateConnPQSndEnabled db connId pqSndEnabled = @@ -294,16 +296,16 @@ updateConnPQEnabledCON db connId pqEnabled = |] (pqEnabled, pqEnabled, connId) -setPeerChatVRange :: DB.Connection -> Int64 -> VersionRangeChat -> IO () -setPeerChatVRange db connId (VersionRange minVer maxVer) = +setPeerChatVRange :: DB.Connection -> Int64 -> Maybe VersionChat -> VersionRangeChat -> IO () +setPeerChatVRange db connId chatV (VersionRange minVer maxVer) = DB.execute db [sql| UPDATE connections - SET peer_chat_min_version = ?, peer_chat_max_version = ? + SET conn_chat_version = ?, peer_chat_min_version = ?, peer_chat_max_version = ? WHERE connection_id = ? |] - (minVer, maxVer, connId) + (chatV, minVer, maxVer, connId) setMemberChatVRange :: DB.Connection -> GroupMemberId -> VersionRangeChat -> IO () setMemberChatVRange db mId (VersionRange minVer maxVer) = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d4a3a8029e..18fe5ad1ac 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -23,7 +23,7 @@ module Simplex.Chat.Types where import Crypto.Number.Serialize (os2ip) -import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) +import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ @@ -1291,6 +1291,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, + connChatVersion :: Maybe VersionChat, peerChatVRange :: VersionRangeChat, connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d953b9183f..8aa917f044 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -342,8 +342,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] CRRemoteCtrlStopped {} -> ["remote controller stopped"] - CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": post-quantum encryption " <> (if pqOn then "enabled" else "disabled")] - CRContactPQAllowed u c -> ttyUser u [ttyContact' c <> ": post-quantum encryption allowed"] + CRContactPQAllowed u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": enable " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption"] + CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = @@ -1177,7 +1177,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] - <> ["post-quantum encryption enabled" | contactPQEnabled ct == CR.PQEncOn] + <> ["quantum resistant end-to-end encryption" | contactPQEnabled ct == CR.PQEncOn] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index cc1a7791b0..f50c82878f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -21,11 +21,11 @@ import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) -import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion, supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQSupportOff, pattern PQEncOn, pattern PQEncOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQSupportOn, pattern PQSupportOff, pattern PQEncOn, pattern PQEncOff) import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) @@ -131,7 +131,8 @@ chatDirectTests = do describe "PQ tests" $ do describe "enable PQ before connection, connect via invitation link" $ pqMatrix2 runTestPQConnectViaLink describe "enable PQ before connection, connect via contact address" $ pqMatrix2 runTestPQConnectViaAddress - it "should enable PQ after several messages in connection without PQ" testPQAllowContact + it "should enable PQ after several messages in connection without PQ" testPQEnableContact + it "should enable PQ, reduce envelope size and enable compression" testPQEnableContactCompression where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -2784,7 +2785,7 @@ runTestPQConnectViaLink (alice, aPQ) (bob, bPQ) = do pqOn :: TestCC -> IO () pqOn cc = do - cc ##> "/_pq on" + cc ##> "/pq on" cc <## "ok" runTestPQConnectViaAddress :: HasCallStack => (TestCC, PQEnabled) -> (TestCC, PQEnabled) -> IO () @@ -2820,8 +2821,8 @@ runTestPQConnectViaAddress (alice, aPQ) (bob, bPQ) = do pqSend = if pqEnabled then (+#>) else (\#>) e2eeInfo = if pqEnabled then e2eeInfoPQStr else e2eeInfoNoPQStr -testPQAllowContact :: HasCallStack => FilePath -> IO () -testPQAllowContact = +testPQEnableContact :: HasCallStack => FilePath -> IO () +testPQEnableContact = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob (alice, "hi") \#> bob @@ -2853,15 +2854,15 @@ testPQAllowContact = PQEncOff <- bob `pqForContact` 2 -- if only one contact allows PQ, it's not enabled - alice ##> "/_pq allow 2" - alice <## "bob: post-quantum encryption allowed" + alice ##> "/pq @bob on" + alice <## "bob: enable quantum resistant end-to-end encryption" sendMany PQEncOff alice bob PQEncOff <- alice `pqForContact` 2 PQEncOff <- bob `pqForContact` 2 -- both contacts have to allow PQ to enable it - bob ##> "/_pq allow 2" - bob <## "alice: post-quantum encryption allowed" + bob ##> "/pq @alice on" + bob <## "alice: enable quantum resistant end-to-end encryption" (alice, "1") \#> bob (bob, "2") \#> alice @@ -2875,16 +2876,16 @@ testPQAllowContact = (bob, "6") ++#> alice -- equivalent to: -- bob `send` "@alice 6" - -- bob <## "alice: post-quantum encryption enabled" + -- bob <## "alice: quantum resistant end-to-end encryption enabled" -- bob <# "@alice 6" - -- alice <## "bob: post-quantum encryption enabled" + -- alice <## "bob: quantum resistant end-to-end encryption enabled" -- alice <# "bob> 6" PQEncOn <- alice `pqForContact` 2 - alice #$> ("/_get chat @2 count=2", chat, [(0, "post-quantum encryption enabled"), (0, "6")]) + alice #$> ("/_get chat @2 count=2", chat, [(0, e2eeInfoPQStr), (0, "6")]) PQEncOn <- bob `pqForContact` 2 - bob #$> ("/_get chat @2 count=2", chat, [(1, "post-quantum encryption enabled"), (1, "6")]) + bob #$> ("/_get chat @2 count=2", chat, [(1, e2eeInfoPQStr), (1, "6")]) (alice, "6") +#> bob (bob, "7") +#> alice @@ -2894,8 +2895,43 @@ testPQAllowContact = PQEncOn <- alice `pqForContact` 2 PQEncOn <- bob `pqForContact` 2 pure () + +sendMany :: PQEncryption -> TestCC -> TestCC -> IO () +sendMany pqEnc alice bob = + forM_ [(1 :: Int) .. 10] $ \i -> do + sndRcv pqEnc False (alice, show i) bob + sndRcv pqEnc False (bob, show i) alice + +testPQEnableContactCompression :: HasCallStack => FilePath -> IO () +testPQEnableContactCompression = + testChat2 aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + (alice, "hi") \#> bob + (bob, "hey") \#> alice + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + (alice, "lrg 1", v) \:#> (bob, v) + (bob, "lrg 2", v) \:#> (alice, v) + PQSupportOff <- alice `pqSupportForCt` 2 + alice ##> "/pq @bob on" + alice <## "bob: enable quantum resistant end-to-end encryption" + PQSupportOn <- alice `pqSupportForCt` 2 + (alice, "lrg 3", v) \:#> (bob, v) + (bob, "lrg 4", v) \:#> (alice, v) + PQSupportOff <- bob `pqSupportForCt` 2 + bob ##> "/pq @alice on" + bob <## "alice: enable quantum resistant end-to-end encryption" + PQSupportOn <- bob `pqSupportForCt` 2 + (alice, "lrg 1", v) \:#> (bob, v') + (bob, "lrg 2", v') \:#> (alice, v') + (alice, "lrg 3", v') \:#> (bob, v') + (bob, "lrg 4", v') \:#> (alice, v') + (alice, "lrg 5", v') +:#> (bob, v') + PQEncOff <- alice `pqForContact` 2 + PQEncOff <- bob `pqForContact` 2 + (bob, "lrg 6", v') ++:#> (alice, v') + (alice, "lrg 7", v') +:#> (bob, v') + (bob, "lrg 8", v') +:#> (alice, v') where - sendMany pqEnc alice bob = - forM_ [(1 :: Int) .. 10] $ \i -> do - sndRcv pqEnc False (alice, show i) bob - sndRcv pqEnc False (bob, show i) alice + v = currentChatVersion + v' = pqEncryptionCompressionVersion diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 387dd95dd9..21993d11ef 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -13,6 +13,7 @@ import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM import Control.Monad (unless, when) import Control.Monad.Except (runExceptT) +import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as B import Data.Char (isDigit) import Data.List (isPrefixOf, isSuffixOf) @@ -31,7 +32,8 @@ import Simplex.Chat.Types.Preferences import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) @@ -201,13 +203,41 @@ sndRcv pqEnc enabled (cc1, msg) cc2 = do name2 <- userName cc2 let cmd = "@" <> name2 <> " " <> msg cc1 `send` cmd - when enabled $ cc1 <## (name2 <> ": post-quantum encryption enabled") + when enabled $ cc1 <## (name2 <> ": quantum resistant end-to-end encryption enabled") cc1 <# cmd cc1 `pqSndForContact` 2 `shouldReturn` pqEnc - when enabled $ cc2 <## (name1 <> ": post-quantum encryption enabled") + when enabled $ cc2 <## (name1 <> ": quantum resistant end-to-end encryption enabled") cc2 <# (name1 <> "> " <> msg) cc2 `pqRcvForContact` 2 `shouldReturn` pqEnc +(\:#>) :: HasCallStack => (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +(\:#>) = sndRcvImg PQEncOff False + +(+:#>) :: HasCallStack => (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +(+:#>) = sndRcvImg PQEncOn False + +(++:#>) :: HasCallStack => (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +(++:#>) = sndRcvImg PQEncOn True + +sndRcvImg :: HasCallStack => PQEncryption -> Bool -> (TestCC, String, VersionChat) -> (TestCC, VersionChat) -> IO () +sndRcvImg pqEnc enabled (cc1, msg, v1) (cc2, v2) = do + name1 <- userName cc1 + name2 <- userName cc2 + g <- C.newRandom + img <- atomically $ B64.encode <$> C.randomBytes lrgLen g + cc1 `send` ("/_send @2 json {\"msgContent\":{\"type\":\"image\",\"text\":\"" <> msg <> "\",\"image\":\"" <> B.unpack img <> "\"}}") + cc1 .<## "}}" + when enabled $ cc1 <## (name2 <> ": quantum resistant end-to-end encryption enabled") + cc1 <# ("@" <> name2 <> " " <> msg) + cc1 `pqSndForContact` 2 `shouldReturn` pqEnc + cc1 `pqVerForContact` 2 `shouldReturn` v1 + when enabled $ cc2 <## (name1 <> ": quantum resistant end-to-end encryption enabled") + cc2 <# (name1 <> "> " <> msg) + cc2 `pqRcvForContact` 2 `shouldReturn` pqEnc + cc2 `pqVerForContact` 2 `shouldReturn` v2 + where + lrgLen = maxEncodedMsgLength PQSupportOff * 3 `div` 4 - 98 -- this is max size for binary image preview given the rest of the message + -- PQ combinators / chat :: String -> [(Int, String)] @@ -518,19 +548,25 @@ getProfilePictureByName cc displayName = DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName) pqSndForContact :: TestCC -> ContactId -> IO PQEncryption -pqSndForContact = pqForContact_ pqSndEnabled +pqSndForContact = pqForContact_ pqSndEnabled PQEncOff pqRcvForContact :: TestCC -> ContactId -> IO PQEncryption -pqRcvForContact = pqForContact_ pqRcvEnabled +pqRcvForContact = pqForContact_ pqRcvEnabled PQEncOff pqForContact :: TestCC -> ContactId -> IO PQEncryption -pqForContact = pqForContact_ (Just . connPQEnabled) +pqForContact = pqForContact_ (Just . connPQEnabled) PQEncOff -pqForContact_ :: (Connection -> Maybe PQEncryption) -> TestCC -> ContactId -> IO PQEncryption -pqForContact_ pqSel cc contactId = - getTestCCContact cc contactId >>= \ct -> case contactConn ct of - Just conn -> pure $ fromMaybe PQEncOff $ pqSel conn - Nothing -> fail "no connection" +pqSupportForCt :: TestCC -> ContactId -> IO PQSupport +pqSupportForCt = pqForContact_ (\Connection {pqSupport} -> Just pqSupport) PQSupportOff + +pqVerForContact :: TestCC -> ContactId -> IO VersionChat +pqVerForContact = pqForContact_ connChatVersion (VersionChat 0) + +pqForContact_ :: (Connection -> Maybe a) -> a -> TestCC -> ContactId -> IO a +pqForContact_ pqSel def cc contactId = (fromMaybe def . pqSel) <$> getCtConn cc contactId + +getCtConn :: TestCC -> ContactId -> IO Connection +getCtConn cc contactId = getTestCCContact cc contactId >>= maybe (fail "no connection") pure . contactConn getTestCCContact :: TestCC -> ContactId -> IO Contact getTestCCContact cc contactId = From 8660bf420a46b3c1fac250ed527c86f4b55d5514 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sun, 10 Mar 2024 18:57:57 +0400 Subject: [PATCH 41/64] core (pq): cross-version tests (#3885) * core (pq): cross-version tests * next (fails) * enable all tests * fix versions * update simplexmq * tests --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- tests/ChatClient.hs | 34 ++++++++++++++--- tests/ChatTests/Direct.hs | 79 ++++++++++++++++++++++++++++++++++++++- tests/ChatTests/Utils.hs | 34 ++++++++++++++++- 5 files changed, 141 insertions(+), 10 deletions(-) diff --git a/cabal.project b/cabal.project index 05e9af91ff..4fccb51694 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: dab55e0a9b03577f643af7922afa061801d82ed5 + tag: 851ed2d02e2a78c15893ad8bc9c5a4d917eb6a35 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7ca6c8c074..b2e11db33a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."dab55e0a9b03577f643af7922afa061801d82ed5" = "0dzqsvzxby83nla0rpx3xzj2y18lvmgs5ldjv5i1yp52npc88s1m"; + "https://github.com/simplex-chat/simplexmq.git"."851ed2d02e2a78c15893ad8bc9c5a4d917eb6a35" = "0rm13iknnqhdb42nmyjc2wj85z23p337bp026ihnychax5s1216j"; "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/tests/ChatClient.hs b/tests/ChatClient.hs index 691a4101a7..792f9642d3 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -28,6 +28,7 @@ import Simplex.Chat import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..)) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Terminal @@ -37,12 +38,13 @@ import Simplex.FileTransfer.Description (kb, mb) import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration) import Simplex.Messaging.Agent.Env.SQLite -import Simplex.Messaging.Agent.Protocol (supportedSMPAgentVRange, pattern VersionSMPA) +import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) -import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange, pattern PQSupportOff, pattern VersionE2E) +import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange, pattern PQSupportOff) +import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport @@ -147,22 +149,38 @@ testAgentCfgVPrev = smpCfg = (smpCfg testAgentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg testAgentCfg} } +testAgentCfgVNext :: AgentConfig +testAgentCfgVNext = + testAgentCfg + { smpClientVRange = nextRange $ smpClientVRange testAgentCfg, + smpAgentVRange = \_ -> mkVersionRange duplexHandshakeSMPAgentVersion $ max pqdrSMPAgentVersion currentSMPAgentVersion, + e2eEncryptVRange = \_ -> mkVersionRange CR.kdfX3DHE2EEncryptVersion $ max CR.pqRatchetE2EEncryptVersion CR.currentE2EEncryptVersion, + smpCfg = (smpCfg testAgentCfg) {serverVRange = nextRange $ serverVRange $ smpCfg testAgentCfg} + } + testAgentCfgV1 :: AgentConfig testAgentCfgV1 = testAgentCfg { smpClientVRange = v1Range, - smpAgentVRange = \_ -> versionToRange (VersionSMPA 2), -- duplexHandshakeSMPAgentVersion, - e2eEncryptVRange = \_ -> versionToRange (VersionE2E 2), -- kdfX3DHE2EEncryptVersion, + smpAgentVRange = \_ -> versionToRange duplexHandshakeSMPAgentVersion, + e2eEncryptVRange = \_ -> versionToRange CR.kdfX3DHE2EEncryptVersion, smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange batchCmdsSMPVersion} } testCfgVPrev :: ChatConfig testCfgVPrev = testCfg - { chatVRange = prevRange . chatVRange testCfg, + { chatVRange = \_ -> prevRange $ chatVRange testCfg PQSupportOff, agentConfig = testAgentCfgVPrev } +testCfgVNext :: ChatConfig +testCfgVNext = + testCfg + { chatVRange = \_ -> mkVersionRange initialChatVersion $ max pqEncryptionCompressionVersion currentChatVersion, + agentConfig = testAgentCfgVNext + } + testCfgV1 :: ChatConfig testCfgV1 = testCfg @@ -173,12 +191,18 @@ testCfgV1 = prevRange :: VersionRange v -> VersionRange v prevRange vr = vr {maxVersion = max (minVersion vr) (prevVersion $ maxVersion vr)} +nextRange :: VersionRange v -> VersionRange v +nextRange vr = vr {maxVersion = max (minVersion vr) (nextVersion $ maxVersion vr)} + v1Range :: VersionRange v v1Range = mkVersionRange (Version 1) (Version 1) prevVersion :: Version v -> Version v prevVersion (Version v) = Version (v - 1) +nextVersion :: Version v -> Version v +nextVersion (Version v) = Version (v + 1) + testCfgCreateGroupDirect :: ChatConfig testCfgCreateGroupDirect = mkCfgCreateGroupDirect testCfg diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index f50c82878f..c80323b114 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -23,9 +23,9 @@ import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion, supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) -import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) +import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, VersionChat, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQSupportOn, pattern PQSupportOff, pattern PQEncOn, pattern PQEncOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) @@ -131,6 +131,8 @@ chatDirectTests = do describe "PQ tests" $ do describe "enable PQ before connection, connect via invitation link" $ pqMatrix2 runTestPQConnectViaLink describe "enable PQ before connection, connect via contact address" $ pqMatrix2 runTestPQConnectViaAddress + describe "connect via invitation link with PQ encryption enabled" $ pqVersionTestMatrix2 runTestPQVersionsViaLink + describe "connect via contact address with PQ encryption enabled" $ pqVersionTestMatrix2 runTestPQVersionsViaAddress it "should enable PQ after several messages in connection without PQ" testPQEnableContact it "should enable PQ, reduce envelope size and enable compression" testPQEnableContactCompression where @@ -2821,6 +2823,79 @@ runTestPQConnectViaAddress (alice, aPQ) (bob, bPQ) = do pqSend = if pqEnabled then (+#>) else (\#>) e2eeInfo = if pqEnabled then e2eeInfoPQStr else e2eeInfoNoPQStr +runTestPQVersionsViaLink :: HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO () +runTestPQVersionsViaLink alice bob pqExpected vExpected = do + img <- genProfileImgForLink + let profileImage = "data:image/png;base64," <> B.unpack img + alice `send` ("/set profile image " <> profileImage) + _trimmedCmd1 <- getTermLine alice + alice <## "profile image updated" + bob `send` ("/set profile image " <> profileImage) + _trimmedCmd2 <- getTermLine bob + bob <## "profile image updated" + + pqOn alice + pqOn bob + + connectUsers alice bob + + (alice, "hi", vExpected) `pqSend` (bob, vExpected) + (bob, "hey", vExpected) `pqSend` (alice, vExpected) + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + where + pqSend = if pqExpected then (+:#>) else (\:#>) + e2eeInfo = if pqExpected then e2eeInfoPQStr else e2eeInfoNoPQStr + +runTestPQVersionsViaAddress :: HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO () +runTestPQVersionsViaAddress alice bob pqExpected vExpected = do + img <- genProfileImgForAddress + let profileImage = "data:image/png;base64," <> B.unpack img + alice `send` ("/set profile image " <> profileImage) + _trimmedCmd1 <- getTermLine alice + alice <## "profile image updated" + bob `send` ("/set profile image " <> profileImage) + _trimmedCmd2 <- getTermLine bob + bob <## "profile image updated" + + pqOn alice + pqOn bob + + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + (alice, "hi", vExpected) `pqSend` (bob, vExpected) + (bob, "hey", vExpected) `pqSend` (alice, vExpected) + + alice ##> "/_get chat @2 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, e2eeInfo)] + alice `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + + bob ##> "/_get chat @2 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, e2eeInfo)] + bob `pqForContact` 2 `shouldReturn` PQEncryption pqExpected + where + pqSend = if pqExpected then (+:#>) else (\:#>) + e2eeInfo = if pqExpected then e2eeInfoPQStr else e2eeInfoNoPQStr + testPQEnableContact :: HasCallStack => FilePath -> IO () testPQEnableContact = testChat2 aliceProfile bobProfile $ \alice bob -> do diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 21993d11ef..16a75377fd 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -13,6 +13,7 @@ import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM import Control.Monad (unless, when) import Control.Monad.Except (runExceptT) +import Data.ByteString (ByteString) import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as B import Data.Char (isDigit) @@ -128,6 +129,23 @@ pqMatrix2 runTest = do where test aPQ bPQ = testChat2 aliceProfile bobProfile $ \a b -> runTest (a, aPQ) (b, bPQ) +pqVersionTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO ()) -> SpecWith FilePath +pqVersionTestMatrix2 runTest = do + it "current" $ testChat2 aliceProfile bobProfile (runTest' True pqEncryptionCompressionVersion) + it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile (runTest' False (VersionChat 6)) + it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest' False (VersionChat 6)) + it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest' False (VersionChat 6)) + it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile (runTest' False (VersionChat 1)) + it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest' False (VersionChat 1)) + it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest' False (VersionChat 1)) + it "next" $ testChatCfg2 testCfgVNext aliceProfile bobProfile (runTest' True pqEncryptionCompressionVersion) + it "next to curr" $ runTestCfg2 testCfg testCfgVNext (runTest' True pqEncryptionCompressionVersion) + it "curr to next" $ runTestCfg2 testCfgVNext testCfg (runTest' True pqEncryptionCompressionVersion) + it "next to prev" $ runTestCfg2 testCfgVPrev testCfgVNext (runTest' False (VersionChat 6)) + it "prev to next" $ runTestCfg2 testCfgVNext testCfgVPrev (runTest' False (VersionChat 6)) + where + runTest' pqExpected v a b = runTest a b pqExpected v + withTestChatGroup3Connected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatGroup3Connected tmp dbPrefix action = do withTestChat tmp dbPrefix $ \cc -> do @@ -236,7 +254,21 @@ sndRcvImg pqEnc enabled (cc1, msg, v1) (cc2, v2) = do cc2 `pqRcvForContact` 2 `shouldReturn` pqEnc cc2 `pqVerForContact` 2 `shouldReturn` v2 where - lrgLen = maxEncodedMsgLength PQSupportOff * 3 `div` 4 - 98 -- this is max size for binary image preview given the rest of the message + lrgLen = maxEncodedMsgLength PQSupportOff * 3 `div` 4 - 110 -- 98 is ~ max size for binary image preview given the rest of the message + +genProfileImgForLink :: IO ByteString +genProfileImgForLink = do + g <- C.newRandom + atomically $ B64.encode <$> C.randomBytes lrgLen g + where + lrgLen = maxConnInfoLength PQSupportOff * 3 `div` 4 - 240 -- 214 is the magic number to make tests pass (10737) + +genProfileImgForAddress :: IO ByteString +genProfileImgForAddress = do + g <- C.newRandom + atomically $ B64.encode <$> C.randomBytes lrgLen g + where + lrgLen = maxConnInfoLength PQSupportOff * 3 `div` 4 - 260 -- 238 is the magic number to make tests pass (10713) -- PQ combinators / From 49bd866c4ba69b13bdf37388bacb14d0655f54d4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 10 Mar 2024 20:52:29 +0000 Subject: [PATCH 42/64] core: pass version range to determine missing connection version (#3887) * core: pass version range function to store methods * pass current version to Connection to determine agreed version with peer * simplify --- src/Simplex/Chat.hs | 343 +++++++++++++------------- src/Simplex/Chat/Store/Connections.hs | 11 +- src/Simplex/Chat/Store/Direct.hs | 70 +++--- src/Simplex/Chat/Store/Files.hs | 34 +-- src/Simplex/Chat/Store/Groups.hs | 217 ++++++++-------- src/Simplex/Chat/Store/Messages.hs | 41 +-- src/Simplex/Chat/Store/Profiles.hs | 21 +- src/Simplex/Chat/Store/Shared.hs | 27 +- src/Simplex/Chat/Types.hs | 9 +- tests/ChatTests/Utils.hs | 9 +- 10 files changed, 397 insertions(+), 385 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 068d3197fa..74eb38f57f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -367,12 +367,11 @@ startChatController mainApp = do subscribeUsers :: forall m. ChatMonad' m => Bool -> [User] -> m () subscribeUsers onlyNeeded users = do let (us, us') = partition activeUser users - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange subscribe vr us subscribe vr us' where - subscribe :: VersionRangeChat -> [User] -> m () + subscribe :: (PQSupport -> VersionRangeChat) -> [User] -> m () subscribe vr = mapM_ $ runExceptT . subscribeUserConnections vr onlyNeeded Agent.subscribeConnections startFilesToReceive :: forall m. ChatMonad' m => [User] -> m () @@ -451,11 +450,10 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand cmd = - -- TODO PQ this is only used to set membership version range (?) - chatVersionRange PQSupportOff >>= (`processChatCommand'` cmd) + chatVersionRange >>= (`processChatCommand'` cmd) {-# INLINE processChatCommand #-} -processChatCommand' :: forall m. ChatMonad m => VersionRangeChat -> ChatCommand -> m ChatResponse +processChatCommand' :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> ChatCommand -> m ChatResponse processChatCommand' vr = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser NewUser {profile, sameServers, pastTimestamp} -> do @@ -598,7 +596,7 @@ processChatCommand' vr = \case SetContactMergeEnabled onOff -> chatWriteVar contactMergeEnabled onOff >> ok_ APISetPQEncryption onOff -> chatWriteVar pqExperimentalEnabled onOff >> ok_ APISetContactPQ ctId pqEnc -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user ctId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user ctId case activeConn of Just conn@Connection {connId, pqSupport, pqEncryption} | pqEncryption == pqEnc -> pure $ CRContactPQAllowed user ct pqEnc @@ -645,7 +643,7 @@ processChatCommand' vr = \case APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do - directChat <- withStore (\db -> getDirectChat db user cId pagination search) + directChat <- withStore (\db -> getDirectChat db vr user cId pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) CTGroup -> do groupChat <- withStore (\db -> getGroupChat db vr user cId pagination search) @@ -671,7 +669,7 @@ processChatCommand' vr = \case pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> withChatLock "sendMessage" $ case cType of CTDirect -> do - ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db user chatId + ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgNew_ unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct if isVoice mc && not (featureAllowed SCFVoice forUser ct) @@ -769,7 +767,7 @@ processChatCommand' vr = \case pure . CRNewChatItem user $ AChatItem SCTLocal SMDSnd (LocalChat nf) ci APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of CTDirect -> do - ct@Contact {contactId} <- withStore $ \db -> getContact db user chatId + ct@Contact {contactId} <- withStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withStore $ \db -> getDirectCIWithReactions db user ct itemId case cci of @@ -827,7 +825,7 @@ processChatCommand' vr = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do - (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do @@ -864,7 +862,7 @@ processChatCommand' vr = \case (_, _) -> throwChatError CEInvalidChatItemDelete APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> withChatLock "chatItemReaction" $ case cType of CTDirect -> - withStore (\db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId) >>= \case + withStore (\db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId) >>= \case (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (featureAllowed SCFReactions forUser ct) $ throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) @@ -941,7 +939,7 @@ processChatCommand' vr = \case APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withStore $ \db -> do - ct <- getContact db user chatId + ct <- getContact db vr user chatId liftIO $ updateContactUnreadChat db user ct unreadChat ok user CTGroup -> do @@ -957,14 +955,14 @@ processChatCommand' vr = \case _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withStore $ \db -> getContact db user chatId + ct <- withStore $ \db -> getContact db vr user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo let doSendDel = contactReady ct && contactActive ct && notify when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchChatError` const (pure ()) - contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct) + contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db vr userId ct) deleteAgentConnectionsAsync' user contactConnIds doSendDel -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) @@ -1005,7 +1003,7 @@ processChatCommand' vr = \case where deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId])) deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do - ct <- getContact db user contactId + ct <- getContact db vr user contactId ifM ((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct)) (pure (Nothing, [])) @@ -1013,14 +1011,14 @@ processChatCommand' vr = \case where getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId]) getConnections ct = do - conns <- liftIO $ getContactConnections db userId ct + conns <- liftIO $ getContactConnections db vr userId ct e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just) pure (e_, map aConnId conns) CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withStore $ \db -> getContact db user chatId + ct <- withStore $ \db -> getContact db vr user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo @@ -1032,7 +1030,7 @@ processChatCommand' vr = \case cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo withStore' $ \db -> deleteGroupCIs db user gInfo - membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) CTLocal -> do @@ -1062,7 +1060,7 @@ processChatCommand' vr = \case pure $ CRContactRequestRejected user cReq APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId assertDirectAllowed user MDSnd ct XCallInv_ if featureAllowed SCFCalls forUser ct then do @@ -1146,7 +1144,7 @@ processChatCommand' vr = \case _ -> Nothing rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do user <- getUserByContactId db contactId - contact <- getContact db user contactId + contact <- getContact db vr user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} APIGetNetworkStatuses -> withUser $ \_ -> CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses @@ -1155,11 +1153,11 @@ processChatCommand' vr = \case updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) APISetContactPrefs contactId prefs' -> withUser $ \user -> do - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId updateContactPrefs user ct prefs' APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do ct' <- withStore $ \db -> do - ct <- getContact db user contactId + ct <- getContact db vr user contactId liftIO $ updateContactAlias db userId ct localAlias pure $ CRContactAliasUpdated user ct' APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do @@ -1234,7 +1232,7 @@ processChatCommand' vr = \case APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> do - ct <- getContact db user chatId + ct <- getContact db vr user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct forM_ (contactConnId ct) $ \connId -> @@ -1252,13 +1250,13 @@ processChatCommand' vr = \case APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withStore $ \db -> do liftIO $ updateGroupMemberSettings db user gId gMemberId settings - getGroupMember db user gId gMemberId + getGroupMember db vr user gId gMemberId let ntfOn = showMessages $ memberSettings m toggleNtf user m ntfOn ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId incognitoProfile <- case activeConn of Nothing -> pure Nothing Just Connection {customUserProfileId} -> @@ -1269,39 +1267,39 @@ processChatCommand' vr = \case (g, s) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> liftIO (getGroupSummary db user gId) pure $ CRGroupInfo user g s APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) pure $ CRGroupMemberInfo user g m connectionStats APISwitchContact contactId -> withUser $ \user -> do - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> switchConnectionAsync a "" connId pure $ CRContactSwitchStarted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APISwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent (\a -> switchConnectionAsync a "" connId) pure $ CRGroupMemberSwitchStarted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APIAbortSwitchContact contactId -> withUser $ \user -> do - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRContactSwitchAborted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APIAbortSwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do - ct <- withStore $ \db -> getContact db user contactId + ct <- withStore $ \db -> getContact db vr user contactId case contactConn ct of Just conn@Connection {pqSupport} -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) pqSupport force @@ -1309,7 +1307,7 @@ processChatCommand' vr = \case pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withChatLock "syncGroupMemberRatchet" $ do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force @@ -1317,7 +1315,7 @@ processChatCommand' vr = \case pure $ CRGroupMemberRatchetSyncStarted user g m cStats _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1331,7 +1329,7 @@ processChatCommand' vr = \case pure $ CRContactCode user ct' code Nothing -> throwChatError $ CEContactNotActive ct APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do - (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1345,24 +1343,24 @@ processChatCommand' vr = \case pure $ CRGroupMemberCode user g m' code _ -> throwChatError CEGroupMemberNotActive APIVerifyContact contactId code -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId case activeConn of Just conn -> verifyConnectionCode user conn code Nothing -> throwChatError $ CEContactNotActive ct APIVerifyGroupMember gId gMemberId code -> withUser $ \user -> do - GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId + GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user gId gMemberId case activeConn of Just conn -> verifyConnectionCode user conn code _ -> throwChatError CEGroupMemberNotActive APIEnableContact contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn} <- withStore $ \db -> getContact db vr user contactId case activeConn of Just conn -> do withStore' $ \db -> setConnectionAuthErrCounter db user conn 0 ok user Nothing -> throwChatError $ CEContactNotActive ct APIEnableGroupMember gId gMemberId -> withUser $ \user -> do - GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId + GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user gId gMemberId case activeConn of Just conn -> do withStore' $ \db -> setConnectionAuthErrCounter db user conn 0 @@ -1373,7 +1371,7 @@ processChatCommand' vr = \case SetShowMemberMessages gName mName showMessages -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName gInfo <- withStore $ \db -> getGroupInfo db vr user gId - m <- withStore $ \db -> getGroupMember db user gId mId + m <- withStore $ \db -> getGroupMember db vr user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} @@ -1437,7 +1435,7 @@ processChatCommand' vr = \case -- TODO PQ the error above should be CEIncompatibleConnReqVersion, also the same API should be called in Plan Just (agentV, pqSup') -> do let chatV = agentToChatVersion agentV - dm <- encodeConnInfoPQ pqSup' (Just chatV) $ XInfo profileToSend + dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup' subMode conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode chatV pqSup' pure $ CRSentConfirmation user conn @@ -1452,7 +1450,7 @@ processChatCommand' vr = \case _ -> processChatCommand $ APIConnect userId incognito aCReqUri Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do - ct@Contact {activeConn, profile = LocalProfile {contactLink}} <- withStore $ \db -> getContact db user contactId + ct@Contact {activeConn, profile = LocalProfile {contactLink}} <- withStore $ \db -> getContact db vr user contactId when (isJust activeConn) $ throwChatError (CECommandError "contact already has connection") case contactLink of Just cReq -> connectContactViaAddress user incognito ct cReq @@ -1468,7 +1466,7 @@ processChatCommand' vr = \case DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> - CRContactsList user <$> withStore' (`getUserContacts` user) + CRContactsList user <$> withStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do @@ -1480,7 +1478,7 @@ processChatCommand' vr = \case CreateMyAddress -> withUser $ \User {userId} -> processChatCommand $ APICreateMyAddress userId APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do - conns <- withStore (`getUserAddressConnections` user) + conns <- withStore $ \db -> getUserAddressConnections db vr user withChatLock "deleteMyAddress" $ do deleteAgentConnectionsAsync user $ map aConnId conns withStore' (`deleteUserAddress` user) @@ -1546,7 +1544,7 @@ processChatCommand' vr = \case _ -> throwChatError $ CECommandError "not supported" SendMemberContactMessage gName mName msg -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName - m <- withStore $ \db -> getGroupMember db user gId mId + m <- withStore $ \db -> getGroupMember db vr user gId mId let mc = MCText msg case memberContactId m of Nothing -> do @@ -1565,7 +1563,7 @@ processChatCommand' vr = \case let mc = MCText msg processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do - contacts <- withStore' (`getUserContacts` user) + contacts <- withStore' $ \db -> getUserContacts db vr user let cts = filter (\ct -> contactReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do @@ -1617,7 +1615,7 @@ processChatCommand' vr = \case processChatCommand $ APINewGroup userId incognito gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db user contactId + (group, contact) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId assertDirectAllowed user MDSnd contact XGrpInv_ let Group gInfo members = group Contact {localDisplayName = cName} = contact @@ -1648,7 +1646,7 @@ processChatCommand' vr = \case withChatLock "joinGroup" . procCmd $ do (invitation, ct) <- withStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId - (inv,) <$> getContactViaMember db user fromMember + (inv,) <$> getContactViaMember db vr user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct @@ -1657,7 +1655,7 @@ processChatCommand' vr = \case subMode <- chatReadVar subscriptionMode dm <- encodeConnInfo $ XGrpAcpt membershipMemId agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm PQSupportOff subMode - let chatV = vr `compatibleChatVersion` peerChatVRange + let chatV = vr PQSupportOff `peerConnChatVersion` peerChatVRange withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId chatV peerChatVRange subMode updateGroupMemberStatus db userId fromMember GSMemAccepted @@ -1681,7 +1679,7 @@ processChatCommand' vr = \case withStore' $ \db -> updateGroupMemberRole db user m memRole case mStatus of GSMemInvited -> do - withStore (\db -> (,) <$> mapM (getContact db user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case + withStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName _ -> do @@ -1707,7 +1705,7 @@ processChatCommand' vr = \case toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) bm' <- withStore $ \db -> do liftIO $ updateGroupMemberBlocked db user groupId memberId mrs - getGroupMember db user groupId memberId + getGroupMember db vr user groupId memberId toggleNtf user bm' (not blocked) pure CRMemberBlockedForAllUser {user, groupInfo = gInfo, member = bm', blocked} where @@ -1773,7 +1771,7 @@ processChatCommand' vr = \case APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> CRGroupsList user <$> withStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do - ct_ <- forM cName_ $ \cName -> withStore $ \db -> getContactByName db user cName + ct_ <- forM cName_ $ \cName -> withStore $ \db -> getContactByName db vr user cName processChatCommand $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do g <- withStore $ \db -> getGroup db vr user groupId @@ -1812,7 +1810,7 @@ processChatCommand' vr = \case (_, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo groupLink mRole APICreateMemberContact gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor unless (groupFeatureAllowed SGFDirectMessages g) $ throwChatError $ CECommandError "direct messages not allowed" case memberConn m of @@ -1935,7 +1933,7 @@ processChatCommand' vr = \case withStore (\db -> liftIO $ lookupChatRefByFileId db user fileId) >>= \case Nothing -> pure () Just (ChatRef CTDirect contactId) -> do - (contact, sharedMsgId) <- withStore $ \db -> (,) <$> getContact db user contactId <*> getSharedMsgIdByFileId db userId fileId + (contact, sharedMsgId) <- withStore $ \db -> (,) <$> getContact db vr user contactId <*> getSharedMsgIdByFileId db userId fileId void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId Just (ChatRef CTGroup groupId) -> do (Group gInfo ms, sharedMsgId) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getSharedMsgIdByFileId db userId fileId @@ -1992,7 +1990,7 @@ processChatCommand' vr = \case let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user} updateProfile user p SetContactFeature (ACF f) cName allowed_ -> withUser $ \user -> do - ct@Contact {userPreferences} <- withStore $ \db -> getContactByName db user cName + ct@Contact {userPreferences} <- withStore $ \db -> getContactByName db vr user cName let prefs' = setPreference f allowed_ $ Just userPreferences updateContactPrefs user ct prefs' SetGroupFeature (AGF f) gName enabled -> @@ -2004,7 +2002,7 @@ processChatCommand' vr = \case p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference' SCFTimedMessages (Just pref) $ preferences' user} updateProfile user p SetContactTimedMessages cName timedMessagesEnabled_ -> withUser $ \user -> do - ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withStore $ \db -> getContactByName db user cName + ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withStore $ \db -> getContactByName db vr user cName let currentTTL = timedMessages >>= \TimedMessagesPreference {ttl} -> ttl pref_ = tmeToPref currentTTL <$> timedMessagesEnabled_ prefs' = setPreference' SCFTimedMessages pref_ $ Just userPreferences @@ -2149,7 +2147,7 @@ processChatCommand' vr = \case case groupLinkId of -- contact address Nothing -> - withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + withStore' (\db -> getConnReqContactXContactId db vr user cReqHash) >>= \case (Just contact, _) -> pure $ CRContactAlreadyExists user contact (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> drgRandomBytes 16 @@ -2157,7 +2155,7 @@ processChatCommand' vr = \case connect' Nothing cReqHash xContactId False -- group link Just gLinkId -> - withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + withStore' (\db -> getConnReqContactXContactId db vr user cReqHash) >>= \case (Just _contact, _) -> procCmd $ do -- allow repeat contact request newXContactId <- XContactId <$> drgRandomBytes 16 @@ -2179,7 +2177,7 @@ processChatCommand' vr = \case pqSup <- chatReadVar pqExperimentalEnabled (connId, incognitoProfile, subMode, chatV) <- requestContact user incognito cReq newXContactId False pqSup let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct' <- withStore $ \db -> createAddressContactConnection db user ct connId cReqHash newXContactId incognitoProfile subMode chatV pqSup + ct' <- withStore $ \db -> createAddressContactConnection db vr user ct connId cReqHash newXContactId incognitoProfile subMode chatV pqSup pure $ CRSentInvitationToContact user ct' incognitoProfile requestContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> XContactId -> Bool -> PQSupport -> m (ConnId, Maybe Profile, SubscriptionMode, VersionChat) requestContact user incognito cReq xContactId inGroup pqSup = do @@ -2193,7 +2191,7 @@ processChatCommand' vr = \case Nothing -> throwChatError CEInvalidConnReq Just (agentV, _) -> do let chatV = agentToChatVersion agentV - dm <- encodeConnInfoPQ pqSup (Just chatV) (XContact profileToSend $ Just xContactId) + dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend $ Just xContactId) subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm pqSup subMode pure (connId, incognitoProfile, subMode, chatV) @@ -2216,7 +2214,7 @@ processChatCommand' vr = \case | otherwise = do when (n /= n') $ checkValidName n' -- read contacts before user update to correctly merge preferences - contacts <- withStore' (`getUserContacts` user) + contacts <- withStore' $ \db -> getUserContacts db vr user user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" . procCmd $ do @@ -2309,7 +2307,7 @@ processChatCommand' vr = \case withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do user <- getUserByContactId db ctId - (user,) <$> getContact db user ctId + (user,) <$> getContact db vr user ctId calls <- asks currentCalls withChatLock "currentCall" $ atomically (TM.lookup ctId calls) >>= \case @@ -2426,7 +2424,7 @@ processChatCommand' vr = \case (chatId, chatSettings) <- case cType of CTDirect -> withStore $ \db -> do ctId <- getContactIdByName db user name - Contact {chatSettings} <- getContact db user ctId + Contact {chatSettings} <- getContact db vr user ctId pure (ctId, chatSettings) CTGroup -> withStore $ \db -> do @@ -2467,7 +2465,7 @@ processChatCommand' vr = \case Nothing -> withStore' (\db -> getContactConnEntityByConnReqHash db vr user cReqHashes) >>= \case Nothing -> - withStore' (\db -> getContactWithoutConnViaAddress db user cReqSchemas) >>= \case + withStore' (\db -> getContactWithoutConnViaAddress db vr user cReqSchemas) >>= \case Nothing -> pure $ CPContactAddress CAPOk Just ct -> pure $ CPContactAddress (CAPContactViaAddress ct) Just (RcvDirectMsgConnection _conn Nothing) -> pure $ CPContactAddress CAPConnectingConfirmReconnect @@ -2757,8 +2755,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI unless (fileStatus == RFSNew) $ case fileStatus of RFSCancelled _ -> throwChatError $ CEFileCancelled fName _ -> throwChatError $ CEFileAlreadyReceiving fName - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange case (xftpRcvFile, fileConnReq) of -- direct file protocol (Nothing, Just connReq) -> do @@ -2783,10 +2780,10 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI chatRef <- withStoreCtx (Just "acceptFileReceive, getChatRefByFileId") $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of (ChatRef CTDirect contactId, Nothing) -> do - ct <- withStoreCtx (Just "acceptFileReceive, getContact") $ \db -> getContact db user contactId + ct <- withStoreCtx (Just "acceptFileReceive, getContact") $ \db -> getContact db vr user contactId acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId, Just memId) -> do - GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db user groupId memId + GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db vr user groupId memId case activeConn of Just conn -> do acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMemberMessage conn msg groupId @@ -2797,8 +2794,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI acceptFile cmdFunction send = do filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange if | inline -> do -- accepting inline @@ -2845,8 +2841,7 @@ receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile startReceivingFile :: ChatMonad m => User -> FileTransferId -> m () startReceivingFile user fileId = do - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange ci <- withStoreCtx (Just "startReceivingFile, updateRcvFileStatus ...") $ \db -> do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 @@ -2890,10 +2885,11 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> m Contact acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId, pqSupport} incognitoProfile contactUsed = do subMode <- chatReadVar subscriptionMode - let profileToSend = profileToSendOnAccept user incognitoProfile False pqSup <- chatReadVar pqExperimentalEnabled - let pqSup' = pqSup `CR.pqSupportAnd` pqSupport - chatV <- pqCompatibleVersion pqSup cReqChatVRange + vr <- chatVersionRange + let profileToSend = profileToSendOnAccept user incognitoProfile False + chatV = vr pqSup `peerConnChatVersion` cReqChatVRange + pqSup' = pqSup `CR.pqSupportAnd` pqSupport dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend acId <- withAgent $ \a -> acceptContact a True invId dm pqSup' subMode withStore' $ \db -> createAcceptedContact db user acId chatV cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode pqSup' contactUsed @@ -2902,7 +2898,8 @@ acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False - chatV <- chatReadVar pqExperimentalEnabled >>= (`pqCompatibleVersion` cReqChatVRange) + vr <- chatVersionRange + chatV <- (\pq -> vr pq `peerConnChatVersion` cReqChatVRange) <$> chatReadVar pqExperimentalEnabled (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup chatV withStore' $ \db -> do ct@Contact {activeConn} <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed @@ -2931,11 +2928,12 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - chatV <- chatReadVar pqExperimentalEnabled >>= (`pqCompatibleVersion` cReqChatVRange) + vr <- chatVersionRange + chatV <- (\pq -> vr pq `peerConnChatVersion` cReqChatVRange) <$> chatReadVar pqExperimentalEnabled connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do liftIO $ createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode - getGroupMemberById db user groupMemberId + getGroupMemberById db vr user groupMemberId profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Bool -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing @@ -2946,12 +2944,14 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> deleteGroupLink' :: ChatMonad m => User -> GroupInfo -> m () deleteGroupLink' user gInfo = do - conn <- withStore $ \db -> getGroupLinkConnection db user gInfo + vr <- chatVersionRange + conn <- withStore $ \db -> getGroupLinkConnection db vr user gInfo deleteGroupLink_ user gInfo conn deleteGroupLinkIfExists :: ChatMonad m => User -> GroupInfo -> m () deleteGroupLinkIfExists user gInfo = do - conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db user gInfo) + vr <- chatVersionRange + conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db vr user gInfo) mapM_ (deleteGroupLink_ user gInfo) conn_ deleteGroupLink_ :: ChatMonad m => User -> GroupInfo -> Connection -> m () @@ -2980,7 +2980,7 @@ agentSubscriber = do type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ())) -subscribeUserConnections :: forall m. ChatMonad m => VersionRangeChat -> Bool -> AgentBatchSubscribe m -> User -> m () +subscribeUserConnections :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> Bool -> AgentBatchSubscribe m -> User -> m () subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = do -- get user connections ce <- asks $ subscriptionEvents . config @@ -3036,12 +3036,12 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = } getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do - cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts + cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") (`getUserContacts` vr) let cts' = mapMaybe (\ct -> (,ct) <$> contactConnId ct) $ filter contactActive cts pure (map fst cts', M.fromList cts') getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do - (cs, ucs) <- unzip <$> withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContactLinks") getUserContactLinks + (cs, ucs) <- unzip <$> withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContactLinks") (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) @@ -3181,7 +3181,8 @@ cleanupManager = do timedItems <- withStoreCtx' (Just "cleanupManager, getTimedItems") $ \db -> getTimedItems db user startTimedThreadCutoff forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchChatError` const (pure ()) cleanupDeletedContacts user = do - contacts <- withStore' (`getDeletedContacts` user) + vr <- chatVersionRange + contacts <- withStore' $ \db -> getDeletedContacts db vr user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) `catchChatError` (toView . CRChatError (Just user)) @@ -3221,11 +3222,10 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do ts <- liftIO getCurrentTime liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts waitChatStartedAndActivated - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange case cType of CTDirect -> do - (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId deleteDirectCI user ct ci True True >>= toView CTGroup -> do (gInfo, CChatItem _ ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId @@ -3243,17 +3243,16 @@ startUpdatedTimedItemThread user chatRef ci ci' = expireChatItems :: forall m. ChatMonad m => User -> Int64 -> Bool -> m () expireChatItems user@User {userId} ttl sync = do currentTs <- liftIO getCurrentTime - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs waitChatStartedAndActivated - contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") (`getUserContacts` user) + contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") $ \db -> getUserContacts db vr user loop contacts $ processContact expirationDate waitChatStartedAndActivated - groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (\db -> getUserGroupDetails db vr user Nothing Nothing) - loop groups $ processGroup expirationDate createdAtCutoff + groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") $ \db -> getUserGroupDetails db vr user Nothing Nothing + loop groups $ processGroup vr expirationDate createdAtCutoff where loop :: [a] -> (a -> m ()) -> m () loop [] _ = pure () @@ -3275,14 +3274,14 @@ expireChatItems user@User {userId} ttl sync = do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate - processGroup :: UTCTime -> UTCTime -> GroupInfo -> m () - processGroup expirationDate createdAtCutoff gInfo = do + processGroup :: (PQSupport -> VersionRangeChat) -> UTCTime -> UTCTime -> GroupInfo -> m () + processGroup vr expirationDate createdAtCutoff gInfo = do waitChatStartedAndActivated filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo + membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db vr user gInfo forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () @@ -3291,8 +3290,7 @@ processAgentMessage _ connId (DEL_RCVQ srv qId err_) = processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = do - -- TODO PQ this is only used to set membership version range (?) - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange withStore' (`getUserByAConnId` AgentConnId connId) >>= \case Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) @@ -3331,8 +3329,7 @@ processAgentMsgSndFile _corrId aFileId msg = (ft@FileTransferMeta {fileId, xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> do fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId getSndFileTransfer db user fileId - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange unless cancelled $ case msg of SFPROG sndProgress sndTotal -> do let status = CIFSSndTransfer {sndProgress, sndTotal} @@ -3369,7 +3366,7 @@ processAgentMsgSndFile _corrId aFileId msg = withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId withAgent (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do - ms <- withStore' $ \db -> getGroupMembers db user g + ms <- withStore' $ \db -> getGroupMembers db vr user g let rfdsMemberFTs = zip rfds $ memberFTs ms extraRFDs = drop (length rfdsMemberFTs) rfds withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) @@ -3418,7 +3415,7 @@ processAgentMsgSndFile _corrId aFileId msg = case L.nonEmpty fds of Just fds' -> loopSend fds' Nothing -> pure msgDeliveryId - sendFileError :: Text -> Int64 -> VersionRangeChat -> FileTransferMeta -> m () + sendFileError :: Text -> Int64 -> (PQSupport -> VersionRangeChat) -> FileTransferMeta -> m () sendFileError err fileId vr ft = do logError $ "Sent file error: " <> err ci <- withStore $ \db -> do @@ -3453,8 +3450,7 @@ processAgentMsgRcvFile _corrId aFileId msg = ft@RcvFileTransfer {fileId} <- withStore $ \db -> do fileId <- getXFTPRcvFileDBId db $ AgentRcvFileId aFileId getRcvFileTransfer db user fileId - -- TODO PQ this is only used to set membership version range - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange unless (rcvFileCompleteOrCancelled ft) $ case msg of RFPROG rcvProgress rcvTotal -> do let status = CIFSRcvTransfer {rcvProgress, rcvTotal} @@ -3485,7 +3481,7 @@ processAgentMsgRcvFile _corrId aFileId msg = agentXFTPDeleteRcvFile aFileId fileId toView $ CRRcvFileError user ci e ft -processAgentMessageConn :: forall m. ChatMonad m => VersionRangeChat -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () +processAgentMessageConn :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of @@ -3773,7 +3769,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv hostConnId (Just directConnReq) XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do - ct <- withStore $ \db -> getContactViaMember db user m + ct <- withStore $ \db -> getContactViaMember db vr user m withStore' $ \db -> setNewContactMemberConnRequest db user m cReq groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId @@ -3857,7 +3853,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem - members <- withStore' $ \db -> getGroupMembers db user gInfo + members <- withStore' $ \db -> getGroupMembers db vr user gInfo void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m sendIntroductions members when (groupFeatureAllowed SGFHistory gInfo) sendHistory @@ -3867,7 +3863,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = profileToSend = profileToSendOnAccept user profileMode True void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId sendIntroductions members = do - intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m + intros <- withStore' $ \db -> createIntroductions db (maxVersion $ vr PQSupportOff) members m shuffledIntros <- liftIO $ shuffleIntros intros if m `supportsVersion` batchSendVersion then do @@ -3971,7 +3967,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure msgForwardEvents _ -> do let memCategory = memberCategory m - withStore' (\db -> getViaGroupContact db user m) >>= \case + withStore' (\db -> getViaGroupContact db vr user m) >>= \case Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership @@ -3988,12 +3984,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemCon = \case GCPreMember -> forM_ (invitedByGroupMemberId membership) $ \hostId -> do - host <- withStore $ \db -> getGroupMember db user groupId hostId + host <- withStore $ \db -> getGroupMember db vr user groupId hostId forM_ (memberConn host) $ \hostConn -> void $ sendDirectMemberMessage hostConn (XGrpMemCon memberId) groupId GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do - im <- withStore $ \db -> getGroupMember db user groupId invitingMemberId + im <- withStore $ \db -> getGroupMember db vr user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" @@ -4066,10 +4062,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- members introduced to this invited member introducedMembers <- if memberCategory m == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db user m highlyAvailable + then withStore' $ \db -> getForwardIntroducedMembers db vr user m highlyAvailable else pure [] -- invited members to which this member was introduced - invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db user m highlyAvailable + invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user m highlyAvailable let GroupMember {memberId} = m ms = forwardedToGroupMembers (introducedMembers <> invitedMembers) chatMsg' msg = XGrpMsgForward memberId chatMsg' brokerTs @@ -4218,13 +4214,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fileInvConnReq@(CRInvitationUri _ _) -> case cmdFunction of -- [async agent commands] direct XFileAcptInv continuation on receiving INV CFCreateConnFileInvDirect -> do - ct <- withStore $ \db -> getContactByFileId db user fileId + ct <- withStore $ \db -> getContactByFileId db vr user fileId sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId void $ sendDirectContactMessage user ct (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) -- [async agent commands] group XFileAcptInv continuation on receiving INV CFCreateConnFileInvGroup -> case grpMemberId of Just gMemberId -> do - GroupMember {groupId, activeConn} <- withStore $ \db -> getGroupMemberById db user gMemberId + GroupMember {groupId, activeConn} <- withStore $ \db -> getGroupMemberById db vr user gMemberId case activeConn of Just gMemberConn -> do sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId @@ -4315,7 +4311,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> m () profileContactRequest invId chatVRange p xContactId_ reqPQSup = do - withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case + withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId @@ -4452,9 +4448,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendProbe probe cs <- if doProbeContacts - then map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + then map COMContact <$> withStore' (\db -> getMatchingContacts db vr user ct) else pure [] - ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) + ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db vr user ct) sendProbeHashes (cs <> ms) probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where @@ -4470,7 +4466,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ COMGroupMember m sendProbe probe - cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db user m) + cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db vr user m) sendProbeHashes cs probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where @@ -4676,7 +4672,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = - withStore' (\db -> getCIModeration db user gInfo memberId sharedMsgId_) >>= \case + withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case Just ciModeration -> do applyModeration ciModeration withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ @@ -4848,7 +4844,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode dm <- encodeConnInfo XOk connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndDirectFTConnection db user fileId connIds subMode + withStore' $ \db -> createSndDirectFTConnection db vr user fileId connIds subMode -- receiving inline _ -> do event <- withStore $ \db -> do @@ -4945,7 +4941,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability dm <- encodeConnInfo XOk connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m subMode + withStore' $ \db -> createSndGroupFileTransferConnection db vr user fileId connIds m subMode (_, Just conn) -> do -- receiving inline event <- withStore $ \db -> do @@ -5012,7 +5008,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if directOrUsed c then do ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted - contactConns <- withStore' $ \db -> getContactConnections db userId ct' + contactConns <- withStore' $ \db -> getContactConnections db vr userId ct' deleteAgentConnectionsAsync user $ map aConnId contactConns forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} @@ -5021,7 +5017,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) toView $ CRContactDeletedByContact user ct'' else do - contactConns <- withStore' $ \db -> getContactConnections db userId c + contactConns <- withStore' $ \db -> getContactConnections db vr userId c deleteAgentConnectionsAsync user $ map aConnId contactConns withStore $ \db -> deleteContact db user c where @@ -5092,7 +5088,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do - mCt <- withStore $ \db -> getContact db user mContactId + mCt <- withStore $ \db -> getContact db vr user mContactId if canUpdateProfile mCt then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' @@ -5133,7 +5129,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm2)) $ do - cgm1s <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe + cgm1s <- withStore' $ \db -> matchReceivedProbe db vr user cgm2 probe let cgm1s' = filter (not . contactOrMemberIncognito) cgm1s probeMatches cgm1s' cgm2 where @@ -5149,7 +5145,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do - cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db user cgm1 probeHash + cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db vr user cgm1 probeHash forM_ cgm2Probe_ $ \(cgm2, probe) -> unless (contactOrMemberIncognito cgm2) . void $ probeMatch cgm1 cgm2 probe @@ -5181,7 +5177,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoProbeOk :: ContactOrMember -> Probe -> m () xInfoProbeOk cgm1 probe = do - cgm2 <- withStore' $ \db -> matchSentProbe db user cgm1 probe + cgm2 <- withStore' $ \db -> matchSentProbe db vr user cgm1 probe case cgm1 of COMContact c1@Contact {contactId = cId1} -> case cgm2 of @@ -5317,7 +5313,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing where merge c1' c2' = do - c2'' <- withStore $ \db -> mergeContactRecords db user c1' c2' + c2'' <- withStore $ \db -> mergeContactRecords db vr user c1' c2' toView $ CRContactsMerged user c1' c2' c2'' when (directOrUsed c2'') $ showSecurityCodeChanged c2'' pure $ Just c2'' @@ -5362,7 +5358,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = associateContactWithMember :: GroupMember -> Contact -> m Contact associateContactWithMember m1@GroupMember {groupId} c2 = do - c2' <- withStore $ \db -> associateContactWithMemberRecord db user m1 c2 + c2' <- withStore $ \db -> associateContactWithMemberRecord db vr user m1 c2 g <- withStore $ \db -> getGroupInfo db vr user groupId toView $ CRContactAndMemberAssociated user c2 g m1 c2' pure c2' @@ -5388,9 +5384,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msg brokerTs = do checkHostRole m memRole unless (sameMemberId memId $ membership gInfo) $ - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db user m unknownMember memInfo + updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo toView $ CRUnknownMemberAnnounced user gInfo m unknownMember updatedMember memberAnnouncedToView updatedMember Right _ -> messageError "x.grp.mem.new error: member already exists" @@ -5408,7 +5404,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do case memberCategory m of GCHostMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right _ -> messageError "x.grp.mem.intro ignored: member already exists" Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) @@ -5421,7 +5417,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | maxVersion mcvr >= groupDirectInvVersion -> pure Nothing | otherwise -> Just <$> createConn subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - chatV = (vr `compatibleChatVersion` ) . fromChatVRange =<< memChatVRange + vr' = vr PQSupportOff + chatV = maybe (minVersion vr') (\peerVR -> vr' `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" where @@ -5429,7 +5426,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do - hostConn <- withStore $ \db -> getConnectionById db user hostConnId + hostConn <- withStore $ \db -> getConnectionById db vr user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} void $ sendDirectMemberMessage hostConn msg groupId withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited @@ -5438,7 +5435,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemInv gInfo@GroupInfo {groupId} m memId introInv = do case memberCategory m of GCInviteeMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv @@ -5452,7 +5449,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent -- the situation when member does not exist is an error -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. @@ -5469,7 +5466,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memChatVRange - chatV = vr `compatibleChatVersion` mcvr + chatV = vr PQSupportOff `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> m () @@ -5478,7 +5475,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole Left _ -> messageError "x.grp.mem.role with unknown member ID" where @@ -5507,7 +5504,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- member shouldn't receive this message about themselves messageError "x.grp.mem.restrict: admin blocks you" | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} | senderRole < GRAdmin || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do @@ -5526,12 +5523,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = setMemberBlocked bmId = withStore $ \db -> do liftIO $ updateGroupMemberBlocked db user groupId bmId restriction - getGroupMember db user groupId bmId + getGroupMember db vr user groupId bmId blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> m () xGrpMemCon gInfo sendingMember memId = do - refMember <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memId + refMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId case (memberCategory sendingMember, memberCategory refMember) of (GCInviteeMember, GCInviteeMember) -> withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case @@ -5575,13 +5572,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then checkRole membership $ do deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history - members <- withStore' $ \db -> getGroupMembers db user gInfo + members <- withStore' $ \db -> getGroupMembers db vr user gInfo deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m else - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.del with unknown member ID" Right member@GroupMember {groupMemberId, memberProfile} -> checkRole member $ do @@ -5613,7 +5610,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner ms <- withStore' $ \db -> do - members <- getGroupMembers db user gInfo + members <- getGroupMembers db vr user gInfo updateGroupMemberStatus db userId membership GSMemGroupDeleted pure members -- member records are not deleted to keep history @@ -5642,7 +5639,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberContactId of Nothing -> createNewContact subMode Just mContactId -> do - mCt <- withStore $ \db -> getContact db user mContactId + mCt <- withStore $ \db -> getContact db vr user mContactId let Contact {activeConn, contactGrpInvSent} = mCt forM_ activeConn $ \Connection {connId} -> if contactGrpInvSent @@ -5685,7 +5682,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> m () xGrpMsgForward gInfo@GroupInfo {groupId} m@GroupMember {memberRole, localDisplayName} memberId msg msgTs = do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memberId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Right author -> processForwardedMsg author msg Left (SEGroupMemberNotFoundByMemberId _) -> do unknownAuthor <- createUnknownMember gInfo memberId @@ -5846,13 +5843,11 @@ updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, pure (mem {memberChatVRange = msgVRange, activeConn = Just conn'}, conn') else pure (mem, conn) -upgradedConnVersion :: ChatMonad' m => PQSupport -> Maybe VersionChat -> VersionRangeChat -> m (Maybe VersionChat) -upgradedConnVersion pqSup v_ vr = do - v_' <- pqCompatibleVersion pqSup vr - pure $ case (v_, v_') of - (Just v, Just v') -> Just $ max v v' - (Nothing, v'@Just {}) -> v' - (v, Nothing) -> v +upgradedConnVersion :: ChatMonad' m => PQSupport -> VersionChat -> VersionRangeChat -> m VersionChat +upgradedConnVersion pqSup v peerVR = do + vr <- chatVersionRange + -- don't allow reducing agreed connection version + pure $ maybe v (\(Compatible v') -> max v v') $ vr pqSup `compatibleVersion` peerVR parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = @@ -5894,7 +5889,7 @@ parseChatMessage conn s = do sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do - vr <- chatVersionRange PQSupportOff + vr <- chatVersionRange withStore' (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo Nothing -> do @@ -6017,7 +6012,8 @@ cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, age deleteSndFileChunks db ft when sendCancel $ case fileInline of Just _ -> do - (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db user connId + vr <- chatVersionRange + (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db vr user connId void $ sendDirectMessage_ conn PQSupportOff (BFileChunk sharedMsgId FileChunkCancel) (ConnectionId connId) _ -> withAgent $ \a -> void . sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunkCancel pure fileConnId @@ -6096,7 +6092,7 @@ createSndMessage chatMsgEvent connOrGroupId pqSup = createSndMessages :: forall e m t. (MsgEncodingI e, ChatMonad' m, Traversable t) => t (ConnOrGroupId, PQSupport, ChatMsgEvent e) -> m (t (Either ChatError SndMessage)) createSndMessages idsEvents = do g <- asks random - ChatConfig {chatVRange = vr} <- asks config + vr <- chatVersionRange withStoreBatch $ \db -> fmap (createMsg db g vr) idsEvents where createMsg :: DB.Connection -> TVar ChaChaDRG -> (PQSupport -> VersionRangeChat) -> (ConnOrGroupId, PQSupport, ChatMsgEvent e) -> IO (Either ChatError SndMessage) @@ -6147,17 +6143,17 @@ batchSndMessagesJSON = batchMessages maxRawMsgLength . L.toList encodeConnInfo :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString encodeConnInfo chatMsgEvent = do - vr <- chatVersionRange PQSupportOff - encodeConnInfoPQ PQSupportOff (Just $ maxVersion vr) chatMsgEvent + vr <- chatVersionRange + encodeConnInfoPQ PQSupportOff (maxVersion $ vr PQSupportOff) chatMsgEvent -- TODO PQ check size after compression (in compressedBatchMsgBody_ ?) -encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> Maybe VersionChat -> ChatMsgEvent e -> m ByteString +encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> VersionChat -> ChatMsgEvent e -> m ByteString encodeConnInfoPQ pqSup v chatMsgEvent = do - chatVRange <- chatVersionRange pqSup - let msg = ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} + vr <- chatVersionRange + let msg = ChatMessage {chatVRange = vr pqSup, msgId = Nothing, chatMsgEvent} case encodeChatMessage maxConnInfoLength msg of ECMEncoded encodedBody -> case pqSup of - PQSupportOn | maybe False (>= pqEncryptionCompressionVersion) v -> liftIO $ compressedBatchMsgBody encodedBody + PQSupportOn | v >= pqEncryptionCompressionVersion -> liftIO $ compressedBatchMsgBody encodedBody _ -> pure encodedBody ECMLarge -> throwChatError $ CEException "large message" where @@ -6189,7 +6185,7 @@ deliverMessagesB msgReqs = do where compressBodies = liftIO $ withCompressCtx (toEnum maxRawMsgLength) $ \cctx -> do forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion}, msgFlags, msgBody, msgId) -> Right <$> case pqSupport of - PQSupportOn | maybe False (>= pqEncryptionCompressionVersion) connChatVersion -> + PQSupportOn | connChatVersion >= pqEncryptionCompressionVersion -> (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody _ -> pure mr toAgent = \case @@ -6337,7 +6333,8 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) `catchChatError` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId + vr <- chatVersionRange + fm <- withStore $ \db -> getGroupMember db vr user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e @@ -6352,7 +6349,8 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) `catchChatError` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId + vr <- chatVersionRange + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGroupMemberId if sameMemberId refMemberId am then forM_ (memberConn forwardingMember) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId @@ -6467,7 +6465,7 @@ allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersi withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> Maybe VersionChat -> m (CommandId, ConnId) +agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> VersionChat -> m (CommandId, ConnId) agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact dm <- encodeConnInfoPQ pqSup chatV msg @@ -6705,16 +6703,11 @@ waitChatStartedAndActivated = do activated <- readTVar chatActivated unless (isJust started && activated) retry -chatVersionRange :: ChatMonad' m => PQSupport -> m VersionRangeChat -chatVersionRange pq = do +chatVersionRange :: ChatMonad' m => m (PQSupport -> VersionRangeChat) +chatVersionRange = do ChatConfig {chatVRange} <- asks config - pure $ chatVRange pq - -compatibleChatVersion :: VersionRangeChat -> VersionRangeChat -> Maybe VersionChat -compatibleChatVersion vr vr' = (\(Compatible v) -> v) <$> (vr `compatibleVersion` vr') - -pqCompatibleVersion :: ChatMonad' m => PQSupport -> VersionRangeChat -> m (Maybe VersionChat) -pqCompatibleVersion pq vr' = (`compatibleChatVersion` vr') <$> chatVersionRange pq + pure chatVRange +{-# INLINE chatVersionRange #-} chatCommandP :: Parser ChatCommand chatCommandP = diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 3bb4f3bf45..f8e9fa3401 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -34,9 +34,10 @@ import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Util (eitherToMaybe) -getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity +getConnectionEntity :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ case entityId of @@ -54,7 +55,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do where getConnection_ :: ExceptT StoreError IO Connection getConnection_ = ExceptT $ do - firstRow toConnection (SEConnectionNotFound agentConnId) $ + firstRow (toConnection vr) (SEConnectionNotFound agentConnId) $ DB.query db [sql| @@ -157,7 +158,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionEntityByConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ @@ -168,7 +169,7 @@ getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address -getContactConnEntityByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ @@ -188,7 +189,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ -getConnectionsToSubscribe :: DB.Connection -> VersionRangeChat -> IO ([ConnId], [ConnectionEntity]) +getConnectionsToSubscribe :: DB.Connection -> (PQSupport -> VersionRangeChat) -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db vr = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" entities <- forM aConnIds $ \acId -> do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index d3806fe34c..47174a59a6 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -125,11 +125,11 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createAddressContactConnection :: DB.Connection -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO Contact -createAddressContactConnection db user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode chatV pqSup = do +createAddressContactConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO Contact +createAddressContactConnection db vr user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode chatV pqSup = do PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode chatV pqSup liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId) - getContact db user contactId + getContact db vr user contactId createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup = do @@ -152,9 +152,9 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} -getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) -getConnReqContactXContactId db user@User {userId} cReqHash = do - getContactByConnReqHash db user cReqHash >>= \case +getConnReqContactXContactId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) +getConnReqContactXContactId db vr user@User {userId} cReqHash = do + getContactByConnReqHash db vr user cReqHash >>= \case c@(Just _) -> pure (c, Nothing) Nothing -> (Nothing,) <$> getXContactId where @@ -166,9 +166,9 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -getContactByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact) -getContactByConnReqHash db user@User {userId} cReqHash = - maybeFirstRow (toContact user) $ +getContactByConnReqHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ConnReqUriHash -> IO (Maybe Contact) +getContactByConnReqHash db vr user@User {userId} cReqHash = + maybeFirstRow (toContact vr user) $ DB.query db [sql| @@ -278,13 +278,13 @@ setContactDeleted db user@User {userId} ct@Contact {contactId} = do currentTs <- getCurrentTime DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) -getDeletedContacts :: DB.Connection -> User -> IO [Contact] -getDeletedContacts db user@User {userId} = do +getDeletedContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [Contact] +getDeletedContacts db vr user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) - rights <$> mapM (runExceptT . getDeletedContact db user) contactIds + rights <$> mapM (runExceptT . getDeletedContact db vr user) contactIds -getDeletedContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact -getDeletedContact db user contactId = getContact_ db user contactId True +getDeletedContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO Contact +getDeletedContact db vr user contactId = getContact_ db vr user contactId True deleteContactProfile_ :: DB.Connection -> UserId -> ContactId -> IO () deleteContactProfile_ db userId contactId = @@ -520,19 +520,19 @@ updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt (newName, updatedAt, userId, contactId) safeDeleteLDN db user displayName -getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact -getContactByName db user localDisplayName = do +getContactByName :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactName -> ExceptT StoreError IO Contact +getContactByName db vr user localDisplayName = do cId <- getContactIdByName db user localDisplayName - getContact db user cId + getContact db vr user cId -getUserContacts :: DB.Connection -> User -> IO [Contact] -getUserContacts db user@User {userId} = do +getUserContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [Contact] +getUserContacts db vr user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) - contacts <- rights <$> mapM (runExceptT . getContact db user) contactIds + contacts <- rights <$> mapM (runExceptT . getContact db vr user) contactIds pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts -createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> ExceptT StoreError IO ContactOrRequest -createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ pqSup = +createOrUpdateContactRequest :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> ExceptT StoreError IO ContactOrRequest +createOrUpdateContactRequest db vr user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ pqSup = liftIO (maybeM getContact' xContactId_) >>= \case Just contact -> pure $ CORContact contact Nothing -> CORRequest <$> createOrUpdate_ @@ -571,7 +571,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = - maybeFirstRow (toContact user) $ + maybeFirstRow (toContact vr user) $ DB.query db [sql| @@ -709,7 +709,7 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> User -> ConnId -> Maybe VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO Contact +createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO Contact createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId connChatVersion cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) createdAt <- getCurrentTime @@ -731,12 +731,12 @@ getContactIdByName db User {userId} cName = ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0" (userId, cName) -getContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact -getContact db user contactId = getContact_ db user contactId False +getContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO Contact +getContact db vr user contactId = getContact_ db vr user contactId False -getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact -getContact_ db user@User {userId} contactId deleted = - ExceptT . firstRow (toContact user) (SEContactNotFound contactId) $ +getContact_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact +getContact_ db vr user@User {userId} contactId deleted = + ExceptT . firstRow (toContact vr user) (SEContactNotFound contactId) $ DB.query db [sql| @@ -790,8 +790,8 @@ getPendingContactConnections db User {userId} = do |] [":user_id" := userId, ":conn_type" := ConnContact] -getContactConnections :: DB.Connection -> UserId -> Contact -> IO [Connection] -getContactConnections db userId Contact {contactId} = +getContactConnections :: DB.Connection -> (PQSupport -> VersionRangeChat) -> UserId -> Contact -> IO [Connection] +getContactConnections db vr userId Contact {contactId} = connections =<< liftIO getConnections_ where getConnections_ = @@ -808,11 +808,11 @@ getContactConnections db userId Contact {contactId} = |] (userId, userId, contactId) connections [] = pure [] - connections rows = pure $ map toConnection rows + connections rows = pure $ map (toConnection vr) rows -getConnectionById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Connection -getConnectionById db User {userId} connId = ExceptT $ do - firstRow toConnection (SEConnectionNotFoundById connId) $ +getConnectionById :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO Connection +getConnectionById db vr User {userId} connId = ExceptT $ do + firstRow (toConnection vr) (SEConnectionNotFoundById connId) $ DB.query db [sql| diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 0965150605..e77681bb9b 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -116,6 +116,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) +import Simplex.Messaging.Version import System.FilePath (takeFileName) getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] @@ -173,10 +174,10 @@ getPendingSndChunks db fileId connId = |] (fileId, connId) -createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () -createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do +createSndDirectFTConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createSndDirectFTConnection db vr user@User {userId} fileId (cmdId, acId) subMode = do currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode + Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -193,10 +194,10 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation fileId <- insertedRowId db pure FileTransferMeta {fileId, xftpSndFile = Nothing, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} -createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () -createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do +createSndGroupFileTransferConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () +createSndGroupFileTransferConnection db vr user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode + Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode setCommandConnId db user cmdId connId DB.execute db @@ -429,11 +430,10 @@ lookupChatRefByFileId db User {userId} fileId = (userId, fileId) -- TODO v6.0 remove -createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection -createSndFileConnection_ db userId fileId agentConnId subMode = do +createSndFileConnection_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection +createSndFileConnection_ db vr userId fileId agentConnId subMode = do currentTs <- getCurrentTime - -- TODO PQ use range from minVersion of the current range? - createConnection_ db userId ConnSndFile (Just fileId) agentConnId (Just initialChatVersion) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff + createConnection_ db userId ConnSndFile (Just fileId) agentConnId (minVersion $ vr PQSupportOff) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do @@ -695,7 +695,7 @@ getRcvFileTransfer_ db userId fileId = do _ -> pure Nothing cancelled = fromMaybe False cancelled_ -acceptRcvFileTransfer :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem +acceptRcvFileTransfer :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do currentTs <- getCurrentTime acceptRcvFT_ db user fileId filePath Nothing currentTs @@ -707,16 +707,16 @@ acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus f setCommandConnId db user cmdId connId runExceptT $ getChatItemByFileId db vr user fileId -getContactByFileId :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO Contact -getContactByFileId db user@User {userId} fileId = do +getContactByFileId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> FileTransferId -> ExceptT StoreError IO Contact +getContactByFileId db vr user@User {userId} fileId = do cId <- getContactIdByFileId - getContact db user cId + getContact db vr user cId where getContactIdByFileId = ExceptT . firstRow fromOnly (SEContactNotFoundByFileId fileId) $ DB.query db "SELECT contact_id FROM files WHERE user_id = ? AND file_id = ?" (userId, fileId) -acceptRcvInlineFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +acceptRcvInlineFT :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem acceptRcvInlineFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath (Just IFMOffer) =<< getCurrentTime getChatItemByFileId db vr user fileId @@ -725,7 +725,7 @@ startRcvInlineFT :: DB.Connection -> User -> RcvFileTransfer -> FilePath -> Mayb startRcvInlineFT db user RcvFileTransfer {fileId} filePath rcvFileInline = acceptRcvFT_ db user fileId filePath rcvFileInline =<< getCurrentTime -xftpAcceptRcvFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +xftpAcceptRcvFT :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem xftpAcceptRcvFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath Nothing =<< getCurrentTime getChatItemByFileId db vr user fileId @@ -1000,7 +1000,7 @@ getLocalCryptoFile db userId fileId sent = pure $ CryptoFile filePath fileCryptoArgs _ -> throwError $ SEFileNotFound fileId -updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> VersionRangeChat -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem +updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db vr user fileId fileStatus = do aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db vr user fileId case (cType, testEquality d $ msgDirection @d) of diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3b81d5b242..254b8dab59 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -142,7 +142,7 @@ import Simplex.Messaging.Agent.Protocol (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.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (PQSupport, pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version @@ -154,9 +154,9 @@ type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupM type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) -toGroupInfo :: VersionRangeChat -> Int64 -> GroupInfoRow -> GroupInfo +toGroupInfo :: (PQSupport -> VersionRangeChat) -> Int64 -> GroupInfoRow -> GroupInfo toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} + let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr PQSupportOff} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} @@ -186,11 +186,11 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId (Just initialChatVersion) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff -getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection -getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = - ExceptT . firstRow toConnection (SEGroupLinkNotFound groupInfo) $ +getGroupLinkConnection :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> ExceptT StoreError IO Connection +getGroupLinkConnection db vr User {userId} groupInfo@GroupInfo {groupId} = + ExceptT . firstRow (toConnection vr) (SEGroupLinkNotFound groupInfo) $ DB.query db [sql| @@ -261,7 +261,7 @@ setGroupLinkMemberRole :: DB.Connection -> User -> Int64 -> GroupMemberRole -> I setGroupLinkMemberRole db User {userId} userContactLinkId memberRole = DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId) -getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) +getGroupAndMember :: DB.Connection -> User -> Int64 -> (PQSupport -> VersionRangeChat) -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId vr = ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ DB.query @@ -303,10 +303,10 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = toGroupAndMember (groupInfoRow :. memberRow :. connRow) = let groupInfo = toGroupInfo vr userContactId groupInfoRow member = toGroupMember userContactId memberRow - in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo +createNewGroup :: DB.Connection -> (PQSupport -> VersionRangeChat) -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -348,7 +348,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) +createGroupInvitation :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case @@ -393,7 +393,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ |] (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) insertedRowId db - let hostVRange = peerChatVRange + let hostVRange = const $ adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} @@ -414,13 +414,18 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ groupMemberId ) +adjustedMemberVRange :: (PQSupport -> VersionRangeChat) -> VersionRangeChat -> VersionRangeChat +adjustedMemberVRange getVR vr@(VersionRange minV maxV) = + let maxV' = min maxV (maxVersion $ getVR PQSupportOff) + in fromMaybe vr $ safeVersionRange minV (max minV maxV') + getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostMemberId_ db User {userId} groupId = ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt memberChatVRange@(VersionRange minV maxV) = do +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> (PQSupport -> VersionRangeChat) -> ExceptT StoreError IO GroupMember +createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt vr = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of (Just profile@LocalProfile {displayName}, Just profileId) -> @@ -447,6 +452,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberChatVRange } where + memberChatVRange@(VersionRange minV maxV) = vr PQSupportOff insertMember_ :: IO ContactName insertMember_ = do let localDisplayName = localDisplayName' userOrContact @@ -482,7 +488,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe ) pure $ Right incognitoLdn -createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupInvitedViaLink db vr @@ -496,7 +502,7 @@ createGroupInvitedViaLink -- using IBUnknown since host is created without contact void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs vr liftIO $ setViaGroupLinkHash db groupId connId - (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db user hostMemberId + (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where insertGroup_ currentTs = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile @@ -553,10 +559,10 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO Group +getGroup :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> ExceptT StoreError IO Group getGroup db vr user groupId = do gInfo <- getGroupInfo db vr user groupId - members <- liftIO $ getGroupMembers db user gInfo + members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () @@ -608,12 +614,12 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroups :: DB.Connection -> VersionRangeChat -> User -> IO [Group] +getUserGroups :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [Group] getUserGroups db vr user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db vr user) groupIds -getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] +getUserGroupDetails :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = map (toGroupInfo vr userContactId) <$> DB.query @@ -636,7 +642,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = where search = fromMaybe "" search_ -getUserGroupsWithSummary :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)] +getUserGroupsWithSummary :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)] getUserGroupsWithSummary db vr user _contactId_ search_ = getUserGroupDetails db vr user _contactId_ search_ >>= mapM (\g@GroupInfo {groupId} -> (g,) <$> getGroupSummary db user groupId) @@ -677,7 +683,7 @@ checkContactHasGroups :: DB.Connection -> User -> Contact -> IO (Maybe GroupId) checkContactHasGroups db User {userId} Contact {contactId} = maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) -getGroupInfoByName :: DB.Connection -> VersionRangeChat -> User -> GroupName -> ExceptT StoreError IO GroupInfo +getGroupInfoByName :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupName -> ExceptT StoreError IO GroupInfo getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName getGroupInfo db vr user gId @@ -701,41 +707,41 @@ groupMemberQuery = ) |] -getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMember db vr user@User {userId} groupId groupMemberId = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) -getGroupMemberById :: DB.Connection -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMemberById db vr user@User {userId} groupMemberId = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (userId, groupMemberId, userId) -getGroupMemberByMemberId :: DB.Connection -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db user@User {userId} GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember +getGroupMemberByMemberId db vr user@User {userId} GroupInfo {groupId} memberId = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") (userId, groupId, memberId) -getGroupMembers :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember user) +getGroupMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> IO [GroupMember] +getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userId, userContactId) -getGroupMembersForExpiration :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] -getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember user) +getGroupMembersForExpiration :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> IO [GroupMember] +getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) <$> DB.query db ( groupMemberQuery @@ -749,9 +755,9 @@ getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {gro ) (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) -toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection connRow} +toContactMember :: (PQSupport -> VersionRangeChat) -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember vr User {userContactId} (memberRow :. connRow) = + (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do @@ -767,14 +773,14 @@ getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do (groupId, userId) pure $ length $ filter memberCurrent' statuses -getGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation +getGroupInvitation :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation getGroupInvitation db vr user groupId = getConnRec_ user >>= \case Just connRequest -> do groupInfo@GroupInfo {membership} <- getGroupInfo db vr user groupId when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined hostId <- getHostMemberId_ db user groupId - fromMember <- getGroupMember db user groupId hostId + fromMember <- getGroupMember db vr user groupId hostId pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} _ -> throwError SEGroupInvitationNotFound where @@ -832,7 +838,7 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, :. (minV, maxV) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> Maybe VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime @@ -889,7 +895,7 @@ createAcceptedMember :. (minV, maxV) ) -createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> Maybe VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () createAcceptedMemberConnection db user@User {userId} @@ -902,8 +908,8 @@ createAcceptedMemberConnection Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatV cReqChatVRange Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId -getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact -getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do +getContactViaMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> ExceptT StoreError IO Contact +getContactViaMember db vr user@User {userId} GroupMember {groupMemberId} = do contactId <- ExceptT $ firstRow fromOnly (SEContactNotFoundByMemberId groupMemberId) $ @@ -917,7 +923,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do LIMIT 1 |] (userId, groupMemberId) - getContact db user contactId + getContact db vr user contactId setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -929,12 +935,12 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) -createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> Maybe VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () +createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () createMemberConnection db userId GroupMember {groupMemberId} agentConnId chatV peerChatVRange subMode = do currentTs <- getCurrentTime void $ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode -createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> Maybe VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> IO () createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) chatV peerChatVRange subMode = do currentTs <- getCurrentTime Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode @@ -1163,10 +1169,10 @@ getIntroduction db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -getForwardIntroducedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardIntroducedMembers db user invitee highlyAvailable = do +getForwardIntroducedMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> Bool -> IO [GroupMember] +getForwardIntroducedMembers db vr user invitee highlyAvailable = do memberIds <- map fromOnly <$> query - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where mId = groupMemberId' invitee query @@ -1183,10 +1189,10 @@ getForwardIntroducedMembers db user invitee highlyAvailable = do WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -getForwardInvitedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardInvitedMembers db user forwardMember highlyAvailable = do +getForwardInvitedMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> Bool -> IO [GroupMember] +getForwardInvitedMembers db vr user forwardMember highlyAvailable = do memberIds <- map fromOnly <$> query - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where mId = groupMemberId' forwardMember query @@ -1203,7 +1209,7 @@ getForwardInvitedMembers db user forwardMember highlyAvailable = do WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> Maybe VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db user@User {userId} @@ -1236,7 +1242,7 @@ createIntroReMember liftIO $ setCommandConnId db user groupCmdId groupConnId pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> Maybe VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- getCurrentTime @@ -1273,11 +1279,11 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = |] [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff -getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) +getViaGroupMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = maybeFirstRow toGroupAndMember $ DB.query @@ -1320,10 +1326,10 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = toGroupAndMember (groupInfoRow :. memberRow :. connRow) = let groupInfo = toGroupInfo vr userContactId groupInfoRow member = toGroupMember userContactId memberRow - in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) -getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) -getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do +getViaGroupContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> IO (Maybe Contact) +getViaGroupContact db vr user@User {userId} GroupMember {groupMemberId} = do contactId_ <- maybeFirstRow fromOnly $ DB.query @@ -1337,7 +1343,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do LIMIT 1 |] (userId, groupMemberId) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} @@ -1373,7 +1379,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, (ldn, currentTs, userId, groupId) safeDeleteLDN db user localDisplayName -getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db vr User {userId, userContactId} groupId = ExceptT . firstRow (toGroupInfo vr userContactId) (SEGroupNotFound groupId) $ DB.query @@ -1396,7 +1402,7 @@ getGroupInfo db vr User {userId, userContactId} groupId = |] (groupId, userId, userContactId) -getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do groupId_ <- maybeFirstRow fromOnly $ @@ -1410,7 +1416,7 @@ getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReq (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ -getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- maybeFirstRow fromOnly $ @@ -1437,7 +1443,7 @@ getGroupMemberIdByName db User {userId} groupId groupMemberName = ExceptT . firstRow fromOnly (SEGroupMemberNameNotFound groupId groupMemberName) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ?" (userId, groupId, groupMemberName) -getActiveMembersByName :: DB.Connection -> VersionRangeChat -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] +getActiveMembersByName :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] getActiveMembersByName db vr user@User {userId} groupMemberName = do groupMemberIds :: [(GroupId, GroupMemberId)] <- liftIO $ @@ -1452,19 +1458,19 @@ getActiveMembersByName db vr user@User {userId} groupMemberName = do (userId, groupMemberName, GSMemConnected, GSMemComplete, GCUserMember) possibleMembers <- forM groupMemberIds $ \(groupId, groupMemberId) -> do groupInfo <- getGroupInfo db vr user groupId - groupMember <- getGroupMember db user groupId groupMemberId + groupMember <- getGroupMember db vr user groupId groupMemberId pure (groupInfo, groupMember) pure $ sortOn (Down . ts . fst) possibleMembers where ts GroupInfo {chatTs, updatedAt} = fromMaybe updatedAt chatTs -getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact] -getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do +getMatchingContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> IO [Contact] +getMatchingContacts db vr user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, CSActive, displayName, fullName, img) Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, CSActive, displayName, fullName) - rights <$> mapM (runExceptT . getContact db user) contactIds + rights <$> mapM (runExceptT . getContact db vr user) contactIds where -- this query is different from one in getMatchingMemberContacts -- it checks that it's not the same contact @@ -1478,13 +1484,13 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro AND p.display_name = ? AND p.full_name = ? |] -getMatchingMembers :: DB.Connection -> User -> Contact -> IO [GroupMember] -getMatchingMembers db user@User {userId} Contact {profile = LocalProfile {displayName, fullName, image}} = do +getMatchingMembers :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> IO [GroupMember] +getMatchingMembers db vr user@User {userId} Contact {profile = LocalProfile {displayName, fullName, image}} = do memberIds <- map fromOnly <$> case image of Just img -> DB.query db (q <> " AND p.image = ?") (userId, GCUserMember, displayName, fullName, img) Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, GCUserMember, displayName, fullName) - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where -- only match with members without associated contact q = @@ -1497,14 +1503,14 @@ getMatchingMembers db user@User {userId} Contact {profile = LocalProfile {displa AND p.display_name = ? AND p.full_name = ? |] -getMatchingMemberContacts :: DB.Connection -> User -> GroupMember -> IO [Contact] -getMatchingMemberContacts _ _ GroupMember {memberContactId = Just _} = pure [] -getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} = do +getMatchingMemberContacts :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> IO [Contact] +getMatchingMemberContacts _ _ _ GroupMember {memberContactId = Just _} = pure [] +getMatchingMemberContacts db vr user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of Just img -> DB.query db (q <> " AND p.image = ?") (userId, CSActive, displayName, fullName, img) Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, CSActive, displayName, fullName) - rights <$> mapM (runExceptT . getContact db user) contactIds + rights <$> mapM (runExceptT . getContact db vr user) contactIds where q = [sql| @@ -1536,8 +1542,8 @@ createSentProbeHash db userId probeId to = do "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, group_member_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (probeId, ctId, gmId, userId, currentTs, currentTs) -matchReceivedProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] -matchReceivedProbe db user@User {userId} from (Probe probe) = do +matchReceivedProbe :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] +matchReceivedProbe db vr user@User {userId} from (Probe probe) = do let probeHash = C.sha256Hash probe cgmIds <- DB.query @@ -1558,7 +1564,7 @@ matchReceivedProbe db user@User {userId} from (Probe probe) = do "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) let cgmIds' = filterFirstContactId cgmIds - catMaybes <$> mapM (getContactOrMember_ db user) cgmIds' + catMaybes <$> mapM (getContactOrMember_ db vr user) cgmIds' where filterFirstContactId :: [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] -> [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] filterFirstContactId cgmIds = do @@ -1568,8 +1574,8 @@ matchReceivedProbe db user@User {userId} from (Probe probe) = do (x : _) -> [x] ctIds' <> memIds -matchReceivedProbeHash :: DB.Connection -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) -matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do +matchReceivedProbeHash :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) +matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do probeIds <- maybeFirstRow id $ DB.query @@ -1589,11 +1595,11 @@ matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probeHash, userId, currentTs, currentTs) - pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db user cgmIds + pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db vr user cgmIds -matchSentProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) -matchSentProbe db user@User {userId} _from (Probe probe) = do - cgmIds $>>= getContactOrMember_ db user +matchSentProbe :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) +matchSentProbe db vr user@User {userId} _from (Probe probe) = do + cgmIds $>>= getContactOrMember_ db vr user where (ctId, gmId) = contactOrMemberIds _from cgmIds = @@ -1612,16 +1618,16 @@ matchSentProbe db user@User {userId} _from (Probe probe) = do |] (userId, probe, ctId, gmId) -getContactOrMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) -getContactOrMember_ db user ids = +getContactOrMember_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) +getContactOrMember_ db vr user ids = fmap eitherToMaybe . runExceptT $ case ids of - (Just ctId, _, _) -> COMContact <$> getContact db user ctId - (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db user gId gmId + (Just ctId, _, _) -> COMContact <$> getContact db vr user ctId + (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db vr user gId gmId _ -> throwError $ SEInternalError "" -- if requested merge direction is overruled (toFromContacts), keepLDN is kept -mergeContactRecords :: DB.Connection -> User -> Contact -> Contact -> ExceptT StoreError IO Contact -mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN} from = do +mergeContactRecords :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Contact -> Contact -> ExceptT StoreError IO Contact +mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keepLDN} from = do let (toCt, fromCt) = toFromContacts to from Contact {contactId = toContactId, localDisplayName = toLDN} = toCt Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt @@ -1679,7 +1685,7 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN WHERE user_id = ? AND local_display_name = ? |] (keepLDN, currentTs, userId, toLDN) - getContact db user toContactId + getContact db vr user toContactId where toFromContacts :: Contact -> Contact -> (Contact, Contact) toFromContacts c1 c2 @@ -1710,9 +1716,10 @@ associateMemberWithContactRecord when (memProfileId /= profileId) $ deleteUnusedProfile_ db userId memProfileId when (memLDN /= localDisplayName) $ deleteUnusedDisplayName_ db userId memLDN -associateContactWithMemberRecord :: DB.Connection -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact +associateContactWithMemberRecord :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact associateContactWithMemberRecord db + vr user@User {userId} GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} = do @@ -1736,7 +1743,7 @@ associateContactWithMemberRecord (memLDN, memProfileId, currentTs, userId, contactId) when (profileId /= memProfileId) $ deleteUnusedProfile_ db userId profileId when (localDisplayName /= memLDN) $ deleteUnusedDisplayName_ db userId localDisplayName - getContact db user contactId + getContact db vr user contactId deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = @@ -1946,14 +1953,14 @@ createMemberContact mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} -getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) +getMemberContact :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do - ct <- getContact db user contactId + ct <- getContact db vr user contactId let Contact {contactGroupMemberId, activeConn} = ct case (activeConn, contactGroupMemberId) of (Just Connection {connId}, Just groupMemberId) -> do cReq <- getConnReqInv db connId - m@GroupMember {groupId} <- getGroupMemberById db user groupMemberId + m@GroupMember {groupId} <- getGroupMemberById db vr user groupMemberId g <- getGroupInfo db vr user groupId pure (g, m, ct, cReq) _ -> @@ -2126,7 +2133,7 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" (xGrpLinkMemReceived, currentTs, mId) -createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember +createNewUnknownGroupMember :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName @@ -2146,12 +2153,12 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g :. (minV, maxV) ) insertedRowId db - getGroupMemberById db user groupMemberId + getGroupMemberById db vr user groupMemberId where - VersionRange minV maxV = vr + VersionRange minV maxV = vr PQSupportOff -updateUnknownMemberAnnounced :: DB.Connection -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do +updateUnknownMemberAnnounced :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember +updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do _ <- updateMemberProfile db user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ @@ -2171,7 +2178,7 @@ updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@ ( (memberRole, GCPostMember, GSMemAnnounced, groupMemberId' invitingMember) :. (minV, maxV, currentTs, userId, groupMemberId) ) - getGroupMemberById db user groupMemberId + getGroupMemberById db vr user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 1a69e16e27..05b1a153b2 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -145,6 +145,7 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserI import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM @@ -481,7 +482,7 @@ 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 -> VersionRangeChat -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] +getChatPreviews :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] getChatPreviews db vr user withPCC pagination query = do directChats <- findDirectChatPreviews_ db user pagination query groupChats <- findGroupChatPreviews_ db user pagination query @@ -504,7 +505,7 @@ getChatPreviews db vr user withPCC pagination query = do 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 + SCTDirect -> getDirectChatPreview_ db vr user cpd SCTGroup -> getGroupChatPreview_ db vr user cpd SCTLocal -> getLocalChatPreview_ db user cpd SCTContactRequest -> let (ContactRequestPD _ chat) = cpd in pure chat @@ -618,9 +619,9 @@ findDirectChatPreviews_ db User {userId} pagination clq = ) ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) -getDirectChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat -getDirectChatPreview_ db user (DirectChatPD _ contactId lastItemId_ stats) = do - contact <- getContact db user contactId +getDirectChatPreview_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat +getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do + contact <- getContact db vr user contactId lastItem <- case lastItemId_ of Just lastItemId -> (: []) <$> getDirectChatItem db user contactId lastItemId Nothing -> pure [] @@ -714,7 +715,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = ) ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) -getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat +getGroupChatPreview_ :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db vr user groupId lastItem <- case lastItemId_ of @@ -919,10 +920,10 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of 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 +getDirectChat :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChat db vr user contactId pagination search_ = do let search = fromMaybe "" search_ - ct <- getContact db user contactId + ct <- getContact db vr user contactId liftIO $ case pagination of CPLast count -> getDirectChatLast_ db user ct count search CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search @@ -1039,7 +1040,7 @@ getDirectChatBefore_ db user@User {userId} ct@Contact {contactId} beforeChatItem |] (userId, contactId, search, beforeChatItemId, count) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChat :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChat db vr user groupId pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId @@ -1505,7 +1506,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] +getAllChatItems :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of @@ -2149,7 +2150,7 @@ deleteLocalChatItem db User {userId} NoteFolder {noteFolderId} ci = do |] (userId, noteFolderId, itemId) -getChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO AChatItem +getChatItemByFileId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO AChatItem getChatItemByFileId db vr user@User {userId} fileId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ @@ -2165,13 +2166,13 @@ getChatItemByFileId db vr user@User {userId} fileId = do (userId, fileId) getAChatItem db vr user chatRef itemId -lookupChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) +lookupChatItemByFileId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) lookupChatItemByFileId db vr user fileId = do fmap Just (getChatItemByFileId db vr user fileId) `catchError` \case SEChatItemNotFoundByFileId {} -> pure Nothing e -> throwError e -getChatItemByGroupId :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO AChatItem +getChatItemByGroupId :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupId -> ExceptT StoreError IO AChatItem getChatItemByGroupId db vr user@User {userId} groupId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByGroupId groupId) $ @@ -2197,10 +2198,10 @@ getChatRefViaItemId db User {userId} itemId = do (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId (_, _) -> Left $ SEBadChatItem itemId Nothing -getAChatItem :: DB.Connection -> VersionRangeChat -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem +getAChatItem :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem getAChatItem db vr user chatRef itemId = case chatRef of ChatRef CTDirect contactId -> do - ct <- getContact db user contactId + ct <- getContact db vr user contactId (CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci ChatRef CTGroup groupId -> do @@ -2437,9 +2438,9 @@ createCIModeration db GroupInfo {groupId} moderatorMember itemMemberId itemShare |] (groupId, groupMemberId' moderatorMember, itemMemberId, itemSharedMId, msgId, moderatedAtTs) -getCIModeration :: DB.Connection -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) -getCIModeration _ _ _ _ Nothing = pure Nothing -getCIModeration db user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do +getCIModeration :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) +getCIModeration _ _ _ _ _ Nothing = pure Nothing +getCIModeration db vr user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do r_ <- maybeFirstRow id $ DB.query @@ -2453,7 +2454,7 @@ getCIModeration db user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do (groupId, itemMemberId, sharedMsgId) case r_ of Just (moderationId, moderatorId, createdByMsgId, moderatedAt) -> do - runExceptT (getGroupMember db user groupId moderatorId) >>= \case + runExceptT (getGroupMember db vr user groupId moderatorId) >>= \case Right moderatorMember -> pure (Just CIModeration {moderationId, moderatorMember, createdByMsgId, moderatedAt}) _ -> pure Nothing _ -> pure Nothing diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 0d47982aca..512c857b23 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -86,6 +86,7 @@ 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 qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) @@ -324,16 +325,16 @@ createUserContactLink db User {userId} agentConnId cReq subMode = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId (Just initialChatVersion) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff -getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] -getUserAddressConnections db User {userId} = do +getUserAddressConnections :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> ExceptT StoreError IO [Connection] +getUserAddressConnections db vr User {userId} = do cs <- liftIO getUserAddressConnections_ if null cs then throwError SEUserContactLinkNotFound else pure cs where getUserAddressConnections_ :: IO [Connection] getUserAddressConnections_ = - map toConnection + map (toConnection vr) <$> DB.query db [sql| @@ -347,8 +348,8 @@ getUserAddressConnections db User {userId} = do |] (userId, userId) -getUserContactLinks :: DB.Connection -> User -> IO [(Connection, UserContact)] -getUserContactLinks db User {userId} = +getUserContactLinks :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> IO [(Connection, UserContact)] +getUserContactLinks db vr User {userId} = map toUserContactConnection <$> DB.query db @@ -365,7 +366,7 @@ getUserContactLinks db User {userId} = (userId, userId) where toUserContactConnection :: (ConnectionRow :. (Int64, ConnReqContact, Maybe GroupId)) -> (Connection, UserContact) - toUserContactConnection (connRow :. (userContactLinkId, connReqContact, groupId)) = (toConnection connRow, UserContact {userContactLinkId, connReqContact, groupId}) + toUserContactConnection (connRow :. (userContactLinkId, connReqContact, groupId)) = (toConnection vr connRow, UserContact {userContactLinkId, connReqContact, groupId}) deleteUserAddress :: DB.Connection -> User -> IO () deleteUserAddress db user@User {userId} = do @@ -473,8 +474,8 @@ getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = |] (userId, cReqSchema1, cReqSchema2) -getContactWithoutConnViaAddress :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) -getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) = do +getContactWithoutConnViaAddress :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) +getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do ctId_ <- maybeFirstRow fromOnly $ DB.query @@ -487,7 +488,7 @@ getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL |] (userId, cReqSchema1, cReqSchema2) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) ctId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) ctId_ updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 0650dd23de..6d5c41c1a5 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -155,13 +155,13 @@ type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) -toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, connChatVersion, minVer, maxVer)) = +toConnection :: (PQSupport -> VersionRangeChat) -> ConnectionRow -> Connection +toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, chatV, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, - connChatVersion, -- TODO we could avoid maybe here by computing compatible version, but it would require passing current version range here as well - peerChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer, + connChatVersion = fromMaybe (vr pqSupport `peerConnChatVersion` peerChatVRange) chatV, + peerChatVRange = peerChatVRange, connLevel, viaContact, viaUserContactLink, @@ -182,6 +182,7 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup createdAt } where + peerChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId entityId_ ConnMember = groupMemberId @@ -189,12 +190,12 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnSndFile = sndFileId entityId_ ConnUserContact = userContactLinkId -toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, connChatVersion, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, connChatVersion, minVer, maxVer)) -toMaybeConnection _ = Nothing +toMaybeConnection :: (PQSupport -> VersionRangeChat) -> MaybeConnectionRow -> Maybe Connection +toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, connChatVersion, Just minVer, Just maxVer)) = + Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, connChatVersion, minVer, maxVer)) +toMaybeConnection _ _ = Nothing -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection createConnection_ db userId connType entityId acId connChatVersion peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode pqSup = do viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId -> maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId) @@ -296,7 +297,7 @@ updateConnPQEnabledCON db connId pqEnabled = |] (pqEnabled, pqEnabled, connId) -setPeerChatVRange :: DB.Connection -> Int64 -> Maybe VersionChat -> VersionRangeChat -> IO () +setPeerChatVRange :: DB.Connection -> Int64 -> VersionChat -> VersionRangeChat -> IO () setPeerChatVRange db connId chatV (VersionRange minVer maxVer) = DB.execute db @@ -370,10 +371,10 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) -toContact :: User -> ContactRow :. MaybeConnectionRow -> Contact -toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContact :: (PQSupport -> VersionRangeChat) -> User -> ContactRow :. MaybeConnectionRow -> Contact +toContact vr user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - activeConn = toMaybeConnection connRow + activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 18fe5ad1ac..f16913439d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1291,7 +1291,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, - connChatVersion :: Maybe VersionChat, + connChatVersion :: VersionChat, peerChatVRange :: VersionRangeChat, connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection @@ -1649,6 +1649,13 @@ pattern VersionChat v = Version v -- this newtype exists to have a concise JSON encoding of version ranges in chat protocol messages in the form of "1-2" or just "1" newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRangeChat} deriving (Eq, Show) +-- TODO v6.0 review +peerConnChatVersion :: VersionRangeChat -> VersionRangeChat -> VersionChat +peerConnChatVersion _local@(VersionRange lmin lmax) _peer@(VersionRange rmin rmax) + | lmin <= rmax && rmin <= lmax = min lmax rmax -- compatible + | rmin > lmax = rmin + | otherwise = rmax + initialChatVersion :: VersionChat initialChatVersion = VersionChat 1 diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 16a75377fd..fe80a1a532 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -586,13 +586,13 @@ pqRcvForContact :: TestCC -> ContactId -> IO PQEncryption pqRcvForContact = pqForContact_ pqRcvEnabled PQEncOff pqForContact :: TestCC -> ContactId -> IO PQEncryption -pqForContact = pqForContact_ (Just . connPQEnabled) PQEncOff +pqForContact = pqForContact_ (Just . connPQEnabled) (error "impossible") pqSupportForCt :: TestCC -> ContactId -> IO PQSupport pqSupportForCt = pqForContact_ (\Connection {pqSupport} -> Just pqSupport) PQSupportOff pqVerForContact :: TestCC -> ContactId -> IO VersionChat -pqVerForContact = pqForContact_ connChatVersion (VersionChat 0) +pqVerForContact = pqForContact_ (Just . connChatVersion) (error "impossible") pqForContact_ :: (Connection -> Maybe a) -> a -> TestCC -> ContactId -> IO a pqForContact_ pqSel def cc contactId = (fromMaybe def . pqSel) <$> getCtConn cc contactId @@ -601,10 +601,11 @@ getCtConn :: TestCC -> ContactId -> IO Connection getCtConn cc contactId = getTestCCContact cc contactId >>= maybe (fail "no connection") pure . contactConn getTestCCContact :: TestCC -> ContactId -> IO Contact -getTestCCContact cc contactId = +getTestCCContact cc contactId = do + let TestCC {chatController = ChatController {config = ChatConfig {chatVRange = vr}}} = cc withCCTransaction cc $ \db -> withCCUser cc $ \user -> - runExceptT (getContact db user contactId) >>= either (fail . show) pure + runExceptT (getContact db vr user contactId) >>= either (fail . show) pure lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do From 56fcaf514ee2a2bf8a5a63b52ede0bed96999f69 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 11 Mar 2024 02:54:55 +0400 Subject: [PATCH 43/64] core (pq): don't compress if message fits without compression; check compressed message fits size limit (#3888) * core (pq): don't compress if message fits without compression; check compressed message fits size limit * refactor * errors * fix tests * envelope sizes * refactor * comment * more flexible test * refactor, comment --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 48 +++++++++++++++++++++--------------- src/Simplex/Chat/Protocol.hs | 33 +++++++++++++------------ tests/ChatTests/Direct.hs | 4 +-- tests/ChatTests/Utils.hs | 21 +++++----------- tests/MessageBatching.hs | 4 +-- 7 files changed, 57 insertions(+), 57 deletions(-) diff --git a/cabal.project b/cabal.project index 4fccb51694..330d1055db 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 851ed2d02e2a78c15893ad8bc9c5a4d917eb6a35 + tag: b4c90781bba8cca3a8f7bea9e0c2b6707ff923af source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b2e11db33a..3b9a02f83d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."851ed2d02e2a78c15893ad8bc9c5a4d917eb6a35" = "0rm13iknnqhdb42nmyjc2wj85z23p337bp026ihnychax5s1216j"; + "https://github.com/simplex-chat/simplexmq.git"."b4c90781bba8cca3a8f7bea9e0c2b6707ff923af" = "0f4h1akgpkrg68lmhrnvrq6srr2c3gj0fyx4ghnsp5hmbyhn2mk2"; "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 74eb38f57f..56914d2d9d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3350,7 +3350,8 @@ processAgentMsgSndFile _corrId aFileId msg = [] -> case xftpRedirectFor of Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft Just _ -> sendFileError "Prohibit chaining redirects" fileId vr ft - rfds' -> do -- we have 1 chunk - use it as URI whether it is redirect or not + rfds' -> do + -- we have 1 chunk - use it as URI whether it is redirect or not ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor toView $ CRSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) -> @@ -6057,8 +6058,7 @@ sendDirectContactMessage user ct chatMsgEvent = do conn@Connection {connId, pqSupport} <- liftEither $ contactSendConn_ ct r <- sendDirectMessage_ conn pqSupport chatMsgEvent (ConnectionId connId) let (sndMessage, msgDeliveryId, pqEnc') = r - -- TODO PQ use updated ct' and conn'? check downstream if it may affect something, maybe it's not necessary - void $ createContactPQSndItem user ct conn pqEnc' -- (_ct', _conn') + void $ createContactPQSndItem user ct conn pqEnc' pure (sndMessage, msgDeliveryId) contactSendConn_ :: Contact -> Either ChatError Connection @@ -6127,12 +6127,12 @@ processSndMessageBatch conn@Connection {connId} (MsgBatch batchBody sndMsgs) = d -- TODO v5.7 update batching for groups batchSndMessagesJSON :: NonEmpty SndMessage -> [Either ChatError MsgBatch] -batchSndMessagesJSON = batchMessages maxRawMsgLength . L.toList +batchSndMessagesJSON = batchMessages maxEncodedMsgLength . L.toList -- batchSndMessagesBinary :: forall m. ChatMonad m => NonEmpty SndMessage -> m [Either ChatError MsgBatch] -- batchSndMessagesBinary msgs = do -- compressed <- liftIO $ withCompressCtx maxChatMsgSize $ \cctx -> mapM (compressForBatch cctx) msgs --- pure . map toMsgBatch . SMP.batchTransmissions_ (maxEncodedMsgLength PQEncOff) $ L.zip compressed msgs +-- pure . map toMsgBatch . SMP.batchTransmissions_ (maxEncodedMsgLength) $ L.zip compressed msgs -- where -- compressForBatch cctx SndMessage {msgBody} = bimap (const TELargeMsg) smpEncode <$> compress cctx msgBody -- toMsgBatch :: SMP.TransportBatch SndMessage -> Either ChatError MsgBatch @@ -6146,19 +6146,20 @@ encodeConnInfo chatMsgEvent = do vr <- chatVersionRange encodeConnInfoPQ PQSupportOff (maxVersion $ vr PQSupportOff) chatMsgEvent --- TODO PQ check size after compression (in compressedBatchMsgBody_ ?) encodeConnInfoPQ :: (MsgEncodingI e, ChatMonad m) => PQSupport -> VersionChat -> ChatMsgEvent e -> m ByteString encodeConnInfoPQ pqSup v chatMsgEvent = do vr <- chatVersionRange - let msg = ChatMessage {chatVRange = vr pqSup, msgId = Nothing, chatMsgEvent} - case encodeChatMessage maxConnInfoLength msg of - ECMEncoded encodedBody -> case pqSup of - PQSupportOn | v >= pqEncryptionCompressionVersion -> liftIO $ compressedBatchMsgBody encodedBody - _ -> pure encodedBody - ECMLarge -> throwChatError $ CEException "large message" - where - compressedBatchMsgBody msgBody = - withCompressCtx (toEnum $ B.length msgBody) (`compressedBatchMsgBody_` msgBody) + let info = ChatMessage {chatVRange = vr pqSup, msgId = Nothing, chatMsgEvent} + case encodeChatMessage maxEncodedInfoLength info of + ECMEncoded connInfo -> case pqSup of + PQSupportOn | v >= pqEncryptionCompressionVersion && B.length connInfo > maxCompressedInfoLength -> do + connInfo' <- liftIO compressedBatchMsgBody + when (B.length connInfo' > maxCompressedInfoLength) $ throwChatError $ CEException "large compressed info" + pure connInfo' + _ -> pure connInfo + where + compressedBatchMsgBody = withCompressCtx (toEnum $ B.length connInfo) (`compressedBatchMsgBody_` connInfo) + ECMLarge -> throwChatError $ CEException "large info" deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do @@ -6183,11 +6184,18 @@ deliverMessagesB msgReqs = do void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where - compressBodies = liftIO $ withCompressCtx (toEnum maxRawMsgLength) $ \cctx -> do - forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion}, msgFlags, msgBody, msgId) -> Right <$> case pqSupport of - PQSupportOn | connChatVersion >= pqEncryptionCompressionVersion -> - (\cBody -> (conn, msgFlags, cBody, msgId)) <$> compressedBatchMsgBody_ cctx msgBody - _ -> pure mr + compressBodies = liftIO $ withCompressCtx (toEnum maxEncodedMsgLength) $ \cxt -> + forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion = v}, msgFlags, msgBody, msgId) -> + runExceptT $ case pqSupport of + -- we only compress messages when: + -- 1) PQ support is enabled + -- 2) version is compatible with compression + -- 3) message is longer than max compressed size (as this function is not used for batched messages anyway) + PQSupportOn | v >= pqEncryptionCompressionVersion && B.length msgBody > maxCompressedMsgLength -> do + msgBody' <- liftIO $ compressedBatchMsgBody_ cxt msgBody + when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message" + pure (conn, msgFlags, msgBody', msgId) + _ -> pure mr toAgent = \case Right (conn@Connection {pqEncryption}, msgFlags, msgBody, _msgId) -> Right (aConnId conn, pqEncryption, msgFlags, msgBody) Left _ce -> Left (AP.INTERNAL "ChatError, skip") -- as long as it is Left, the agent batchers should just step over it diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 1af6d676ab..85ef027335 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -531,29 +531,29 @@ $(JQ.deriveJSON defaultJSON ''QuotedMsg) -- this limit reserves space for metadata in forwarded messages -- 15780 (limit used for fileChunkSize) - 161 (x.grp.msg.forward overhead) = 15619, round to 15610 -maxRawMsgLength :: Int -maxRawMsgLength = 15610 +maxEncodedMsgLength :: Int +maxEncodedMsgLength = 15610 -maxEncodedMsgLength :: PQSupport -> Int -maxEncodedMsgLength = \case - PQSupportOn -> 13410 -- reduced by 2200 (original message should be compressed) - PQSupportOff -> maxRawMsgLength -{-# INLINE maxEncodedMsgLength #-} +-- maxEncodedMsgLength - 2222, see e2eEncUserMsgLength in agent +maxCompressedMsgLength :: Int +maxCompressedMsgLength = 13388 -maxConnInfoLength :: PQSupport -> Int -maxConnInfoLength = \case - PQSupportOn -> 10902 -- reduced by 3700 - PQSupportOff -> 14602 -- 15610 - delta in agent between MSG and INFO -{-# INLINE maxConnInfoLength #-} +-- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead) +-- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008 +maxEncodedInfoLength :: Int +maxEncodedInfoLength = 14702 + +maxCompressedInfoLength :: Int +maxCompressedInfoLength = 10976 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent data EncodedChatMessage = ECMEncoded ByteString | ECMLarge -encodeChatMessage :: MsgEncodingI e => (PQSupport -> Int) -> ChatMessage e -> EncodedChatMessage -encodeChatMessage getMaxSize msg = do +encodeChatMessage :: MsgEncodingI e => Int -> ChatMessage e -> EncodedChatMessage +encodeChatMessage maxSize msg = do case chatToAppMessage msg of AMJson m -> do let body = LB.toStrict $ J.encode m - if B.length body > getMaxSize PQSupportOff + if B.length body > maxSize then ECMLarge else ECMEncoded body AMBinary m -> ECMEncoded $ strEncode m @@ -573,7 +573,8 @@ parseChatMessages s = case B.head s of decodeCompressed :: ByteString -> [Either String AChatMessage] decodeCompressed s' = case smpDecode s' of Left e -> [Left e] - Right compressed -> concatMap (either (pure . Left) parseChatMessages) . L.toList $ decompressBatch maxRawMsgLength compressed + -- TODO v5.7 don't reserve multiple large buffers when decoding batches + Right compressed -> concatMap (either (pure . Left) parseChatMessages) . L.toList $ decompressBatch maxEncodedMsgLength compressed compressedBatchMsgBody_ :: CompressCtx -> MsgBody -> IO ByteString compressedBatchMsgBody_ ctx msgBody = markCompressedBatch . smpEncode . (L.:| []) <$> compress ctx msgBody diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index c80323b114..4e06f68fb6 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -2825,7 +2825,7 @@ runTestPQConnectViaAddress (alice, aPQ) (bob, bPQ) = do runTestPQVersionsViaLink :: HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO () runTestPQVersionsViaLink alice bob pqExpected vExpected = do - img <- genProfileImgForLink + img <- genProfileImg let profileImage = "data:image/png;base64," <> B.unpack img alice `send` ("/set profile image " <> profileImage) _trimmedCmd1 <- getTermLine alice @@ -2857,7 +2857,7 @@ runTestPQVersionsViaLink alice bob pqExpected vExpected = do runTestPQVersionsViaAddress :: HasCallStack => TestCC -> TestCC -> Bool -> VersionChat -> IO () runTestPQVersionsViaAddress alice bob pqExpected vExpected = do - img <- genProfileImgForAddress + img <- genProfileImg let profileImage = "data:image/png;base64," <> B.unpack img alice `send` ("/set profile image " <> profileImage) _trimmedCmd1 <- getTermLine alice diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index fe80a1a532..3b0748e7d0 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -245,30 +245,21 @@ sndRcvImg pqEnc enabled (cc1, msg, v1) (cc2, v2) = do img <- atomically $ B64.encode <$> C.randomBytes lrgLen g cc1 `send` ("/_send @2 json {\"msgContent\":{\"type\":\"image\",\"text\":\"" <> msg <> "\",\"image\":\"" <> B.unpack img <> "\"}}") cc1 .<## "}}" - when enabled $ cc1 <## (name2 <> ": quantum resistant end-to-end encryption enabled") - cc1 <# ("@" <> name2 <> " " <> msg) + cc1 <### ([ConsoleString (name2 <> ": quantum resistant end-to-end encryption enabled") | enabled] <> [WithTime ("@" <> name2 <> " " <> msg)]) cc1 `pqSndForContact` 2 `shouldReturn` pqEnc cc1 `pqVerForContact` 2 `shouldReturn` v1 - when enabled $ cc2 <## (name1 <> ": quantum resistant end-to-end encryption enabled") - cc2 <# (name1 <> "> " <> msg) + cc2 <### ([ConsoleString (name1 <> ": quantum resistant end-to-end encryption enabled") | enabled] <> [WithTime (name1 <> "> " <> msg)]) cc2 `pqRcvForContact` 2 `shouldReturn` pqEnc cc2 `pqVerForContact` 2 `shouldReturn` v2 where - lrgLen = maxEncodedMsgLength PQSupportOff * 3 `div` 4 - 110 -- 98 is ~ max size for binary image preview given the rest of the message + lrgLen = maxEncodedMsgLength * 3 `div` 4 - 110 -- 98 is ~ max size for binary image preview given the rest of the message -genProfileImgForLink :: IO ByteString -genProfileImgForLink = do +genProfileImg :: IO ByteString +genProfileImg = do g <- C.newRandom atomically $ B64.encode <$> C.randomBytes lrgLen g where - lrgLen = maxConnInfoLength PQSupportOff * 3 `div` 4 - 240 -- 214 is the magic number to make tests pass (10737) - -genProfileImgForAddress :: IO ByteString -genProfileImgForAddress = do - g <- C.newRandom - atomically $ B64.encode <$> C.randomBytes lrgLen g - where - lrgLen = maxConnInfoLength PQSupportOff * 3 `div` 4 - 260 -- 238 is the magic number to make tests pass (10713) + lrgLen = maxEncodedInfoLength * 3 `div` 4 - 420 -- PQ combinators / diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 010fb5a2b4..54a0ae4f1c 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -17,7 +17,7 @@ import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages.Batch import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Messages (SndMessage (..)) -import Simplex.Chat.Protocol (SharedMsgId (..), maxRawMsgLength) +import Simplex.Chat.Protocol (SharedMsgId (..), maxEncodedMsgLength) import Test.Hspec batchingTests :: Spec @@ -99,7 +99,7 @@ testImageFitsSingleBatch = do msg s = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = s} batched = "[" <> xMsgNewStr <> "," <> descrStr <> "]" - runBatcherTest' maxRawMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] + runBatcherTest' maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] runBatcherTest :: Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest maxLen msgs expectedErrors expectedBatches = From 0e7d81681f86288f49058deec48fd426e0f8d965 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 10 Mar 2024 23:26:35 +0000 Subject: [PATCH 44/64] core: update 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 330d1055db..3916c9fb6d 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: b4c90781bba8cca3a8f7bea9e0c2b6707ff923af + tag: 78eb4f764fd52385a8687d2605a0e6edc1808431 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3b9a02f83d..c7678d1201 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b4c90781bba8cca3a8f7bea9e0c2b6707ff923af" = "0f4h1akgpkrg68lmhrnvrq6srr2c3gj0fyx4ghnsp5hmbyhn2mk2"; + "https://github.com/simplex-chat/simplexmq.git"."78eb4f764fd52385a8687d2605a0e6edc1808431" = "09nmrk65nbn6mp8mwwk09d5zx9cgm38i6xgmndk6jzlhnfl5fiy6"; "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"; From 6c78bbc1786e0d5159fb3f839ad7c1956aba956e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 11 Mar 2024 09:26:37 +0000 Subject: [PATCH 45/64] core: 5.6.0.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 850f337fbc..a4df72cda8 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.5.6.0 +version: 5.6.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 63f66ca561..26300dc146 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.5.6.0 +version: 5.6.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 8b8846c7b7fab60f956617ed6be6cde3b3878e80 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 11 Mar 2024 13:39:22 +0400 Subject: [PATCH 46/64] 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 1195aed3c5..09904ad4fe 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -57,15 +57,15 @@ 5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65F341297D3F3600B67AF3 /* VersionView.swift */; }; 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BA666289BD954009B8ECC /* DismissSheets.swift */; }; 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */; }; + 5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746D9F2B9F09AD0049D734 /* libffi.a */; }; + 5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA02B9F09AD0049D734 /* libgmpxx.a */; }; + 5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */; }; + 5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */; }; + 5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA32B9F09AD0049D734 /* libgmp.a */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 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 */; }; - 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD32B99B38B00C72EFF /* libgmp.a */; }; - 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD42B99B38B00C72EFF /* libgmpxx.a */; }; - 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD52B99B38B00C72EFF /* libffi.a */; }; - 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */; }; - 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.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 */; }; @@ -321,15 +321,15 @@ 5C6D183229E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = "pl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C6D183329E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFeaturePreferenceView.swift; sourceTree = ""; }; + 5C746D9F2B9F09AD0049D734 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C746DA02B9F09AD0049D734 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a"; sourceTree = ""; }; + 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a"; sourceTree = ""; }; + 5C746DA32B9F09AD0049D734 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; - 5C777BD32B99B38B00C72EFF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C777BD42B99B38B00C72EFF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C777BD52B99B38B00C72EFF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a"; sourceTree = ""; }; - 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a"; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -514,13 +514,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */, - 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */, + 5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */, + 5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */, - 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */, + 5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */, + 5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */, + 5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -582,11 +582,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C777BD52B99B38B00C72EFF /* libffi.a */, - 5C777BD32B99B38B00C72EFF /* libgmp.a */, - 5C777BD42B99B38B00C72EFF /* libgmpxx.a */, - 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */, - 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */, + 5C746D9F2B9F09AD0049D734 /* libffi.a */, + 5C746DA32B9F09AD0049D734 /* libgmp.a */, + 5C746DA02B9F09AD0049D734 /* libgmpxx.a */, + 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */, + 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */, ); path = Libraries; sourceTree = ""; From 3f6c74f97580bc2dc4722007a7507dfc5c96e25c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 11 Mar 2024 10:36:36 +0000 Subject: [PATCH 47/64] ios: e2e information chat items (#3890) * ios: e2e information chat items * texts --- apps/ios/Shared/Views/Chat/ChatItemView.swift | 25 +++++++++++++++---- apps/ios/SimpleXChat/ChatTypes.swift | 18 ++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index d9404547e2..177bbbe1c4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -111,11 +111,10 @@ struct ChatItemContentView: View { case .rcvModerated: deletedItemView() case .rcvBlocked: deletedItemView() case let .invalidJSON(json): CIInvalidJSONView(json: json) - // TODO proper items - case .sndDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) - case .rcvDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) - case .sndGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) - case .rcvGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) + case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) + case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) + case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) + case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) } } @@ -175,6 +174,22 @@ struct ChatItemContentView: View { Text(members) } } + + private func directE2EEInfoText(_ info: E2EEInfo) -> Text { + info.pqEnabled + ? Text("Messages, files and calls are protected by **quantum resistant e2e encryption**. It has perfect forward secrecy, repudiation and break-in recovery.") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + : e2eeInfoNoPQText() + } + + private func e2eeInfoNoPQText() -> Text { + Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + } } func chatEventText(_ text: Text) -> Text { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3463bfca18..4349fc7beb 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2784,23 +2784,23 @@ public enum CIContent: Decodable, ItemContent { case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") - case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) - case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) - case .sndGroupE2EEInfo: return e2eeInfoNoPQText - case .rcvGroupE2EEInfo: return e2eeInfoNoPQText + case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case .sndGroupE2EEInfo: return e2eeInfoNoPQStr + case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } } } - private func directE2EEInfoToText(_ e2eeInfo: E2EEInfo) -> String { + private func directE2EEInfoStr(_ e2eeInfo: E2EEInfo) -> String { e2eeInfo.pqEnabled - ? NSLocalizedString("This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery.", comment: "E2EE info chat item") - : e2eeInfoNoPQText + ? NSLocalizedString("This chat is protected by quantum resistant end-to-end encryption.", comment: "E2EE info chat item") + : e2eeInfoNoPQStr } - private var e2eeInfoNoPQText: String { - NSLocalizedString("This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.", comment: "E2EE info chat item") + private var e2eeInfoNoPQStr: String { + NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item") } static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { From 80690326cb421aa3860fe6f76162851b201e47f1 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 11 Mar 2024 16:36:59 +0400 Subject: [PATCH 48/64] multiplatform: e2e information chat items (#3891) --- apps/ios/Shared/Views/Chat/ChatItemView.swift | 4 +-- .../chat/simplex/common/model/ChatModel.kt | 16 ++++----- .../common/views/chat/item/ChatItemView.kt | 33 ++++++++++++++++--- .../commonMain/resources/MR/base/strings.xml | 6 ++-- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 177bbbe1c4..da9dc523e1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -110,11 +110,11 @@ struct ChatItemContentView: View { case .sndModerated: deletedItemView() case .rcvModerated: deletedItemView() case .rcvBlocked: deletedItemView() - case let .invalidJSON(json): CIInvalidJSONView(json: json) case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) + case let .invalidJSON(json): CIInvalidJSONView(json: json) } } @@ -177,7 +177,7 @@ struct ChatItemContentView: View { private func directE2EEInfoText(_ info: E2EEInfo) -> Text { info.pqEnabled - ? Text("Messages, files and calls are protected by **quantum resistant e2e encryption**. It has perfect forward secrecy, repudiation and break-in recovery.") + ? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.") .font(.caption) .foregroundColor(.secondary) .fontWeight(.light) 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 df1dec330d..b5f00e75aa 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 @@ -2328,10 +2328,10 @@ sealed class CIContent: ItemContent { is SndModerated -> generalGetString(MR.strings.moderated_description) is RcvModerated -> generalGetString(MR.strings.moderated_description) is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description) - is SndDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) - is RcvDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) - is SndGroupE2EEInfo -> e2eeInfoNoPQText - is RcvGroupE2EEInfo -> e2eeInfoNoPQText + is SndDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) + is RcvDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) + is SndGroupE2EEInfo -> e2eeInfoNoPQStr + is RcvGroupE2EEInfo -> e2eeInfoNoPQStr is InvalidJSON -> "invalid data" } @@ -2350,14 +2350,14 @@ sealed class CIContent: ItemContent { } companion object { - fun directE2EEInfoToText(e2EEInfo: E2EEInfo): String = + fun directE2EEInfoStr(e2EEInfo: E2EEInfo): String = if (e2EEInfo.pqEnabled) { - generalGetString(MR.strings.e2ee_info_pq) + generalGetString(MR.strings.e2ee_info_pq_short) } else { - e2eeInfoNoPQText + e2eeInfoNoPQStr } - private val e2eeInfoNoPQText: String = generalGetString(MR.strings.e2ee_info_no_pq) + private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short) fun featureText(feature: Feature, enabled: String, param: Int?): String = if (feature.hasParam) { 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 cce4307d1f..64741f7466 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 @@ -379,6 +379,30 @@ fun ChatItemView( } } + @Composable + fun E2EEInfoNoPQText() { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } + + @Composable + fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { + if (e2EEInfo.pqEnabled) { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } else { + E2EEInfoNoPQText() + } + } + when (val c = cItem.content) { is CIContent.SndMsgContent -> ContentItem() is CIContent.RcvMsgContent -> ContentItem() @@ -452,11 +476,10 @@ fun ChatItemView( is CIContent.SndModerated -> DeletedItem() is CIContent.RcvModerated -> DeletedItem() is CIContent.RcvBlocked -> DeletedItem() - // TODO proper items - is CIContent.SndDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) - is CIContent.RcvDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) - is CIContent.SndGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) - is CIContent.RcvGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) } } 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 49552a592d..d074530a5e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,8 +54,10 @@ Decryption error Encryption re-negotiation error - This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery. - This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery. + end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.]]> + quantum resistant e2e encryption with perfect forward secrecy, repudiation and break-in recovery.]]> + This chat is protected by end-to-end encryption. + This chat is protected by quantum resistant end-to-end encryption. Private notes From a56bc6760ba359b213393e8fae66467480f38014 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 11 Mar 2024 21:17:28 +0700 Subject: [PATCH 49/64] ios: migration via link (#3808) * ios: migration via link * changes in UI * UI * UI and API changes * UI and logic * simplify statement * UI, API, logic * formatting * animation fix * better animation * test * changed directory * changes * migrating to device * migrate settings * more state updates on main thread * texts * continue migration after restart * toggle for saving passphrase and footer text * no visual arthefacts when deleting a chat after migration * saving settings before changing passphrase * back button is looking disabled when it's disabled * fixed starting chat issues when migrating to device * paste and share link elements * proper import process and refactoring UI in SimpleXInfo * show progress on settings while starting chat * title bold font * changes as in Android * brace * changes as in Android * rename to prevent confusion * fixes and adapted to Android * unused param * comment * don't allow going back on Archiving step * update core library * changes as in Android * correction * correction * change * qr code * update network settings view * update progress * changes * navigation view and focus in text field * texts --------- Co-authored-by: Avently Co-authored-by: Evgeny Poberezkin Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/Model/SimpleXAPI.swift | 134 +++- apps/ios/Shared/SimpleXApp.swift | 7 +- .../Views/Chat/ContactPreferencesView.swift | 2 +- .../Chat/Group/GroupPreferencesView.swift | 2 +- .../Views/Chat/Group/GroupWelcomeView.swift | 2 +- .../Database/DatabaseEncryptionView.swift | 137 ++-- .../Views/Database/DatabaseErrorView.swift | 4 +- .../Shared/Views/Database/DatabaseView.swift | 6 +- .../Database/MigrateToAppGroupView.swift | 23 +- .../Migration/MigrateFromAnotherDevice.swift | 720 +++++++++++++++++ .../Migration/MigrateToAnotherDevice.swift | 727 ++++++++++++++++++ .../Shared/Views/NewChat/NewChatView.swift | 114 +-- .../Shared/Views/Onboarding/SimpleXInfo.swift | 45 ++ .../RemoteAccess/ConnectDesktopView.swift | 2 +- .../Views/UserSettings/AppSettings.swift | 72 ++ .../UserSettings/ProtocolServerView.swift | 4 +- .../UserSettings/ProtocolServersView.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 27 +- .../Views/UserSettings/UserAddressView.swift | 2 +- .../ios/SimpleX NSE/NotificationService.swift | 4 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 + apps/ios/SimpleXChat/API.swift | 35 +- apps/ios/SimpleXChat/APITypes.swift | 218 +++++- apps/ios/SimpleXChat/AppGroup.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 7 +- apps/ios/SimpleXChat/FileUtils.swift | 9 +- 27 files changed, 2142 insertions(+), 188 deletions(-) create mode 100644 apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift create mode 100644 apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift create mode 100644 apps/ios/Shared/Views/UserSettings/AppSettings.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c54e11eb78..bed5d9b2de 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -95,6 +95,7 @@ final class ChatModel: ObservableObject { @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? + @Published var migrationState: MigrationFromAnotherDeviceState? = MigrationFromAnotherDeviceState.transform() // audio recording and playback @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var draft: ComposeState? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 57dab12a87..7318a54e92 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -90,12 +90,12 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } -func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse { +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse { logger.debug("chatSendCmd \(cmd.cmdType)") let start = Date.now let resp = bgTask - ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) } - : sendSimpleXCmd(cmd) + ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) } + : sendSimpleXCmd(cmd, ctrl) logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") @@ -106,24 +106,24 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = return resp } -func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) async -> ChatResponse { +func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse { await withCheckedContinuation { cont in - cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay)) + cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl)) } } -func chatRecvMsg() async -> ChatResponse? { +func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? { await withCheckedContinuation { cont in _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in - let resp = recvSimpleXMsg() + let resp = recvSimpleXMsg(ctrl) cont.resume(returning: resp) return resp } } } -func apiGetActiveUser() throws -> User? { - let r = chatSendCmdSync(.showActiveUser) +func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { + let r = chatSendCmdSync(.showActiveUser, ctrl) switch r { case let .activeUser(user): return user case .chatCmdError(_, .error(.noActiveUser)): return nil @@ -131,8 +131,8 @@ func apiGetActiveUser() throws -> User? { } } -func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false) throws -> User { - let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp)) +func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { + let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl) if case let .activeUser(user) = r { return user } throw r } @@ -210,8 +210,8 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn throw r } -func apiStartChat() throws -> Bool { - let r = chatSendCmdSync(.startChat(mainApp: true)) +func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool { + let r = chatSendCmdSync(.startChat(mainApp: true), ctrl) switch r { case .chatStarted: return true case .chatRunning: return false @@ -240,14 +240,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) { logger.error("apiSuspendChat error: \(String(describing: r))") } -func apiSetTempFolder(tempFolder: String) throws { - let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder)) +func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws { + let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl) if case .cmdOk = r { return } throw r } -func apiSetFilesFolder(filesFolder: String) throws { - let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder)) +func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws { + let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl) if case .cmdOk = r { return } throw r } @@ -258,6 +258,18 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { throw r } +func apiSaveAppSettings(settings: AppSettings) throws { + let r = chatSendCmdSync(.apiSaveSettings(settings: settings)) + if case .cmdOk = r { return } + throw r +} + +func apiGetAppSettings(settings: AppSettings) throws -> AppSettings { + let r = chatSendCmdSync(.apiGetSettings(settings: settings)) + if case let .appSettings(settings) = r { return settings } + throw r +} + func apiSetPQEncryption(_ enable: Bool) throws { let r = chatSendCmdSync(.apiSetPQEncryption(enable: enable)) if case .cmdOk = r { return } @@ -288,6 +300,10 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey))) } +func testStorageEncryption(key: String, _ ctrl: chat_ctrl? = nil) async throws { + try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl) +} + func apiGetChats() throws -> [ChatData] { let userId = try currentUserId("apiGetChats") return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId))) @@ -510,8 +526,8 @@ func getNetworkConfig() async throws -> NetCfg? { throw r } -func setNetworkConfig(_ cfg: NetCfg) throws { - let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg)) +func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws { + let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl) if case .cmdOk = r { return } throw r } @@ -876,6 +892,36 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) } +func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) { + let r = await chatSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl) + if case let .sndStandaloneFileCreated(_, fileTransferMeta) = r { + return (fileTransferMeta, nil) + } else { + logger.error("uploadStandaloneFile error: \(String(describing: r))") + return (nil, String(describing: r)) + } +} + +func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) { + let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl) + if case let .rcvStandaloneFileCreated(_, rcvFileTransfer) = r { + return (rcvFileTransfer, nil) + } else { + logger.error("downloadStandaloneFile error: \(String(describing: r))") + return (nil, String(describing: r)) + } +} + +func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? { + let r = await chatSendCmd(.apiStandaloneFileInfo(url: url), ctrl) + if case let .standaloneFileInfo(fileMeta) = r { + return fileMeta + } else { + logger.error("standaloneFileInfo error: \(String(describing: r))") + return nil + } +} + func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async { if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) { await chatItemSimpleUpdate(user, chatItem) @@ -921,8 +967,8 @@ func cancelFile(user: User, fileId: Int64) async { } } -func apiCancelFile(fileId: Int64) async -> AChatItem? { - let r = await chatSendCmd(.cancelFile(fileId: fileId)) +func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? { + let r = await chatSendCmd(.cancelFile(fileId: fileId), ctrl) switch r { case let .sndFileCancelled(_, chatItem, _, _) : return chatItem case let .rcvFileCancelled(_, chatItem, _) : return chatItem @@ -1094,8 +1140,8 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { } } -private func sendCommandOkResp(_ cmd: ChatCommand) async throws { - let r = await chatSendCmd(cmd) +private func sendCommandOkResp(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) async throws { + let r = await chatSendCmd(cmd, ctrl) if case .cmdOk = r { return } throw r } @@ -1336,6 +1382,16 @@ func startChat(refreshInvitations: Bool = true) throws { chatLastStartGroupDefault.set(Date.now) } +func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { + logger.debug("startChatWithTemporaryDatabase") + let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl) + try setNetworkConfig(getNetCfg(), ctrl: ctrl) + try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl) + try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl) + _ = try apiStartChat(ctrl: ctrl) + return migrationActiveUser +} + func changeActiveUser(_ userId: Int64, viewPwd: String?) { do { try changeActiveUser_(userId, viewPwd: viewPwd) @@ -1714,27 +1770,37 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .rcvFileSndCancelled(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } - case let .rcvFileProgressXFTP(user, aChatItem, _, _): - await chatItemSimpleUpdate(user, aChatItem) - case let .rcvFileError(user, aChatItem): - await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupFile(aChatItem) } + case let .rcvFileProgressXFTP(user, aChatItem, _, _, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + } + case let .rcvFileError(user, aChatItem, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + Task { cleanupFile(aChatItem) } + } case let .sndFileStart(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) case let .sndFileComplete(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupDirectFile(aChatItem) } case let .sndFileRcvCancelled(user, aChatItem, _): - await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupDirectFile(aChatItem) } + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + Task { cleanupDirectFile(aChatItem) } + } case let .sndFileProgressXFTP(user, aChatItem, _, _, _): - await chatItemSimpleUpdate(user, aChatItem) + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + } case let .sndFileCompleteXFTP(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } - case let .sndFileError(user, aChatItem): - await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupFile(aChatItem) } + case let .sndFileError(user, aChatItem, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + Task { cleanupFile(aChatItem) } + } case let .callInvitation(invitation): await MainActor.run { m.callInvitations[invitation.contact.id] = invitation diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index e5b98589a0..7d69466c07 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -44,7 +44,12 @@ struct SimpleXApp: App { chatModel.appOpenUrl = url } .onAppear() { - if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil { + // Present screen for continue migration if it wasn't finished yet + if chatModel.migrationState != nil { + // It's important, otherwise, user may be locked in undefined state + onboardingStageDefault.set(.step1_SimpleXInfo) + chatModel.onboardingStage = onboardingStageDefault.get() + } else if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift index 57007fff3f..86acbf6d54 100644 --- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -35,7 +35,7 @@ struct ContactPreferencesView: View { .disabled(currentFeaturesAllowed == featuresAllowed) } } - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if currentFeaturesAllowed == featuresAllowed { dismiss() } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index d88bdfa4a4..7ab4bf4ece 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -48,7 +48,7 @@ struct GroupPreferencesView: View { preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl } } - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if currentPreferences == preferences { dismiss() } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index d6dbf06efc..00d4f8c37b 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -24,7 +24,7 @@ struct GroupWelcomeView: View { VStack { if groupInfo.canEdit { editorView() - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if welcomeTextUnchanged() { dismiss() } else { diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 90cd17fbb3..4031c3e00a 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -36,6 +36,7 @@ enum DatabaseEncryptionAlert: Identifiable { struct DatabaseEncryptionView: View { @EnvironmentObject private var m: ChatModel @Binding var useKeychain: Bool + var migration: Bool @State private var alert: DatabaseEncryptionAlert? = nil @State private var progressIndicator = false @State private var useKeychainToggle = storeDBPassphraseGroupDefault.get() @@ -48,7 +49,12 @@ struct DatabaseEncryptionView: View { var body: some View { ZStack { - databaseEncryptionView() + List { + if migration { + chatStoppedView() + } + databaseEncryptionView() + } if progressIndicator { ProgressView().scaleEffect(2) } @@ -56,72 +62,71 @@ struct DatabaseEncryptionView: View { } private func databaseEncryptionView() -> some View { - List { - Section { - settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) { - Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle) + Section { + settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) { + Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle) .onChange(of: useKeychainToggle) { _ in if useKeychainToggle { setUseKeychain(true) - } else if storedKey { + } else if storedKey && !migration { + // Don't show in migration process since it will remove the key after successfull encryption alert = .keychainRemoveKey } else { setUseKeychain(false) } } - .disabled(initialRandomDBPassphrase) - } + .disabled(initialRandomDBPassphrase && !migration) + } - if !initialRandomDBPassphrase && m.chatDbEncrypted == true { - PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) - } + if !initialRandomDBPassphrase && m.chatDbEncrypted == true { + PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) + } - PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) - PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) + PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) + PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) - settingsRow("lock.rotation") { - Button("Update database passphrase") { - alert = currentKey == "" - ? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) - : (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey) - } + settingsRow("lock.rotation") { + Button(migration ? "Set passphrase" : "Update database passphrase") { + alert = currentKey == "" + ? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) + : (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey) } - .disabled( - (m.chatDbEncrypted == true && currentKey == "") || - currentKey == newKey || - newKey != confirmNewKey || - newKey == "" || - !validKey(currentKey) || - !validKey(newKey) - ) - } header: { - Text("") - } footer: { - VStack(alignment: .leading, spacing: 16) { - if m.chatDbEncrypted == false { - Text("Your chat database is not encrypted - set passphrase to encrypt it.") - } else if useKeychain { - if storedKey { - Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.") - if initialRandomDBPassphrase { - Text("Database is encrypted using a random passphrase, you can change it.") - } else { - Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") - } + } + .disabled( + (m.chatDbEncrypted == true && currentKey == "") || + currentKey == newKey || + newKey != confirmNewKey || + newKey == "" || + !validKey(currentKey) || + !validKey(newKey) + ) + } header: { + Text(migration ? "Database passphrase" : "") + } footer: { + VStack(alignment: .leading, spacing: 16) { + if m.chatDbEncrypted == false { + Text("Your chat database is not encrypted - set passphrase to encrypt it.") + } else if useKeychain { + if storedKey { + Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.") + if initialRandomDBPassphrase && !migration { + Text("Database is encrypted using a random passphrase, you can change it.") } else { - Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.") + Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") } } else { - Text("You have to enter passphrase every time the app starts - it is not stored on the device.") - Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") - if m.notificationMode == .instant && m.notificationPreview != .hidden { - Text("**Warning**: Instant push notifications require passphrase saved in Keychain.") - } + Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.") + } + } else { + Text("You have to enter passphrase every time the app starts - it is not stored on the device.") + Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") + if m.notificationMode == .instant && m.notificationPreview != .hidden && !migration { + Text("**Warning**: Instant push notifications require passphrase saved in Keychain.") } } - .padding(.top, 1) - .font(.callout) } + .padding(.top, 1) + .font(.callout) } .onAppear { if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" } @@ -136,9 +141,15 @@ struct DatabaseEncryptionView: View { do { encryptionStartedDefault.set(true) encryptionStartedAtDefault.set(Date.now) + if !m.chatDbChanged { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) + } try await apiStorageEncryption(currentKey: currentKey, newKey: newKey) encryptionStartedDefault.set(false) initialRandomDBPassphraseGroupDefault.set(false) + if migration { + storeDBPassphraseGroupDefault.set(useKeychain) + } if useKeychain { if kcDatabasePassword.set(newKey) { await resetFormAfterEncryption(true) @@ -148,6 +159,9 @@ struct DatabaseEncryptionView: View { await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain")) } } else { + if migration { + removePassphraseFromKeyChain() + } await resetFormAfterEncryption() await operationEnded(.databaseEncrypted) } @@ -174,7 +188,10 @@ struct DatabaseEncryptionView: View { private func setUseKeychain(_ value: Bool) { useKeychain = value - storeDBPassphraseGroupDefault.set(value) + // Postpone it when migrating to the end of encryption process + if !migration { + storeDBPassphraseGroupDefault.set(value) + } } private func databaseEncryptionAlert(_ alertItem: DatabaseEncryptionAlert) -> Alert { @@ -184,13 +201,7 @@ struct DatabaseEncryptionView: View { title: Text("Remove passphrase from keychain?"), message: Text("Instant push notifications will be hidden!\n") + storeSecurelyDanger(), primaryButton: .destructive(Text("Remove")) { - if kcDatabasePassword.remove() { - logger.debug("passphrase removed from keychain") - setUseKeychain(false) - storedKey = false - } else { - alert = .error(title: "Keychain error", error: "Failed to remove passphrase") - } + removePassphraseFromKeyChain() }, secondaryButton: .cancel() { withAnimation { useKeychainToggle = true } @@ -236,6 +247,16 @@ struct DatabaseEncryptionView: View { } } + private func removePassphraseFromKeyChain() { + if kcDatabasePassword.remove() { + logger.debug("passphrase removed from keychain") + setUseKeychain(false) + storedKey = false + } else { + alert = .error(title: "Keychain error", error: "Failed to remove passphrase") + } + } + private func storeSecurelySaved() -> Text { Text("Please store passphrase securely, you will NOT be able to change it if you lose it.") } @@ -346,6 +367,6 @@ func validKey(_ s: String) -> Bool { struct DatabaseEncryptionView_Previews: PreviewProvider { static var previews: some View { - DatabaseEncryptionView(useKeychain: Binding.constant(true)) + DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false) } } diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 52ded44782..f8d282a6d1 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -64,7 +64,7 @@ struct DatabaseErrorView: View { case let .migrationError(mtrError): titleText("Incompatible database version") fileNameText(dbFile) - Text("Error: ") + Text(mtrErrorDescription(mtrError)) + Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError)) } case let .errorSQL(dbFile, migrationSQLError): titleText("Database error") @@ -105,7 +105,7 @@ struct DatabaseErrorView: View { Text("Migrations: \(ms.joined(separator: ", "))") } - private func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey { + static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey { switch err { case let .noDown(dbMigrations): return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))" diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 31b1f618e3..2e0cd7738f 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -116,7 +116,7 @@ struct DatabaseView: View { let color: Color = unencrypted ? .orange : .secondary settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) { NavigationLink { - DatabaseEncryptionView(useKeychain: $useKeychain) + DatabaseEncryptionView(useKeychain: $useKeychain, migration: false) .navigationTitle("Database passphrase") } label: { Text("Database passphrase") @@ -485,6 +485,10 @@ func deleteChatAsync() async throws { _ = kcDatabasePassword.remove() storeDBPassphraseGroupDefault.set(true) deleteAppDatabaseAndFiles() + // Clean state so when creating new user the app will start chat automatically (see CreateProfile:createProfile()) + DispatchQueue.main.async { + ChatModel.shared.users = [] + } } struct DatabaseView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 046929a9d0..ae6af24f53 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -188,6 +188,7 @@ struct MigrateToAppGroupView: View { let config = ArchiveConfig(archivePath: getDocumentsDirectory().appendingPathComponent(archiveName).path) Task { do { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) try await apiExportArchive(config: config) await MainActor.run { setV3DBMigration(.exported) } } catch let error { @@ -204,7 +205,11 @@ struct MigrateToAppGroupView: View { resetChatCtrl() try await MainActor.run { try initializeChat(start: false) } let _ = try await apiImportArchive(config: config) - await MainActor.run { setV3DBMigration(.migrated) } + let appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport()) + await MainActor.run { + appSettings.importIntoApp() + setV3DBMigration(.migrated) + } } catch let error { dbContainerGroupDefault.set(.documents) await MainActor.run { @@ -216,16 +221,22 @@ struct MigrateToAppGroupView: View { } } -func exportChatArchive() async throws -> URL { +func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL { let archiveTime = Date.now let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted)) let archiveName = "simplex-chat.\(ts).zip" - let archivePath = getDocumentsDirectory().appendingPathComponent(archiveName) + let archivePath = (storagePath ?? getDocumentsDirectory()).appendingPathComponent(archiveName) let config = ArchiveConfig(archivePath: archivePath.path) + // Settings should be saved before changing a passphrase, otherwise the database needs to be migrated first + if !ChatModel.shared.chatDbChanged { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) + } try await apiExportArchive(config: config) - deleteOldArchive() - UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) - chatArchiveTimeDefault.set(archiveTime) + if storagePath == nil { + deleteOldArchive() + UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) + chatArchiveTimeDefault.set(archiveTime) + } return archivePath } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift new file mode 100644 index 0000000000..9022beebd3 --- /dev/null +++ b/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift @@ -0,0 +1,720 @@ +// +// MigrateFromAnotherDevice.swift +// SimpleX (iOS) +// +// Created by Avently on 23.02.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum MigrationFromAnotherDeviceState: Codable, Equatable { + case downloadProgress(link: String, archiveName: String) + case archiveImport(archiveName: String) + case passphrase + + func makeMigrationState() -> MigrationFromState { + var initial: MigrationFromState = .pasteOrScanLink + //logger.debug("Inited with migrationState: \(String(describing: self))") + switch self { + case let .downloadProgress(link, archiveName): + // iOS changes absolute directory every launch, check this way + let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName + initial = .downloadFailed(totalBytes: 0, link: link, archivePath: archivePath) + case let .archiveImport(archiveName): + let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName + initial = .archiveImportFailed(archivePath: archivePath) + case .passphrase: + initial = .passphrase(passphrase: "") + } + return initial + } + + // Here we check whether it's needed to show migration process after app restart or not + // It's important to NOT show the process when archive was corrupted/not fully downloaded + static func transform() -> MigrationFromAnotherDeviceState? { + let state: MigrationFromAnotherDeviceState? = UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE) != nil ? decodeJSON(UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE)!) : nil + + if case let .downloadProgress(_, archiveName) = state { + // iOS changes absolute directory every launch, check this way + let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName + try? FileManager.default.removeItem(atPath: archivePath) + UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE) + // No migration happens at the moment actually since archive were not downloaded fully + logger.debug("MigrateFromDevice: archive wasn't fully downloaded, removed broken file") + return nil + } + return state + } + + static func save(_ state: MigrationFromAnotherDeviceState?, apply: (MigrationFromAnotherDeviceState?) -> Void) { + if let state { + UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_STAGE) + } else { + UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE) + } + apply(state) + } +} + +enum MigrationFromState: Equatable { + case pasteOrScanLink + case linkDownloading(link: String) + case downloadProgress(downloadedBytes: Int64, totalBytes: Int64, fileId: Int64, link: String, archivePath: String, ctrl: chat_ctrl?) + case downloadFailed(totalBytes: Int64, link: String, archivePath: String) + case archiveImport(archivePath: String) + case archiveImportFailed(archivePath: String) + case passphrase(passphrase: String) + case migrationConfirmation(status: DBMigrationResult, passphrase: String, useKeychain: Bool) + case migration(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Bool) + case onion(appSettings: AppSettings) +} + +private enum MigrateFromAnotherDeviceViewAlert: Identifiable { + case chatImportedWithErrors(title: LocalizedStringKey = "Chat database imported", + text: LocalizedStringKey = "Some non-fatal errors occurred during import - you may see Chat console for more details.") + + case wrongPassphrase(title: LocalizedStringKey = "Wrong passphrase!", message: LocalizedStringKey = "Enter correct passphrase.") + case invalidConfirmation(title: LocalizedStringKey = "Invalid migration confirmation") + case keychainError(_ title: LocalizedStringKey = "Keychain error") + case databaseError(_ title: LocalizedStringKey = "Database error", message: String) + case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String) + + case error(title: LocalizedStringKey, error: String = "") + + var id: String { + switch self { + case .chatImportedWithErrors: return "chatImportedWithErrors" + + case .wrongPassphrase: return "wrongPassphrase" + case .invalidConfirmation: return "invalidConfirmation" + case .keychainError: return "keychainError" + case let .databaseError(title, message): return "\(title) \(message)" + case let .unknownError(title, message): return "\(title) \(message)" + + case let .error(title, _): return "error \(title)" + } + } +} + +struct MigrateFromAnotherDevice: View { + @EnvironmentObject var m: ChatModel + @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @State var migrationState: MigrationFromState + @State private var useKeychain = storeDBPassphraseGroupDefault.get() + @State private var alert: MigrateFromAnotherDeviceViewAlert? + private let tempDatabaseUrl = urlForTemporaryDatabase() + @State private var chatReceiver: MigrationChatReceiver? = nil + // Prevent from hiding the view until migration is finished or app deleted + @State private var backDisabled: Bool = false + @State private var showQRCodeScanner: Bool = true + + var body: some View { + VStack { + switch migrationState { + case .pasteOrScanLink: + pasteOrScanLinkView() + case let .linkDownloading(link): + linkDownloadingView(link) + case let .downloadProgress(downloaded, total, _, _, _, _): + downloadProgressView(downloaded, totalBytes: total) + case let .downloadFailed(total, link, archivePath): + downloadFailedView(totalBytes: total, link, archivePath) + case let .archiveImport(archivePath): + archiveImportView(archivePath) + case let .archiveImportFailed(archivePath): + archiveImportFailedView(archivePath) + case let .passphrase(passphrase): + PassphraseEnteringView(migrationState: $migrationState, currentKey: passphrase, alert: $alert) + case let .migrationConfirmation(status, passphrase, useKeychain): + migrationConfirmationView(status, passphrase, useKeychain) + case let .migration(passphrase, confirmation, useKeychain): + migrationView(passphrase, confirmation, useKeychain) + case let .onion(appSettings): + OnionView(appSettings: appSettings, finishMigration: finishMigration) + } + } + .onAppear { + backDisabled = switch migrationState { + case .linkDownloading: false + case .downloadProgress: false + case .archiveImportFailed: false + default: m.migrationState != nil + } + } + .onChange(of: migrationState) { state in + backDisabled = switch state { + case .linkDownloading: false + case .downloadProgress: false + case .archiveImportFailed: false + default: m.migrationState != nil + } + } + .onDisappear { + Task { + if case .archiveImportFailed = migrationState { + // Original database is not exist, nothing is setup correctly for showing to a user yet. Return to clean state + deleteAppDatabaseAndFiles() + initChatAndMigrate() + } else if case let .downloadProgress(_, _, fileId, _, _, ctrl) = migrationState, let ctrl { + await stopArchiveDownloading(fileId, ctrl) + } + chatReceiver?.stopAndCleanUp() + if !backDisabled { + try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) + MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + } + } + } + .alert(item: $alert) { alert in + switch alert { + case let .chatImportedWithErrors(title, text): + return Alert(title: Text(title), message: Text(text)) + case let .wrongPassphrase(title, message): + return Alert(title: Text(title), message: Text(message)) + case let .invalidConfirmation(title): + return Alert(title: Text(title)) + case let .keychainError(title): + return Alert(title: Text(title)) + case let .databaseError(title, message): + return Alert(title: Text(title), message: Text(message)) + case let .unknownError(title, message): + return Alert(title: Text(title), message: Text(message)) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + } + } + .interactiveDismissDisabled(backDisabled) + } + + private func pasteOrScanLinkView() -> some View { + ZStack { + List { + Section("Scan QR code") { + ScannerInView(showQRCodeScanner: $showQRCodeScanner) { resp in + switch resp { + case let .success(r): + let link = r.string + if strHasSimplexFileLink(link.trimmingCharacters(in: .whitespaces)) { + migrationState = .linkDownloading(link: link.trimmingCharacters(in: .whitespaces)) + } else { + alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.") + } + case let .failure(e): + logger.error("processQRCode QR code error: \(e.localizedDescription)") + alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.") + } + } + } + if developerTools { + Section("Or paste archive link") { + pasteLinkView() + } + } + } + } + } + + private func pasteLinkView() -> some View { + Button { + if let str = UIPasteboard.general.string { + if strHasSimplexFileLink(str.trimmingCharacters(in: .whitespaces)) { + migrationState = .linkDownloading(link: str.trimmingCharacters(in: .whitespaces)) + } else { + alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.") + } + } + } label: { + Text("Tap to paste link") + } + .disabled(!ChatModel.shared.pasteboardHasStrings) + .frame(maxWidth: .infinity, alignment: .center) + } + + private func linkDownloadingView(_ link: String) -> some View { + ZStack { + List { + Section {} header: { + Text("Downloading link details") + } + } + progressView() + } + .onAppear { + downloadLinkDetails(link) + } + } + + private func downloadProgressView(_ downloadedBytes: Int64, totalBytes: Int64) -> some View { + ZStack { + List { + Section {} header: { + Text("Downloading archive") + } + } + let ratio = Float(downloadedBytes) / Float(max(totalBytes, 1)) + MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded") + } + } + + private func downloadFailedView(totalBytes: Int64, _ link: String, _ archivePath: String) -> some View { + List { + Section { + Button(action: { + try? FileManager.default.removeItem(atPath: archivePath) + migrationState = .linkDownloading(link: link) + }) { + settingsRow("tray.and.arrow.down") { + Text("Repeat download").foregroundColor(.accentColor) + } + } + } header: { + Text("Download failed") + } footer: { + Text("You can give another try.") + .font(.callout) + } + } + .onAppear { + chatReceiver?.stopAndCleanUp() + try? FileManager.default.removeItem(atPath: archivePath) + MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + } + } + + private func archiveImportView(_ archivePath: String) -> some View { + ZStack { + List { + Section {} header: { + Text("Importing archive") + } + } + progressView() + } + .onAppear { + importArchive(archivePath) + } + } + + private func archiveImportFailedView(_ archivePath: String) -> some View { + List { + Section { + Button(action: { + migrationState = .archiveImport(archivePath: archivePath) + }) { + settingsRow("square.and.arrow.down") { + Text("Repeat import").foregroundColor(.accentColor) + } + } + } header: { + Text("Import failed") + } footer: { + Text("You can give another try.") + .font(.callout) + } + } + } + + private func migrationConfirmationView(_ status: DBMigrationResult, _ passphrase: String, _ useKeychain: Bool) -> some View { + List { + let (header, button, footer, confirmation): (LocalizedStringKey, LocalizedStringKey?, String, MigrationConfirmation?) = switch status { + case let .errorMigration(_, migrationError): + switch migrationError { + case .upgrade: + ("Database upgrade", + "Upgrade and open chat", + "", + .yesUp) + case .downgrade: + ("Database downgrade", + "Downgrade and open chat", + NSLocalizedString("Warning: you may lose some data!", comment: ""), + .yesUpDown) + case let .migrationError(mtrError): + ("Incompatible database version", + nil, + "\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))", + nil) + } + default: ("Error", nil, "Unknown error", nil) + } + Section { + if let button, let confirmation { + Button(action: { + migrationState = .migration(passphrase: passphrase, confirmation: confirmation, useKeychain: useKeychain) + }) { + settingsRow("square.and.arrow.down") { + Text(button).foregroundColor(.accentColor) + } + } + } else { + EmptyView() + } + } header: { + Text(header) + } footer: { + Text(footer) + .font(.callout) + } + } + } + + private func migrationView(_ passphrase: String, _ confirmation: MigrationConfirmation, _ useKeychain: Bool) -> some View { + ZStack { + List { + Section {} header: { + Text("Migrating") + } + } + progressView() + } + .onAppear { + startChat(passphrase, confirmation, useKeychain) + } + } + + struct OnionView: View { + @State var appSettings: AppSettings + @State private var onionHosts: OnionHosts = .no + var finishMigration: (AppSettings) -> Void + + var body: some View { + List { + Section { + Button(action: { + var updated = appSettings.networkConfig! + let (hostMode, requiredHostMode) = onionHosts.hostMode + updated.hostMode = hostMode + updated.requiredHostMode = requiredHostMode + updated.socksProxy = nil + appSettings.networkConfig = updated + finishMigration(appSettings) + }) { + settingsRow("checkmark") { + Text("Apply").foregroundColor(.accentColor) + } + } + } header: { + Text("Confirm network settings") + } footer: { + Text("Please confirm that network settings are correct for this device.") + .font(.callout) + } + + Section("Network settings") { + Picker("Use .onion hosts", selection: $onionHosts) { + ForEach(OnionHosts.values, id: \.self) { Text($0.text) } + } + .frame(height: 36) + } + } + } + } + + private func downloadLinkDetails(_ link: String) { + let archiveTime = Date.now + let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted)) + let archiveName = "simplex-chat.\(ts).zip" + let archivePath = getMigrationTempFilesDirectory().appendingPathComponent(archiveName) + + startDownloading(0, link, archivePath.path) + } + + private func initTemporaryDatabase() -> (chat_ctrl, User)? { + let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl) + showErrorOnMigrationIfNeeded(status, $alert) + do { + if let ctrl, let user = try startChatWithTemporaryDatabase(ctrl: ctrl) { + return (ctrl, user) + } + } catch let error { + logger.error("Error while starting chat in temporary database: \(error.localizedDescription)") + } + return nil + } + + private func startDownloading(_ totalBytes: Int64, _ link: String, _ archivePath: String) { + Task { + guard let ctrlAndUser = initTemporaryDatabase() else { + return migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) + } + let (ctrl, user) = ctrlAndUser + chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in + await MainActor.run { + switch msg { + case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer): + migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl) + MigrationFromAnotherDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 } + case .rcvStandaloneFileComplete: + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + migrationState = .archiveImport(archivePath: archivePath) + MigrationFromAnotherDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 } + } + case .rcvFileError: + alert = .error(title: "Download failed", error: "File was deleted or link is invalid") + migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) + default: + logger.debug("unsupported event: \(msg.responseType)") + } + } + } + chatReceiver?.start() + + let (res, error) = await downloadStandaloneFile(user: user, url: link, file: CryptoFile.plain(URL(fileURLWithPath: archivePath).lastPathComponent), ctrl: ctrl) + if res == nil { + await MainActor.run { + migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) + } + return alert = .error(title: "Error downloading the archive", error: error ?? "") + } + } + } + + private func importArchive(_ archivePath: String) { + Task { + do { + if !hasChatCtrl() { + chatInitControllerRemovingDatabases() + } + try await apiDeleteStorage() + do { + let config = ArchiveConfig(archivePath: archivePath) + let archiveErrors = try await apiImportArchive(config: config) + if !archiveErrors.isEmpty { + alert = .chatImportedWithErrors() + } + await MainActor.run { + migrationState = .passphrase(passphrase: "") + MigrationFromAnotherDeviceState.save(.passphrase) { m.migrationState = $0 } + } + } catch let error { + await MainActor.run { + migrationState = .archiveImportFailed(archivePath: archivePath) + } + alert = .error(title: "Error importing chat database", error: responseError(error)) + } + } catch let error { + await MainActor.run { + migrationState = .archiveImportFailed(archivePath: archivePath) + } + alert = .error(title: "Error deleting chat database", error: responseError(error)) + } + } + } + + + private func stopArchiveDownloading(_ fileId: Int64, _ ctrl: chat_ctrl) async { + _ = await apiCancelFile(fileId: fileId, ctrl: ctrl) + } + + private func startChat(_ passphrase: String, _ confirmation: MigrationConfirmation, _ useKeychain: Bool) { + if useKeychain { + _ = kcDatabasePassword.set(passphrase) + } else { + _ = kcDatabasePassword.remove() + } + storeDBPassphraseGroupDefault.set(useKeychain) + initialRandomDBPassphraseGroupDefault.set(false) + AppChatState.shared.set(.active) + Task { + do { + resetChatCtrl() + try initializeChat(start: false, confirmStart: false, dbKey: passphrase, refreshInvitations: true, confirmMigrations: confirmation) + var appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport()) + await MainActor.run { + if appSettings.networkConfig?.hostMode == .onionViaSocks || appSettings.networkConfig?.hostMode == .onionHost || appSettings.networkConfig?.socksProxy != nil { + appSettings.networkConfig?.socksProxy = nil + appSettings.networkConfig?.hostMode = .publicHost + appSettings.networkConfig?.requiredHostMode = true + migrationState = .onion(appSettings: appSettings) + } else { + finishMigration(appSettings) + } + } + } catch let error { + hideView() + AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error)))) + } + } + } + + private func finishMigration(_ appSettings: AppSettings) { + do { + try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) + MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + appSettings.importIntoApp() + try SimpleX.startChat(refreshInvitations: true) + AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.") + } catch let error { + AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error)))) + } + hideView() + } + + private func hideView() { + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete + dismiss() + } + + private func strHasSimplexFileLink(_ text: String) -> Bool { + text.starts(with: "simplex:/file") || text.starts(with: "https://simplex.chat/file") + } + + private static func urlForTemporaryDatabase() -> URL { + URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true)) + } +} + +private struct PassphraseEnteringView: View { + @Binding var migrationState: MigrationFromState + @State private var useKeychain = true + @State var currentKey: String + @State private var verifyingPassphrase: Bool = false + @FocusState private var keyboardVisible: Bool + @Binding var alert: MigrateFromAnotherDeviceViewAlert? + + var body: some View { + ZStack { + List { + Section { + settingsRow("key", color: .secondary) { + Toggle("Save passphrase in Keychain", isOn: $useKeychain) + } + + PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) + .focused($keyboardVisible) + Button(action: { + verifyingPassphrase = true + hideKeyboard() + Task { + let (status, _) = chatInitTemporaryDatabase(url: getAppDatabasePath(), key: currentKey, confirmation: .yesUp) + let success = switch status { + case .ok, .invalidConfirmation: true + default: false + } + if success { + await MainActor.run { + migrationState = .migration(passphrase: currentKey, confirmation: .yesUp, useKeychain: useKeychain) + } + } else if case .errorMigration = status { + await MainActor.run { + migrationState = .migrationConfirmation(status: status, passphrase: currentKey, useKeychain: useKeychain) + } + } else { + showErrorOnMigrationIfNeeded(status, $alert) + } + verifyingPassphrase = false + } + }) { + settingsRow("key", color: .secondary) { + Text("Open chat") + } + } + .disabled(verifyingPassphrase || currentKey.isEmpty) + } header: { + Text("Enter passphrase") + } footer: { + VStack(alignment: .leading, spacing: 16) { + if useKeychain { + Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.") + } else { + Text("You have to enter passphrase every time the app starts - it is not stored on the device.") + Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") + Text("**Warning**: Instant push notifications require passphrase saved in Keychain.") + } + } + .font(.callout) + .padding(.top, 1) + .onTapGesture { keyboardVisible = false } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + keyboardVisible = true + } + } + } + if verifyingPassphrase { + progressView() + } + } + } +} + +private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { + switch status { + case .invalidConfirmation: + alert.wrappedValue = .invalidConfirmation() + case .errorNotADatabase: + alert.wrappedValue = .wrongPassphrase() + case .errorKeychain: + alert.wrappedValue = .keychainError() + case let .errorSQL(_, error): + alert.wrappedValue = .databaseError(message: error) + case let .unknown(error): + alert.wrappedValue = .unknownError(message: error) + case .errorMigration: () + case .ok: () + } +} + +private func progressView() -> some View { + VStack { + ProgressView().scaleEffect(2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity ) +} + +private class MigrationChatReceiver { + let ctrl: chat_ctrl + let databaseUrl: URL + let processReceivedMsg: (ChatResponse) async -> Void + private var receiveLoop: Task? + private var receiveMessages = true + + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { + self.ctrl = ctrl + self.databaseUrl = databaseUrl + self.processReceivedMsg = processReceivedMsg + } + + func start() { + logger.debug("MigrationChatReceiver.start") + receiveMessages = true + if receiveLoop != nil { return } + receiveLoop = Task { await receiveMsgLoop() } + } + + func receiveMsgLoop() async { + // TODO use function that has timeout + if let msg = await chatRecvMsg(ctrl) { + Task { + await TerminalItems.shared.add(.resp(.now, msg)) + } + logger.debug("processReceivedMsg: \(msg.responseType)") + await processReceivedMsg(msg) + } + if self.receiveMessages { + _ = try? await Task.sleep(nanoseconds: 7_500_000) + await receiveMsgLoop() + } + } + + func stopAndCleanUp() { + logger.debug("MigrationChatReceiver.stop") + receiveMessages = false + receiveLoop?.cancel() + receiveLoop = nil + chat_close_store(ctrl) + try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_chat.db") + try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_agent.db") + } +} + +struct MigrateFromAnotherDevice_Previews: PreviewProvider { + static var previews: some View { + MigrateFromAnotherDevice(migrationState: .pasteOrScanLink) + } +} diff --git a/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift new file mode 100644 index 0000000000..01a85aa6db --- /dev/null +++ b/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift @@ -0,0 +1,727 @@ +// +// MigrateToAnotherDevice.swift +// SimpleX (iOS) +// +// Created by Avently on 14.02.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private enum MigrationToState: Equatable { + case chatStopInProgress + case chatStopFailed(reason: String) + case passphraseNotSet + case passphraseConfirmation + case uploadConfirmation + case archiving + case uploadProgress(uploadedBytes: Int64, totalBytes: Int64, fileId: Int64, archivePath: URL, ctrl: chat_ctrl?) + case uploadFailed(totalBytes: Int64, archivePath: URL) + case linkCreation + case linkShown(fileId: Int64, link: String, archivePath: URL, ctrl: chat_ctrl) + case finished(chatDeletion: Bool) +} + +private enum MigrateToAnotherDeviceViewAlert: Identifiable { + case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.") + case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures") + + case wrongPassphrase(title: LocalizedStringKey = "Wrong passphrase!", message: LocalizedStringKey = "Enter correct passphrase.") + case invalidConfirmation(title: LocalizedStringKey = "Invalid migration confirmation") + case keychainError(_ title: LocalizedStringKey = "Keychain error") + case databaseError(_ title: LocalizedStringKey = "Database error", message: String) + case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String) + + case error(title: LocalizedStringKey, error: String = "") + + var id: String { + switch self { + case let .deleteChat(title, text): return "\(title) \(text)" + case let .startChat(title, text): return "\(title) \(text)" + + case .wrongPassphrase: return "wrongPassphrase" + case .invalidConfirmation: return "invalidConfirmation" + case .keychainError: return "keychainError" + case let .databaseError(title, message): return "\(title) \(message)" + case let .unknownError(title, message): return "\(title) \(message)" + + case let .error(title, _): return "error \(title)" + } + } +} + +struct MigrateToAnotherDevice: View { + @EnvironmentObject var m: ChatModel + @Environment(\.dismiss) var dismiss: DismissAction + @Binding var showSettings: Bool + @Binding var showProgressOnSettings: Bool + @State private var migrationState: MigrationToState = .chatStopInProgress + @State private var useKeychain = storeDBPassphraseGroupDefault.get() + @AppStorage(GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE, store: groupDefaults) private var initialRandomDBPassphrase: Bool = false + @State private var alert: MigrateToAnotherDeviceViewAlert? + @State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) + private let tempDatabaseUrl = urlForTemporaryDatabase() + @State private var chatReceiver: MigrationChatReceiver? = nil + @State private var backDisabled: Bool = false + + var body: some View { + if authorized { + migrateView() + } else { + Button(action: runAuth) { Label("Unlock", systemImage: "lock") } + .onAppear(perform: runAuth) + } + } + + private func runAuth() { authorize(NSLocalizedString("Open migration to another device", comment: "authentication reason"), $authorized) } + + func migrateView() -> some View { + VStack { + switch migrationState { + case .chatStopInProgress: + chatStopInProgressView() + case let .chatStopFailed(reason): + chatStopFailedView(reason) + case .passphraseNotSet: + passphraseNotSetView() + case .passphraseConfirmation: + PassphraseConfirmationView(migrationState: $migrationState, alert: $alert) + case .uploadConfirmation: + uploadConfirmationView() + case .archiving: + archivingView() + case let .uploadProgress(uploaded, total, _, archivePath, _): + uploadProgressView(uploaded, totalBytes: total, archivePath) + case let .uploadFailed(total, archivePath): + uploadFailedView(totalBytes: total, archivePath) + case .linkCreation: + linkCreationView() + case let .linkShown(fileId, link, archivePath, ctrl): + linkShownView(fileId, link, archivePath, ctrl) + case let .finished(chatDeletion): + finishedView(chatDeletion) + } + } + .modifier(BackButton(label: "Back", disabled: $backDisabled) { + dismiss() + }) + .onChange(of: migrationState) { state in + backDisabled = switch migrationState { + case .archiving: true + case .linkCreation: true + case .linkShown: true + case .finished: true + default: false + } + } + .onAppear { + stopChat() + } + .onDisappear { + Task { + if case .linkCreation = migrationState {} else if case .linkShown = migrationState {} else if case .finished = migrationState {} else { + await MainActor.run { + showProgressOnSettings = true + } + await startChatAndDismiss(false) + await MainActor.run { + showProgressOnSettings = false + } + } + if case let .uploadProgress(_, _, fileId, _, ctrl) = migrationState, let ctrl { + await cancelUploadedArchive(fileId, ctrl) + } + chatReceiver?.stopAndCleanUp() + try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) + } + } + .alert(item: $alert) { alert in + switch alert { + case let .startChat(title, text): + return Alert( + title: Text(title), + message: Text(text), + primaryButton: .destructive(Text("Start chat")) { + Task { + await startChatAndDismiss() + } + }, + secondaryButton: .cancel() + ) + case let .deleteChat(title, text): + return Alert( + title: Text(title), + message: Text(text), + primaryButton: .destructive(Text("Delete")) { + deleteChatAndDismiss() + }, + secondaryButton: .cancel() + ) + case let .wrongPassphrase(title, message): + return Alert(title: Text(title), message: Text(message)) + case let .invalidConfirmation(title): + return Alert(title: Text(title)) + case let .keychainError(title): + return Alert(title: Text(title)) + case let .databaseError(title, message): + return Alert(title: Text(title), message: Text(message)) + case let .unknownError(title, message): + return Alert(title: Text(title), message: Text(message)) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + } + } + .interactiveDismissDisabled(backDisabled) + } + + private func chatStopInProgressView() -> some View { + ZStack { + List { + Section {} header: { + Text("Stopping chat") + } + } + progressView() + } + } + + private func chatStopFailedView(_ reason: String) -> some View { + List { + Section { + Text(reason) + Button(action: stopChat) { + settingsRow("stop.fill") { + Text("Stop chat").foregroundColor(.red) + } + } + } header: { + Text("Error stopping chat") + } footer: { + Text("In order to continue, chat should be stopped.") + .font(.callout) + } + } + } + + private func passphraseNotSetView() -> some View { + DatabaseEncryptionView(useKeychain: $useKeychain, migration: true) + .onChange(of: initialRandomDBPassphrase) { initial in + if !initial { + migrationState = .uploadConfirmation + } + } + } + + private func uploadConfirmationView() -> some View { + List { + Section { + Button(action: { migrationState = .archiving }) { + settingsRow("tray.and.arrow.up") { + Text("Archive and upload").foregroundColor(.accentColor) + } + } + } header: { + Text("Confirm upload") + } footer: { + Text("All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays.") + .font(.callout) + } + } + } + + private func archivingView() -> some View { + ZStack { + List { + Section {} header: { + Text("Archiving database") + } + } + progressView() + } + .onAppear { + exportArchive() + } + } + + private func uploadProgressView(_ uploadedBytes: Int64, totalBytes: Int64, _ archivePath: URL) -> some View { + ZStack { + List { + Section {} header: { + Text("Uploading archive") + } + } + let ratio = Float(uploadedBytes) / Float(totalBytes) + MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded") + } + .onAppear { + startUploading(totalBytes, archivePath) + } + } + + private func uploadFailedView(totalBytes: Int64, _ archivePath: URL) -> some View { + List { + Section { + Button(action: { + migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil) + }) { + settingsRow("tray.and.arrow.up") { + Text("Repeat upload").foregroundColor(.accentColor) + } + } + } header: { + Text("Upload failed") + } footer: { + Text("You can give another try.") + .font(.callout) + } + } + .onAppear { + chatReceiver?.stopAndCleanUp() + } + } + + private func linkCreationView() -> some View { + ZStack { + List { + Section {} header: { + Text("Creating archive link") + } + } + progressView() + } + } + + private func linkShownView(_ fileId: Int64, _ link: String, _ archivePath: URL, _ ctrl: chat_ctrl) -> some View { + List { + Section { + Button(action: { cancelMigration(fileId, ctrl) }) { + settingsRow("multiply") { + Text("Cancel migration").foregroundColor(.red) + } + } + Button(action: { finishMigration(fileId, ctrl) }) { + settingsRow("checkmark") { + Text("Finalize migration").foregroundColor(.accentColor) + } + } + } footer: { + Text("Choose _Migrate from another device_ on the new device and scan QR code.") + .font(.callout) + } + Section("Show QR code") { + SimpleXLinkQRCode(uri: link) + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + Section("Or securely share this file link") { + shareLinkView(link) + } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) + } + } + + private func finishedView(_ chatDeletion: Bool) -> some View { + ZStack { + List { + Section { + Button(action: { alert = .deleteChat() }) { + settingsRow("trash.fill") { + Text("Delete database from this device").foregroundColor(.accentColor) + } + } + Button(action: { alert = .startChat() }) { + settingsRow("play.fill") { + Text("Start chat").foregroundColor(.red) + } + } + } header: { + Text("Migration complete") + } footer: { + VStack(alignment: .leading, spacing: 16) { + Text("You **must not** use the same database on two devices.") + Text("**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection.") + } + .font(.callout) + } + } + if chatDeletion { + progressView() + } + } + } + + private func shareLinkView(_ link: String) -> some View { + HStack { + linkTextView(link) + Button { + showShareSheet(items: [link]) + } label: { + Image(systemName: "square.and.arrow.up") + .padding(.top, -7) + } + } + .frame(maxWidth: .infinity) + } + + private func linkTextView(_ link: String) -> some View { + Text(link) + .lineLimit(1) + .font(.caption) + .truncationMode(.middle) + } + + static func largeProgressView(_ value: Float, _ title: String, _ description: LocalizedStringKey) -> some View { + ZStack { + VStack { + Text(description) + .font(.title3) + .hidden() + + Text(title) + .font(.system(size: 54)) + .bold() + .foregroundColor(.accentColor) + + Text(description) + .font(.title3) + } + + Circle() + .trim(from: 0, to: CGFloat(value)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 27) + ) + .rotationEffect(.degrees(180)) + .animation(.linear, value: value) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.horizontal) + } + .frame(maxWidth: .infinity) + } + + private func stopChat() { + Task { + do { + try await stopChatAsync() + do { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) + await MainActor.run { + migrationState = initialRandomDBPassphraseGroupDefault.get() ? .passphraseNotSet : .passphraseConfirmation + } + } catch let error { + alert = .error(title: "Error saving settings", error: error.localizedDescription) + migrationState = .chatStopFailed(reason: NSLocalizedString("Error saving settings", comment: "when migrating")) + } + } catch let e { + await MainActor.run { + migrationState = .chatStopFailed(reason: e.localizedDescription) + } + } + } + } + + private func exportArchive() { + Task { + do { + try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true) + let archivePath = try await exportChatArchive(getMigrationTempFilesDirectory()) + if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path), + let totalBytes = attrs[.size] as? Int64 { + await MainActor.run { + migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil) + } + } else { + await MainActor.run { + alert = .error(title: "Exported file doesn't exist") + migrationState = .uploadConfirmation + } + } + } catch let error { + await MainActor.run { + alert = .error(title: "Error exporting chat database", error: responseError(error)) + migrationState = .uploadConfirmation + } + } + } + } + + private func initTemporaryDatabase() -> (chat_ctrl, User)? { + let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl) + showErrorOnMigrationIfNeeded(status, $alert) + do { + if let ctrl, let user = try startChatWithTemporaryDatabase(ctrl: ctrl) { + return (ctrl, user) + } + } catch let error { + logger.error("Error while starting chat in temporary database: \(error.localizedDescription)") + } + return nil + } + + private func startUploading(_ totalBytes: Int64, _ archivePath: URL) { + Task { + guard let ctrlAndUser = initTemporaryDatabase() else { + return migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) + } + let (ctrl, user) = ctrlAndUser + chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in + await MainActor.run { + switch msg { + case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize): + if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total { + migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl) + } + case .sndFileRedirectStartXFTP: + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + migrationState = .linkCreation + } + case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs): + let cfg = getNetCfg() + let data = MigrationFileLinkData.init( + networkConfig: MigrationFileLinkData.NetworkConfig( + socksProxy: cfg.socksProxy, + hostMode: cfg.hostMode, + requiredHostMode: cfg.requiredHostMode + ) + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) + } + default: + logger.debug("unsupported event: \(msg.responseType)") + } + } + } + chatReceiver?.start() + + let (res, error) = await uploadStandaloneFile(user: user, file: CryptoFile.plain(archivePath.lastPathComponent), ctrl: ctrl) + await MainActor.run { + guard let res = res else { + migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) + return alert = .error(title: "Error uploading the archive", error: error ?? "") + } + migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: res.fileSize, fileId: res.fileId, archivePath: archivePath, ctrl: ctrl) + } + } + } + + private func cancelUploadedArchive(_ fileId: Int64, _ ctrl: chat_ctrl) async { + _ = await apiCancelFile(fileId: fileId, ctrl: ctrl) + } + + private func cancelMigration(_ fileId: Int64, _ ctrl: chat_ctrl) { + Task { + await cancelUploadedArchive(fileId, ctrl) + await startChatAndDismiss() + } + } + + private func finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl) { + Task { + await cancelUploadedArchive(fileId, ctrl) + await MainActor.run { + migrationState = .finished(chatDeletion: false) + } + } + } + + private func deleteChatAndDismiss() { + Task { + do { + try await deleteChatAsync() + m.chatDbChanged = true + m.chatInitialized = false + migrationState = .finished(chatDeletion: true) + DispatchQueue.main.asyncAfter(deadline: .now()) { + resetChatCtrl() + do { + try initializeChat(start: false) + m.chatDbChanged = false + AppChatState.shared.set(.active) + } catch let error { + fatalError("Error starting chat \(responseError(error))") + } + showSettings = false + } + } catch let error { + alert = .error(title: "Error deleting database", error: responseError(error)) + } + } + } + + private func startChatAndDismiss(_ dismiss: Bool = true) async { + AppChatState.shared.set(.active) + do { + if m.chatDbChanged { + resetChatCtrl() + try initializeChat(start: true) + m.chatDbChanged = false + } else { + try startChat(refreshInvitations: true) + } + } catch let error { + alert = .error(title: "Error starting chat", error: responseError(error)) + } + // Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered + if dismiss || m.chatDbStatus != .ok { + await MainActor.run { + showSettings = false + } + } + } + + private static func urlForTemporaryDatabase() -> URL { + URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true)) + } +} + +private struct PassphraseConfirmationView: View { + @Binding var migrationState: MigrationToState + @State private var useKeychain = storeDBPassphraseGroupDefault.get() + @State private var currentKey: String = "" + @State private var verifyingPassphrase: Bool = false + @FocusState private var keyboardVisible: Bool + @Binding var alert: MigrateToAnotherDeviceViewAlert? + + var body: some View { + ZStack { + List { + chatStoppedView() + Section { + PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) + .focused($keyboardVisible) + Button(action: { + verifyingPassphrase = true + hideKeyboard() + Task { + await verifyDatabasePassphrase(currentKey) + verifyingPassphrase = false + } + }) { + settingsRow(useKeychain ? "key" : "lock", color: .secondary) { + Text("Verify passphrase") + } + } + .disabled(verifyingPassphrase || currentKey.isEmpty) + } header: { + Text("Verify database passphrase") + } footer: { + Text("Confirm that you remember database passphrase to migrate it.") + .font(.callout) + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + keyboardVisible = true + } + } + } + if verifyingPassphrase { + progressView() + } + } + } + + private func verifyDatabasePassphrase(_ dbKey: String) async { + do { + try await testStorageEncryption(key: dbKey) + await MainActor.run { + migrationState = .uploadConfirmation + } + } catch { + showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) + } + } +} + +private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { + switch status { + case .invalidConfirmation: + alert.wrappedValue = .invalidConfirmation() + case .errorNotADatabase: + alert.wrappedValue = .wrongPassphrase() + case .errorKeychain: + alert.wrappedValue = .keychainError() + case let .errorSQL(_, error): + alert.wrappedValue = .databaseError(message: error) + case let .unknown(error): + alert.wrappedValue = .unknownError(message: error) + case .errorMigration: () + case .ok: () + } +} + +private func progressView() -> some View { + VStack { + ProgressView().scaleEffect(2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity ) +} + +func chatStoppedView() -> some View { + settingsRow("exclamationmark.octagon.fill", color: .red) { + Text("Chat is stopped") + } +} + +private class MigrationChatReceiver { + let ctrl: chat_ctrl + let databaseUrl: URL + let processReceivedMsg: (ChatResponse) async -> Void + private var receiveLoop: Task? + private var receiveMessages = true + + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { + self.ctrl = ctrl + self.databaseUrl = databaseUrl + self.processReceivedMsg = processReceivedMsg + } + + func start() { + logger.debug("MigrationChatReceiver.start") + receiveMessages = true + if receiveLoop != nil { return } + receiveLoop = Task { await receiveMsgLoop() } + } + + func receiveMsgLoop() async { + // TODO use function that has timeout + if let msg = await chatRecvMsg(ctrl) { + Task { + await TerminalItems.shared.add(.resp(.now, msg)) + } + logger.debug("processReceivedMsg: \(msg.responseType)") + await processReceivedMsg(msg) + } + if self.receiveMessages { + _ = try? await Task.sleep(nanoseconds: 7_500_000) + await receiveMsgLoop() + } + } + + func stopAndCleanUp() { + logger.debug("MigrationChatReceiver.stop") + receiveMessages = false + receiveLoop?.cancel() + receiveLoop = nil + chat_close_store(ctrl) + try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_chat.db") + try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_agent.db") + } +} + +struct MigrateToAnotherDevice_Previews: PreviewProvider { + static var previews: some View { + MigrateToAnotherDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) + } +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index b78d92ffc8..7ece4fdee6 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -86,7 +86,7 @@ struct NewChatView: View { } } if case .connect = selection { - ConnectView(showQRCodeScanner: showQRCodeScanner, pastedLink: $pastedLink, alert: $alert) + ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert) .transition(.move(edge: .trailing)) } } @@ -284,8 +284,7 @@ private struct InviteView: View { private struct ConnectView: View { @Environment(\.dismiss) var dismiss: DismissAction - @State var showQRCodeScanner = false - @State private var cameraAuthorizationStatus: AVAuthorizationStatus? + @Binding var showQRCodeScanner: Bool @Binding var pastedLink: String @Binding var alert: NewChatViewAlert? @State private var sheet: PlanAndConnectActionSheet? @@ -295,32 +294,13 @@ private struct ConnectView: View { Section("Paste the link you received") { pasteLinkView() } - - scanCodeView() + Section("Or scan QR code") { + ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode) + } } .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" }) } - .onAppear { - let status = AVCaptureDevice.authorizationStatus(for: .video) - cameraAuthorizationStatus = status - if showQRCodeScanner { - switch status { - case .notDetermined: askCameraAuthorization() - case .restricted: showQRCodeScanner = false - case .denied: showQRCodeScanner = false - case .authorized: () - @unknown default: askCameraAuthorization() - } - } - } - } - - func askCameraAuthorization(_ cb: (() -> Void)? = nil) { - AVCaptureDevice.requestAccess(for: .video) { allowed in - cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) - if allowed { cb?() } - } } @ViewBuilder private func pasteLinkView() -> some View { @@ -351,8 +331,45 @@ private struct ConnectView: View { } } - private func scanCodeView() -> some View { - Section("Or scan QR code") { + private func processQRCode(_ resp: Result) { + switch resp { + case let .success(r): + let link = r.string + if strIsSimplexLink(r.string) { + connect(link) + } else { + alert = .newChatSomeAlert(alert: .someAlert( + alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."), + id: "processQRCode: code is not a SimpleX link" + )) + } + case let .failure(e): + logger.error("processQRCode QR code error: \(e.localizedDescription)") + alert = .newChatSomeAlert(alert: .someAlert( + alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"), + id: "processQRCode: failure" + )) + } + } + + private func connect(_ link: String) { + planAndConnect( + link, + showAlert: { alert = .planAndConnectAlert(alert: $0) }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: nil + ) + } +} + +struct ScannerInView: View { + @Binding var showQRCodeScanner: Bool + let processQRCode: (_ resp: Result) -> Void + @State private var cameraAuthorizationStatus: AVAuthorizationStatus? + + var body: some View { + Group { if showQRCodeScanner, case .authorized = cameraAuthorizationStatus { CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode) .aspectRatio(1, contentMode: .fit) @@ -396,37 +413,26 @@ private struct ConnectView: View { .disabled(cameraAuthorizationStatus == .restricted) } } - } - - private func processQRCode(_ resp: Result) { - switch resp { - case let .success(r): - let link = r.string - if strIsSimplexLink(r.string) { - connect(link) - } else { - alert = .newChatSomeAlert(alert: .someAlert( - alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."), - id: "processQRCode: code is not a SimpleX link" - )) + .onAppear { + let status = AVCaptureDevice.authorizationStatus(for: .video) + cameraAuthorizationStatus = status + if showQRCodeScanner { + switch status { + case .notDetermined: askCameraAuthorization() + case .restricted: showQRCodeScanner = false + case .denied: showQRCodeScanner = false + case .authorized: () + @unknown default: askCameraAuthorization() + } } - case let .failure(e): - logger.error("processQRCode QR code error: \(e.localizedDescription)") - alert = .newChatSomeAlert(alert: .someAlert( - alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"), - id: "processQRCode: failure" - )) } } - private func connect(_ link: String) { - planAndConnect( - link, - showAlert: { alert = .planAndConnectAlert(alert: $0) }, - showActionSheet: { sheet = $0 }, - dismiss: true, - incognito: nil - ) + func askCameraAuthorization(_ cb: (() -> Void)? = nil) { + AVCaptureDevice.requestAccess(for: .video) { allowed in + cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + if allowed { cb?() } + } } } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index ce1d727b10..b68c1279b0 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -7,11 +7,14 @@ // import SwiftUI +import SimpleXChat struct SimpleXInfo: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var showHowItWorks = false + @State private var migrationState: MigrationFromState? = nil + @State private var migrateFromAnotherDevice: Bool = false var onboarding: Bool var body: some View { @@ -44,6 +47,16 @@ struct SimpleXInfo: View { if onboarding { OnboardingActionButton() Spacer() + + Button { + migrationState = nil + migrateFromAnotherDevice = true + } label: { + Label("Migrate from another device", systemImage: "tray.and.arrow.down") + .font(.subheadline) + } + .padding(.bottom, 8) + .frame(maxWidth: .infinity) } Button { @@ -54,9 +67,25 @@ struct SimpleXInfo: View { } .padding(.bottom, 8) .frame(maxWidth: .infinity) + } .frame(minHeight: g.size.height) } + .onAppear { + if m.migrationState != nil { + migrationState = m.migrationState?.makeMigrationState() + migrateFromAnotherDevice = true + } + } + .sheet(isPresented: $migrateFromAnotherDevice) { + NavigationView { + VStack(alignment: .leading) { + MigrateFromAnotherDevice(migrationState: migrationState ?? .pasteOrScanLink) + } + .navigationTitle("Migrate here") + .background(colorScheme == .light ? Color(uiColor: .tertiarySystemGroupedBackground) : .clear) + } + } .sheet(isPresented: $showHowItWorks) { HowItWorks(onboarding: onboarding) } @@ -87,6 +116,7 @@ struct SimpleXInfo: View { struct OnboardingActionButton: View { @EnvironmentObject var m: ChatModel + @Environment(\.colorScheme) var colorScheme var body: some View { if m.currentUser == nil { @@ -111,6 +141,21 @@ struct OnboardingActionButton: View { .frame(maxWidth: .infinity) .padding(.bottom) } + + private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button { + withAnimation { + action() + } + } label: { + HStack { + Text(label).font(.title2) + Image(systemName: "greaterthan") + } + } + .frame(maxWidth: .infinity) + .padding(.bottom) + } } struct SimpleXInfo_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 6809dc1385..3059b049a3 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -58,7 +58,7 @@ struct ConnectDesktopView: View { var body: some View { if viaSettings { viewBody - .modifier(BackButton(label: "Back") { + .modifier(BackButton(label: "Back", disabled: Binding.constant(false)) { if m.activeRemoteCtrl { alert = .disconnectDesktop(action: .back) } else { diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift new file mode 100644 index 0000000000..ba192b333c --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -0,0 +1,72 @@ +// +// AppSettings.swift +// SimpleX (iOS) +// +// Created by Avently on 26.02.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import Foundation +import SimpleXChat +import SwiftUI + +extension AppSettings { + public func importIntoApp() { + let def = UserDefaults.standard + if var val = networkConfig { + // migrating from Android/desktop BUT shouldn't be here ever because it should be changed in migration stage + if case .onionViaSocks = val.hostMode { + val.hostMode = .publicHost + val.requiredHostMode = true + } + val.socksProxy = nil + setNetCfg(val) + } + if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) } + if let val = privacyAcceptImages { + privacyAcceptImagesGroupDefault.set(val) + def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) + } + if let val = privacyLinkPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } + if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } + if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } + if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } + if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() } + if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) } + if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) } + if let val = webrtcICEServers { def.setValue(val, forKey: DEFAULT_WEBRTC_ICE_SERVERS) } + if let val = confirmRemoteSessions { def.setValue(val, forKey: DEFAULT_CONFIRM_REMOTE_SESSIONS) } + if let val = connectRemoteViaMulticast { def.setValue(val, forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) } + if let val = connectRemoteViaMulticastAuto { def.setValue(val, forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) } + if let val = developerTools { def.setValue(val, forKey: DEFAULT_DEVELOPER_TOOLS) } + if let val = confirmDBUpgrades { confirmDBUpgradesGroupDefault.set(val) } + if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) } + if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) } + if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) } + } + + public static var current: AppSettings { + let def = UserDefaults.standard + var c = AppSettings.defaults + c.networkConfig = getNetCfg() + c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get() + c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() + c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) + c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) + c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) + c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode) + c.notificationPreviewMode = ntfPreviewModeGroupDefault.get() + c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) + c.webrtcICEServers = def.stringArray(forKey: DEFAULT_WEBRTC_ICE_SERVERS) + c.confirmRemoteSessions = def.bool(forKey: DEFAULT_CONFIRM_REMOTE_SESSIONS) + c.connectRemoteViaMulticast = def.bool(forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) + c.connectRemoteViaMulticastAuto = def.bool(forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) + c.developerTools = def.bool(forKey: DEFAULT_DEVELOPER_TOOLS) + c.confirmDBUpgrades = confirmDBUpgradesGroupDefault.get() + c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!) + c.iosCallKitEnabled = callKitEnabledGroupDefault.get() + c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + return c + } +} diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift index 48d5a66970..6702ab7ce8 100644 --- a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift @@ -31,7 +31,7 @@ struct ProtocolServerView: View { ProgressView().scaleEffect(2) } } - .modifier(BackButton(label: "Your \(proto) servers") { + .modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) { server = serverToEdit dismiss() }) @@ -117,6 +117,7 @@ struct ProtocolServerView: View { struct BackButton: ViewModifier { var label: LocalizedStringKey = "Back" + @Binding var disabled: Bool var action: () -> Void func body(content: Content) -> some View { @@ -130,6 +131,7 @@ struct BackButton: ViewModifier { Text(label) } } + .disabled(disabled) } } } diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift index 382eaffbef..b9163d4bad 100644 --- a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift @@ -95,7 +95,7 @@ struct ProtocolServersView: View { .sheet(isPresented: $showScanProtoServer) { ScanProtocolServer(servers: $servers) } - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if saveDisabled { dismiss() justOpened = false diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a691e6afc9..842ccaab4c 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -27,7 +27,7 @@ let DEFAULT_NOTIFICATION_ALERT_SHOWN = "notificationAlertShown" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" -let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" +let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" @@ -51,6 +51,7 @@ let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" +let DEFAULT_MIGRATION_STAGE = "migrationStage" let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" @@ -58,6 +59,8 @@ let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" +let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen" + let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_LA_NOTICE_SHOWN: false, @@ -93,6 +96,7 @@ let appDefaults: [String: Any] = [ DEFAULT_CONFIRM_REMOTE_SESSIONS: false, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, + ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue ] // not used anymore @@ -148,10 +152,14 @@ struct SettingsView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var sceneDelegate: SceneDelegate @Binding var showSettings: Bool + @State private var showProgress: Bool = false var body: some View { ZStack { settingsView() + if showProgress { + progressView() + } if let la = chatModel.laRequest { LocalAuthView(authRequest: la) } @@ -202,9 +210,17 @@ struct SettingsView: View { } label: { settingsRow("desktopcomputer") { Text("Use from desktop") } } + + NavigationLink { + MigrateToAnotherDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress) + .navigationTitle("Migrate device") + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("tray.and.arrow.up") { Text("Migrate to another device") } + } } .disabled(chatModel.chatRunning != true) - + Section("Settings") { NavigationLink { NotificationsView() @@ -349,6 +365,13 @@ struct SettingsView: View { } } + private func progressView() -> some View { + VStack { + ProgressView().scaleEffect(2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity ) + } + private enum NotificationAlert { case enable case error(LocalizedStringKey, String) diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index e9657961ef..96eeffd16d 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -47,7 +47,7 @@ struct UserAddressView: View { userAddressScrollView() } else { userAddressScrollView() - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if savedAAS == aas { dismiss() } else { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 67536d7b78..6f76781837 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -640,7 +640,9 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { cleanupDirectFile(aChatItem) return nil case let .sndFileRcvCancelled(_, aChatItem, _): - cleanupDirectFile(aChatItem) + if let aChatItem = aChatItem { + cleanupDirectFile(aChatItem) + } return nil case let .sndFileCompleteXFTP(_, aChatItem, _): cleanupFile(aChatItem) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 09904ad4fe..5f6fe6c65a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -185,6 +185,9 @@ 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; }; + 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; + 8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */; }; + 8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; @@ -473,6 +476,9 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; 8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = ""; }; + 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromAnotherDevice.swift; sourceTree = ""; }; + 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAnotherDevice.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -553,6 +559,7 @@ 5CB924DD27A8622200ACCCDD /* NewChat */, 5CFA59C22860B04D00863A68 /* Database */, 5CB634AB29E46CDB0066AD6B /* LocalAuth */, + 8C7D94982B8894D300B7B9E1 /* Migration */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, @@ -766,6 +773,7 @@ 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */, 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */, 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */, + 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */, ); path = UserSettings; sourceTree = ""; @@ -893,6 +901,15 @@ path = Group; sourceTree = ""; }; + 8C7D94982B8894D300B7B9E1 /* Migration */ = { + isa = PBXGroup; + children = ( + 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */, + 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */, + ); + path = Migration; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1124,6 +1141,7 @@ 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */, 6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, + 8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, @@ -1179,6 +1197,7 @@ 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, + 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, @@ -1220,6 +1239,7 @@ 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, + 8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index c0bb298929..64249fe09b 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -54,6 +54,33 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio return result } +public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) { + let dbPath = url.path + let dbKey = key ?? randomDatabasePassword() + logger.debug("chatInitTemporaryDatabase path: \(dbPath)") + var temporaryController: chat_ctrl? = nil + var cPath = dbPath.cString(using: .utf8)! + var cKey = dbKey.cString(using: .utf8)! + var cConfirm = confirmation.rawValue.cString(using: .utf8)! + let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)! + return (dbMigrationResult(fromCString(cjson)), temporaryController) +} + +public func chatInitControllerRemovingDatabases() { + let dbPath = getAppDatabasePath().path + let dbKey = randomDatabasePassword() + logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)") + var cPath = dbPath.cString(using: .utf8)! + var cKey = dbKey.cString(using: .utf8)! + var cConfirm = MigrationConfirmation.error.rawValue.cString(using: .utf8)! + chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &chatController) + // We need only controller, not databases + let fm = FileManager.default + try? fm.removeItem(atPath: dbPath + CHAT_DB) + try? fm.removeItem(atPath: dbPath + AGENT_DB) +} + + public func chatCloseStore() { let err = fromCString(chat_close_store(getChatCtrl())) if err != "" { @@ -73,17 +100,17 @@ public func resetChatCtrl() { migrationResult = nil } -public func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { +public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! - let cjson = chat_send_cmd(getChatCtrl(), &c)! + let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)! return chatResponse(fromCString(cjson)) } // in microseconds let MESSAGE_TIMEOUT: Int32 = 15_000_000 -public func recvSimpleXMsg() -> ChatResponse? { - if let cjson = chat_recv_msg_wait(getChatCtrl(), MESSAGE_TIMEOUT) { +public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil) -> ChatResponse? { + if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), MESSAGE_TIMEOUT) { let s = fromCString(cjson) return s == "" ? nil : chatResponse(s) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4df419ffef..f55c69a349 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -38,6 +38,9 @@ public enum ChatCommand { case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage case apiStorageEncryption(config: DBEncryptionConfig) + case testStorageEncryption(key: String) + case apiSaveSettings(settings: AppSettings) + case apiGetSettings(settings: AppSettings) case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) @@ -132,6 +135,9 @@ public enum ChatCommand { case listRemoteCtrls case stopRemoteCtrl case deleteRemoteCtrl(remoteCtrlId: Int64) + case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) + case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) + case apiStandaloneFileInfo(url: String) // misc case showVersion case string(String) @@ -170,6 +176,9 @@ public enum ChatCommand { case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" + case let .testStorageEncryption(key): return "/db test key \(key)" + case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))" + case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))" case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") @@ -282,6 +291,9 @@ public enum ChatCommand { case .listRemoteCtrls: return "/list remote ctrls" case .stopRemoteCtrl: return "/stop remote ctrl" case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" + case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" + case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" + case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case .showVersion: return "/version" case let .string(str): return str } @@ -316,6 +328,9 @@ public enum ChatCommand { case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" case .apiStorageEncryption: return "apiStorageEncryption" + case .testStorageEncryption: return "testStorageEncryption" + case .apiSaveSettings: return "apiSaveSettings" + case .apiGetSettings: return "apiGetSettings" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiGetChatItemInfo: return "apiGetChatItemInfo" @@ -408,6 +423,9 @@ public enum ChatCommand { case .listRemoteCtrls: return "listRemoteCtrls" case .stopRemoteCtrl: return "stopRemoteCtrl" case .deleteRemoteCtrl: return "deleteRemoteCtrl" + case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" + case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" + case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .showVersion: return "showVersion" case .string: return "console command" } @@ -442,6 +460,8 @@ public enum ChatCommand { return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) + case let .testStorageEncryption(key): + return .testStorageEncryption(key: obfuscate(key)) default: return self } } @@ -590,20 +610,28 @@ public enum ChatResponse: Decodable, Error { // receiving file events case rcvFileAccepted(user: UserRef, chatItem: AChatItem) case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(user: UserRef, chatItem: AChatItem) - case rcvFileProgressXFTP(user: UserRef, chatItem: AChatItem, receivedSize: Int64, totalSize: Int64) + case standaloneFileInfo(fileMeta: MigrationFileLinkData?) + case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats + case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) case rcvFileComplete(user: UserRef, chatItem: AChatItem) - case rcvFileCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) + case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) - case rcvFileError(user: UserRef, chatItem: AChatItem) + case rcvFileError(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) // sending file events case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileCancelled(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) - case sndFileRcvCancelled(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileProgressXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload + case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) - case sndFileError(user: UserRef, chatItem: AChatItem) + case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) + case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) // call events case callInvitation(callInvitation: RcvCallInvitation) case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) @@ -632,6 +660,7 @@ public enum ChatResponse: Decodable, Error { case chatCmdError(user_: UserRef?, chatError: ChatError) case chatError(user_: UserRef?, chatError: ChatError) case archiveImported(archiveErrors: [ArchiveError]) + case appSettings(appSettings: AppSettings) public var responseType: String { get { @@ -744,18 +773,26 @@ public enum ChatResponse: Decodable, Error { case .newMemberContactReceivedInv: return "newMemberContactReceivedInv" case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" + case .standaloneFileInfo: return "standaloneFileInfo" + case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated" case .rcvFileStart: return "rcvFileStart" case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" case .rcvFileComplete: return "rcvFileComplete" + case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete" case .rcvFileCancelled: return "rcvFileCancelled" case .rcvFileSndCancelled: return "rcvFileSndCancelled" case .rcvFileError: return "rcvFileError" case .sndFileStart: return "sndFileStart" case .sndFileComplete: return "sndFileComplete" case .sndFileCancelled: return "sndFileCancelled" - case .sndFileRcvCancelled: return "sndFileRcvCancelled" + case .sndStandaloneFileCreated: return "sndStandaloneFileCreated" + case .sndFileStartXFTP: return "sndFileStartXFTP" case .sndFileProgressXFTP: return "sndFileProgressXFTP" + case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP" + case .sndFileRcvCancelled: return "sndFileRcvCancelled" case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" + case .sndStandaloneFileComplete: return "sndStandaloneFileComplete" + case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" case .sndFileError: return "sndFileError" case .callInvitation: return "callInvitation" case .callOffer: return "callOffer" @@ -781,6 +818,7 @@ public enum ChatResponse: Decodable, Error { case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" case .archiveImported: return "archiveImported" + case .appSettings: return "appSettings" } } } @@ -896,19 +934,27 @@ public enum ChatResponse: Decodable, Error { case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails + case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) + case .rcvStandaloneFileCreated: return noDetails case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") + case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") + case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileError(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileError(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) + case .sndStandaloneFileCreated: return noDetails + case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileError(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) + case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileError(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .callInvitation(inv): return String(describing: inv) case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") @@ -933,6 +979,7 @@ public enum ChatResponse: Decodable, Error { case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) + case let .appSettings(appSettings): return String(describing: appSettings) } } } @@ -1534,7 +1581,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem { public static var values: [NotificationsMode] = [.instant, .periodic, .off] } -public enum NotificationPreviewMode: String, SelectableItem { +public enum NotificationPreviewMode: String, SelectableItem, Codable { case hidden case contact case message @@ -1734,6 +1781,7 @@ public enum StoreError: Decodable { case fileIdNotFoundBySharedMsgId(sharedMsgId: String) case sndFileNotFoundXFTP(agentSndFileId: String) case rcvFileNotFoundXFTP(agentRcvFileId: String) + case extraFileDescrNotFoundXFTP(fileId: Int64) case connectionNotFound(agentConnId: String) case connectionNotFoundById(connId: Int64) case connectionNotFoundByMemberId(groupMemberId: Int64) @@ -1895,3 +1943,147 @@ public enum RemoteCtrlError: Decodable { case badVersion(appVersion: String) // case protocolError(protocolError: RemoteProtocolError) } + +public struct MigrationFileLinkData: Codable { + let networkConfig: NetworkConfig? + + public init(networkConfig: NetworkConfig) { + self.networkConfig = networkConfig + } + + public struct NetworkConfig: Codable { + let socksProxy: String? + let hostMode: HostMode? + let requiredHostMode: Bool? + + public init(socksProxy: String?, hostMode: HostMode?, requiredHostMode: Bool?) { + self.socksProxy = socksProxy + self.hostMode = hostMode + self.requiredHostMode = requiredHostMode + } + + public func transformToPlatformSupported() -> NetworkConfig { + return if let hostMode, let requiredHostMode { + NetworkConfig( + socksProxy: nil, + hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode, + requiredHostMode: requiredHostMode + ) + } else { self } + } + } + + public func addToLink(link: String) -> String { + "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)" + } + + public static func readFromLink(link: String) -> MigrationFileLinkData? { +// standaloneFileInfo(link) + nil + } +} + +public struct AppSettings: Codable, Equatable { + public var networkConfig: NetCfg? = nil + public var privacyEncryptLocalFiles: Bool? = nil + public var privacyAcceptImages: Bool? = nil + public var privacyLinkPreviews: Bool? = nil + public var privacyShowChatPreviews: Bool? = nil + public var privacySaveLastDraft: Bool? = nil + public var privacyProtectScreen: Bool? = nil + public var notificationMode: AppSettingsNotificationMode? = nil + public var notificationPreviewMode: NotificationPreviewMode? = nil + public var webrtcPolicyRelay: Bool? = nil + public var webrtcICEServers: [String]? = nil + public var confirmRemoteSessions: Bool? = nil + public var connectRemoteViaMulticast: Bool? = nil + public var connectRemoteViaMulticastAuto: Bool? = nil + public var developerTools: Bool? = nil + public var confirmDBUpgrades: Bool? = nil + public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil + public var iosCallKitEnabled: Bool? = nil + public var iosCallKitCallsInRecents: Bool? = nil + + public func prepareForExport() -> AppSettings { + var empty = AppSettings() + let def = AppSettings.defaults + if networkConfig != def.networkConfig { empty.networkConfig = networkConfig } + if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } + if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } + if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } + if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } + if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } + if notificationMode != def.notificationMode { empty.notificationMode = notificationMode } + if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode } + if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay } + if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers } + if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions } + if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast } + if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto } + if developerTools != def.developerTools { empty.developerTools = developerTools } + if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades } + if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen } + if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled } + if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } + return empty + } + + public static var defaults: AppSettings { + AppSettings ( + networkConfig: NetCfg.defaults, + privacyEncryptLocalFiles: true, + privacyAcceptImages: true, + privacyLinkPreviews: true, + privacyShowChatPreviews: true, + privacySaveLastDraft: true, + privacyProtectScreen: false, + notificationMode: AppSettingsNotificationMode.instant, + notificationPreviewMode: NotificationPreviewMode.message, + webrtcPolicyRelay: true, + webrtcICEServers: [], + confirmRemoteSessions: false, + connectRemoteViaMulticast: true, + connectRemoteViaMulticastAuto: true, + developerTools: false, + confirmDBUpgrades: false, + androidCallOnLockScreen: AppSettingsLockScreenCalls.show, + iosCallKitEnabled: true, + iosCallKitCallsInRecents: false + ) + } +} + +public enum AppSettingsNotificationMode: String, Codable { + case off + case periodic + case instant + + public func toNotificationsMode() -> NotificationsMode { + switch self { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } + + public static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode { + switch mode { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } +} + +//public enum NotificationPreviewMode: Codable { +// case hidden +// case contact +// case message +//} + +public enum AppSettingsLockScreenCalls: String, Codable { + case disable + case show + case accept +} diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 47e250b7e9..4fbe78dc7a 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -19,6 +19,7 @@ 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 +// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES 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" @@ -36,7 +37,7 @@ let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl" let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" public let GROUP_DEFAULT_INCOGNITO = "incognito" let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" -let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" +public let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades" public let GROUP_DEFAULT_CALL_KIT_ENABLED = "callKitEnabled" public let GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED = "pqExperimentalEnabled" @@ -169,6 +170,7 @@ public let ntfPreviewModeGroupDefault = EnumDefault( public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO) +// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4349fc7beb..b74a2517c7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3410,11 +3410,14 @@ public struct SndFileTransfer: Decodable { } public struct RcvFileTransfer: Decodable { - + public let fileId: Int64 } public struct FileTransferMeta: Decodable { - + public let fileId: Int64 + public let fileName: String + public let filePath: String + public let fileSize: Int64 } public enum CICallStatus: String, Decodable { diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 7496bf7215..125600f3f3 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -28,9 +28,9 @@ public let MAX_FILE_SIZE_SMP: Int64 = 8000000 public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) -private let CHAT_DB: String = "_chat.db" +let CHAT_DB: String = "_chat.db" -private let AGENT_DB: String = "_agent.db" +let AGENT_DB: String = "_agent.db" private let CHAT_DB_BAK: String = "_chat.db.bak" @@ -83,6 +83,7 @@ public func deleteAppDatabaseAndFiles() { try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK) try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK) try? fm.removeItem(at: getTempFilesDirectory()) + try? fm.removeItem(at: getMigrationTempFilesDirectory()) try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true) deleteAppFiles() _ = kcDatabasePassword.remove() @@ -183,6 +184,10 @@ public func getTempFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("temp_files", isDirectory: true) } +public func getMigrationTempFilesDirectory() -> URL { + getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true) +} + public func getAppFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("app_files", isDirectory: true) } From c9df591e520e6ce9f975881ab13a601ddc696967 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 12 Mar 2024 00:18:58 +0700 Subject: [PATCH 50/64] multiplatform: migration via link (#3854) * multiplatform: migration via link * onion screen * unused code * changes * migrate from device * changes * don't allow going back on Archiving step * changes * correction * correction * change * font * changes * changes * changes * show NEVER text for onion when socks is disabled * onion setup * no check --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .../main/java/chat/simplex/app/SimplexApp.kt | 30 +- .../DatabaseEncryptionView.android.kt | 15 +- .../kotlin/chat/simplex/common/App.kt | 7 + .../chat/simplex/common/model/ChatModel.kt | 14 +- .../chat/simplex/common/model/SimpleXAPI.kt | 451 +++++++++-- .../chat/simplex/common/platform/Core.kt | 29 + .../chat/simplex/common/platform/Files.kt | 2 + .../chat/simplex/common/platform/Platform.kt | 2 + .../views/database/DatabaseEncryptionView.kt | 99 ++- .../views/database/DatabaseErrorView.kt | 12 +- .../common/views/database/DatabaseView.kt | 22 +- .../common/views/helpers/DatabaseUtils.kt | 2 +- .../views/helpers/DefaultProgressBar.kt | 2 +- .../simplex/common/views/helpers/ModalView.kt | 5 +- .../migration/MigrateFromAnotherDevice.kt | 721 ++++++++++++++++++ .../views/migration/MigrateToAnotherDevice.kt | 683 +++++++++++++++++ .../common/views/newchat/NewChatView.kt | 2 +- .../onboarding/SetupDatabasePassphrase.kt | 2 +- .../common/views/onboarding/SimpleXInfo.kt | 21 +- .../views/usersettings/NetworkAndServers.kt | 156 ++-- .../common/views/usersettings/SettingsView.kt | 9 +- .../commonMain/resources/MR/base/strings.xml | 63 ++ .../DatabaseEncryptionView.desktop.kt | 15 +- 23 files changed, 2185 insertions(+), 179 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.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 b76b18fd21..f29aa39607 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,10 +1,16 @@ package chat.simplex.app +import android.annotation.SuppressLint import android.app.* import android.content.Context import chat.simplex.common.platform.Log import android.content.Intent +import android.content.pm.ActivityInfo +import android.media.AudioManager import android.os.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.NtfManager @@ -18,8 +24,7 @@ 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.call.activeCallDestroyWebView +import chat.simplex.common.views.call.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import com.jakewharton.processphoenix.ProcessPhoenix @@ -65,7 +70,11 @@ class SimplexApp: Application(), LifecycleEventObserver { tmpDir.deleteRecursively() tmpDir.mkdir() - if (DatabaseUtils.ksSelfDestructPassword.get() == null) { + // Present screen for continue migration if it wasn't finished yet + if (chatModel.migrationState.value != null) { + // It's important, otherwise, user may be locked in undefined state + appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } else if (DatabaseUtils.ksAppPassword.get() == null || DatabaseUtils.ksSelfDestructPassword.get() == null) { initChatControllerAndRunMigrations() } ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp) @@ -282,6 +291,21 @@ class SimplexApp: Application(), LifecycleEventObserver { activeCallDestroyWebView() } + @SuppressLint("SourceLockedOrientationActivity") + @Composable + override fun androidLockPortraitOrientation() { + val context = LocalContext.current + DisposableEffect(Unit) { + val activity = context as? Activity ?: return@DisposableEffect onDispose {} + // Lock orientation to portrait in order to have good experience with calls + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + onDispose { + // Unlock orientation + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + } + override suspend fun androidAskToAllowBackgroundCalls(): Boolean { if (SimplexService.isBackgroundRestricted()) { val userChoice: CompletableDeferred = CompletableDeferred() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index df2499926f..83677f3318 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.database import SectionItemView import SectionTextFooter +import TextIconSpaced import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -22,8 +23,9 @@ actual fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, - progressIndicator: Boolean, minHeight: Dp, + enabled: Boolean, + smallPadding: Boolean, onCheckedChange: (Boolean) -> Unit, ) { SectionItemView(minHeight = minHeight) { @@ -33,7 +35,11 @@ actual fun SavePassphraseSetting( stringResource(MR.strings.save_passphrase_in_keychain), tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary ) - Spacer(Modifier.padding(horizontal = 4.dp)) + if (smallPadding) { + Spacer(Modifier.padding(horizontal = 4.dp)) + } else { + TextIconSpaced(false) + } Text( stringResource(MR.strings.save_passphrase_in_keychain), Modifier.padding(end = 24.dp), @@ -43,7 +49,7 @@ actual fun SavePassphraseSetting( DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, - enabled = !initialRandomDBPassphrase && !progressIndicator + enabled = enabled ) } } @@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter( chatDbEncrypted: Boolean?, storedKey: MutableState, initialRandomDBPassphrase: MutableState, + migration: Boolean, ) { if (chatDbEncrypted == false) { SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) } else if (useKeychain.value) { if (storedKey.value) { SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) - if (initialRandomDBPassphrase.value) { + if (initialRandomDBPassphrase.value && !migration) { SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) } else { SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) 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 0213350916..e7dda42ade 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 @@ -110,6 +110,13 @@ fun MainScreen() { val localUserCreated = chatModel.localUserCreated.value var showInitializationView by remember { mutableStateOf(false) } when { + onboarding == OnboardingStage.Step1_SimpleXInfo && chatModel.migrationState.value != null -> { + // In migration process. Nothing should interrupt it, that's why it's the first branch in when() + SimpleXInfo(chatModel, onboarding = true) + if (appPlatform.isDesktop) { + ModalManager.fullscreen.showInView() + } + } chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress)) chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database)) showChatDatabaseError -> { 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 b5f00e75aa..faa4200555 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 @@ -13,6 +13,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.MigrationFromAnotherDeviceState import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -104,6 +105,8 @@ object ChatModel { // currently showing invitation val showingInvitation = mutableStateOf(null as ShowingInvitation?) + val migrationState: MutableState by lazy { mutableStateOf(MigrationFromAnotherDeviceState.transform()) } + var draft = mutableStateOf(null as ComposeState?) var draftChatId = mutableStateOf(null as String?) @@ -2973,10 +2976,17 @@ enum class FormatColor(val color: String) { class SndFileTransfer() {} @Serializable -class RcvFileTransfer() {} +data class RcvFileTransfer( + val fileId: Long, +) @Serializable -class FileTransferMeta() {} +data class FileTransferMeta( + val fileId: Long, + val fileName: String, + val filePath: String, + val fileSize: Long, +) @Serializable enum class CICallStatus { 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 d695b2c608..08d30fe086 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 @@ -4,12 +4,15 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.ChatController.getNetCfg +import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.model.ChatModel.changingActiveUserMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import com.charleskorn.kaml.Yaml @@ -144,6 +147,7 @@ class AppPreferences { val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } } + val migrationStage = mkStrPreference(SHARED_PREFS_MIGRATION_STAGE, null) val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true) val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false) val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null) @@ -177,6 +181,11 @@ class AppPreferences { val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null) + + + val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true) + val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false) + private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -277,6 +286,7 @@ class AppPreferences { private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" + const val SHARED_PREFS_MIGRATION_STAGE = "MigrationStage" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_CHAT_STOPPED = "ChatStopped" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" @@ -326,6 +336,9 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" + + private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled" + private const val SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS = "iOSCallKitCallsInRecents" } } @@ -402,6 +415,16 @@ object ChatController { } } + suspend fun startChatWithTemporaryDatabase(ctrl: ChatCtrl, netCfg: NetCfg): User? { + Log.d(TAG, "startChatWithTemporaryDatabase") + val migrationActiveUser = apiGetActiveUser(null, ctrl) ?: apiCreateActiveUser(null, Profile(displayName = "Temp", fullName = ""), ctrl = ctrl) + apiSetNetworkConfig(netCfg, ctrl) + apiSetTempFolder(getMigrationTempFilesDirectory().absolutePath, ctrl) + apiSetFilesFolder(getMigrationTempFilesDirectory().absolutePath, ctrl) + apiStartChat(ctrl) + return migrationActiveUser + } + suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { try { changeActiveUser_(rhId, toUserId, viewPwd) @@ -478,8 +501,8 @@ object ChatController { } } - suspend fun sendCmd(rhId: Long?, cmd: CC): CR { - val ctrl = ctrl ?: throw Exception("Controller is not initialized") + suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null): CR { + val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString @@ -496,7 +519,7 @@ object ChatController { } } - private fun recvMsg(ctrl: ChatCtrl): APIResponse? { + fun recvMsg(ctrl: ChatCtrl): APIResponse? { val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (json == "") { null @@ -509,8 +532,8 @@ object ChatController { } } - suspend fun apiGetActiveUser(rh: Long?): User? { - val r = sendCmd(rh, CC.ShowActiveUser()) + suspend fun apiGetActiveUser(rh: Long?, ctrl: ChatCtrl? = null): User? { + val r = sendCmd(rh, CC.ShowActiveUser(), ctrl) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") if (rh == null) { @@ -519,8 +542,8 @@ object ChatController { return null } - suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { - val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? { + val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp), ctrl) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) else if ( r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || @@ -598,8 +621,8 @@ object ChatController { throw Exception("failed to delete the user ${r.responseType} ${r.details}") } - suspend fun apiStartChat(): Boolean { - val r = sendCmd(null, CC.StartChat(mainApp = true)) + suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(null, CC.StartChat(mainApp = true), ctrl) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -615,14 +638,14 @@ object ChatController { } } - suspend fun apiSetTempFolder(tempFolder: String) { - val r = sendCmd(null, CC.SetTempFolder(tempFolder)) + suspend fun apiSetTempFolder(tempFolder: String, ctrl: ChatCtrl? = null) { + val r = sendCmd(null, CC.SetTempFolder(tempFolder), ctrl) if (r is CR.CmdOk) return throw Error("failed to set temp folder: ${r.responseType} ${r.details}") } - suspend fun apiSetFilesFolder(filesFolder: String) { - val r = sendCmd(null, CC.SetFilesFolder(filesFolder)) + suspend fun apiSetFilesFolder(filesFolder: String, ctrl: ChatCtrl? = null) { + val r = sendCmd(null, CC.SetFilesFolder(filesFolder), ctrl) if (r is CR.CmdOk) return throw Error("failed to set files folder: ${r.responseType} ${r.details}") } @@ -635,6 +658,18 @@ object ChatController { suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiSaveAppSettings(settings: AppSettings) { + val r = sendCmd(null, CC.ApiSaveSettings(settings)) + if (r is CR.CmdOk) return + throw Error("failed to set app settings: ${r.responseType} ${r.details}") + } + + suspend fun apiGetAppSettings(settings: AppSettings): AppSettings { + val r = sendCmd(null, CC.ApiGetSettings(settings)) + if (r is CR.AppSettingsR) return r.appSettings + throw Error("failed to get app settings: ${r.responseType} ${r.details}") + } + suspend fun apiSetPQEncryption(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEncryption(enable)) suspend fun apiSetContactPQ(rh: Long?, contactId: Long, enable: Boolean): Contact? { @@ -669,6 +704,11 @@ object ChatController { throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } + suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl) + return r is CR.CmdOk + } + suspend fun apiGetChats(rh: Long?): List { val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() } val r = sendCmd(rh, CC.ApiGetChats(userId)) @@ -805,8 +845,8 @@ object ChatController { throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } - suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean { - val r = sendCmd(null, CC.APISetNetworkConfig(cfg)) + suspend fun apiSetNetworkConfig(cfg: NetCfg, ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl) return when (r) { is CR.CmdOk -> true else -> { @@ -1236,6 +1276,36 @@ object ChatController { return false } + suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { + val r = sendCmd(null, CC.ApiUploadStandaloneFile(user.userId, file), ctrl) + return if (r is CR.SndStandaloneFileCreated) { + r.fileTransferMeta to null + } else { + Log.e(TAG, "uploadStandaloneFile error: $r") + null to r.toString() + } + } + + suspend fun downloadStandaloneFile(user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { + val r = sendCmd(null, CC.ApiDownloadStandaloneFile(user.userId, url, file), ctrl) + return if (r is CR.RcvStandaloneFileCreated) { + r.rcvFileTransfer to null + } else { + Log.e(TAG, "downloadStandaloneFile error: $r") + null to r.toString() + } + } + + suspend fun standaloneFileInfo(url: String, ctrl: ChatCtrl? = null): MigrationFileLinkData? { + val r = sendCmd(null, CC.ApiStandaloneFileInfo(url), ctrl) + return if (r is CR.StandaloneFileInfo) { + r.fileMeta + } else { + Log.e(TAG, "standaloneFileInfo error: $r") + null + } + } + suspend fun apiReceiveFile(rh: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { // -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline)) @@ -1274,11 +1344,11 @@ object ChatController { } } - suspend fun apiCancelFile(rh: Long?, fileId: Long): AChatItem? { - val r = sendCmd(rh, CC.CancelFile(fileId)) + suspend fun apiCancelFile(rh: Long?, fileId: Long, ctrl: ChatCtrl? = null): AChatItem? { + val r = sendCmd(rh, CC.CancelFile(fileId), ctrl) return when (r) { - is CR.SndFileCancelled -> r.chatItem - is CR.RcvFileCancelled -> r.chatItem + is CR.SndFileCancelled -> r.chatItem_ + is CR.RcvFileCancelled -> r.chatItem_ else -> { Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}") null @@ -1565,8 +1635,8 @@ object ChatController { suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteCtrl(rcId)) - private suspend fun sendCommandOkResp(rh: Long?, cmd: CC): Boolean { - val r = sendCmd(rh, cmd) + private suspend fun sendCommandOkResp(rh: Long?, cmd: CC, ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(rh, cmd, ctrl) val ok = r is CR.CmdOk if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok @@ -1856,11 +1926,16 @@ object ChatController { chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } - is CR.RcvFileProgressXFTP -> - chatItemSimpleUpdate(rhId, r.user, r.chatItem) + is CR.RcvFileProgressXFTP -> { + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + } + } is CR.RcvFileError -> { - chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupFile(r.chatItem) + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + cleanupFile(r.chatItem_) + } } is CR.SndFileStart -> chatItemSimpleUpdate(rhId, r.user, r.chatItem) @@ -1869,18 +1944,25 @@ object ChatController { cleanupDirectFile(r.chatItem) } is CR.SndFileRcvCancelled -> { - chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupDirectFile(r.chatItem) + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + cleanupDirectFile(r.chatItem_) + } + } + is CR.SndFileProgressXFTP -> { + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + } } - is CR.SndFileProgressXFTP -> - chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.SndFileCompleteXFTP -> { chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.SndFileError -> { - chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupFile(r.chatItem) + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + cleanupFile(r.chatItem_) + } } is CR.CallInvitation -> { chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) @@ -2249,21 +2331,13 @@ object ChatController { class SharedPreference(val get: () -> T, set: (T) -> Unit) { val set: (T) -> Unit - private val _state: MutableState by lazy { mutableStateOf(get()) } - val state: State by lazy { _state } + private val _state: MutableState = mutableStateOf(get()) + val state: State = _state init { this.set = { value -> set(value) - try { - _state.value = value - } catch (e: IllegalStateException) { - // Can be `Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied` - Log.i(TAG, e.stackTraceToString()) - withApi { - _state.value = value - } - } + _state.value = value } } } @@ -2295,6 +2369,9 @@ sealed class CC { class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() class ApiStorageEncryption(val config: DBEncryptionConfig): CC() + class TestStorageEncryption(val key: String): CC() + class ApiSaveSettings(val settings: AppSettings): CC() + class ApiGetSettings(val settings: AppSettings): CC() class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() @@ -2388,6 +2465,9 @@ sealed class CC { class ListRemoteCtrls(): CC() class StopRemoteCtrl(): CC() class DeleteRemoteCtrl(val remoteCtrlId: Long): CC() + class ApiUploadStandaloneFile(val userId: Long, val file: CryptoFile): CC() + class ApiDownloadStandaloneFile(val userId: Long, val url: String, val file: CryptoFile): CC() + class ApiStandaloneFileInfo(val url: String): CC() // misc class ShowVersion(): CC() @@ -2426,6 +2506,9 @@ sealed class CC { is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" + is TestStorageEncryption -> "/db test key $key" + is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}" + is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}" is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" @@ -2533,6 +2616,9 @@ sealed class CC { is ListRemoteCtrls -> "/list remote ctrls" is StopRemoteCtrl -> "/stop remote ctrl" is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId" + is ApiUploadStandaloneFile -> "/_upload $userId ${file.filePath}" + is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" + is ApiStandaloneFileInfo -> "/_download info $url" is ShowVersion -> "/version" } @@ -2562,6 +2648,9 @@ sealed class CC { is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" is ApiStorageEncryption -> "apiStorageEncryption" + is TestStorageEncryption -> "testStorageEncryption" + is ApiSaveSettings -> "apiSaveSettings" + is ApiGetSettings -> "apiGetSettings" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiGetChatItemInfo -> "apiGetChatItemInfo" @@ -2654,6 +2743,9 @@ sealed class CC { is ListRemoteCtrls -> "listRemoteCtrls" is StopRemoteCtrl -> "stopRemoteCtrl" is DeleteRemoteCtrl -> "deleteRemoteCtrl" + is ApiUploadStandaloneFile -> "apiUploadStandaloneFile" + is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" + is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ShowVersion -> "showVersion" } @@ -2671,6 +2763,7 @@ sealed class CC { is ApiHideUser -> ApiHideUser(userId, obfuscate(viewPwd)) is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) + is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) else -> this } @@ -3796,6 +3889,13 @@ val json = Json { explicitNulls = false } +val jsonShort = Json { + prettyPrint = false + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false +} + val yaml = Yaml(configuration = YamlConfiguration( strictMode = false, encodeDefaults = false, @@ -3985,20 +4085,28 @@ sealed class CR { // receiving file events @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR() - @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR() + @Serializable @SerialName("standaloneFileInfo") class StandaloneFileInfo(val fileMeta: MigrationFileLinkData?): CR() + @Serializable @SerialName("rcvStandaloneFileCreated") class RcvStandaloneFileCreated(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR() // send by chats + @Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val receivedSize: Long, val totalSize: Long, val rcvFileTransfer: RcvFileTransfer): CR() @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: UserRef, val chatItem: AChatItem): CR() - @Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvStandaloneFileComplete") class RcvStandaloneFileComplete(val user: UserRef, val targetPath: String, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR() @Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR() - @Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR() - @Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR() // sending file events @Serializable @SerialName("sndFileStart") class SndFileStart(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() - @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR() + @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem_: AChatItem?, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() + @Serializable @SerialName("sndStandaloneFileCreated") class SndStandaloneFileCreated(val user: UserRef, val fileTransferMeta: FileTransferMeta): CR() // returned by _upload + @Serializable @SerialName("sndFileStartXFTP") class SndFileStartXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR() // not used + @Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR() + @Serializable @SerialName("sndFileRedirectStartXFTP") class SndFileRedirectStartXFTP(val user: UserRef, val fileTransferMeta: FileTransferMeta, val redirectMeta: FileTransferMeta): CR() @Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR() - @Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem: AChatItem): CR() + @Serializable @SerialName("sndStandaloneFileComplete") class SndStandaloneFileComplete(val user: UserRef, val fileTransferMeta: FileTransferMeta, val rcvURIs: List): CR() + @Serializable @SerialName("sndFileCancelledXFTP") class SndFileCancelledXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR() + @Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR() // call events @Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR() @Serializable @SerialName("callInvitations") class CallInvitations(val callInvitations: List): CR() @@ -4032,6 +4140,7 @@ sealed class CR { @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() + @Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -4141,19 +4250,27 @@ sealed class CR { is NewMemberContactSentInv -> "newMemberContactSentInv" is NewMemberContactReceivedInv -> "newMemberContactReceivedInv" is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled" + is StandaloneFileInfo -> "standaloneFileInfo" + is RcvStandaloneFileCreated -> "rcvStandaloneFileCreated" is RcvFileAccepted -> "rcvFileAccepted" is RcvFileStart -> "rcvFileStart" is RcvFileComplete -> "rcvFileComplete" + is RcvStandaloneFileComplete -> "rcvStandaloneFileComplete" is RcvFileCancelled -> "rcvFileCancelled" + is SndStandaloneFileCreated -> "sndStandaloneFileCreated" + is SndFileStartXFTP -> "sndFileStartXFTP" is RcvFileSndCancelled -> "rcvFileSndCancelled" is RcvFileProgressXFTP -> "rcvFileProgressXFTP" + is SndFileRedirectStartXFTP -> "sndFileRedirectStartXFTP" is RcvFileError -> "rcvFileError" - is SndFileCancelled -> "sndFileCancelled" + is SndFileStart -> "sndFileStart" is SndFileComplete -> "sndFileComplete" is SndFileRcvCancelled -> "sndFileRcvCancelled" - is SndFileStart -> "sndFileStart" + is SndFileCancelled -> "sndFileCancelled" is SndFileProgressXFTP -> "sndFileProgressXFTP" is SndFileCompleteXFTP -> "sndFileCompleteXFTP" + is SndStandaloneFileComplete -> "sndStandaloneFileComplete" + is SndFileCancelledXFTP -> "sndFileCancelledXFTP" is SndFileError -> "sndFileError" is CallInvitations -> "callInvitations" is CallInvitation -> "callInvitation" @@ -4183,6 +4300,7 @@ sealed class CR { is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" is ArchiveImported -> "archiveImported" + is AppSettingsR -> "appSettings" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -4195,7 +4313,7 @@ sealed class CR { is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, json.encodeToString(chat)) - is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(AChatItem)}\n${json.encodeToString(chatItemInfo)}") + is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) @@ -4292,20 +4410,28 @@ sealed class CR { is NewMemberContactSentInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is NewMemberContactReceivedInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is RcvFileAcceptedSndCancelled -> withUser(user, noDetails()) + is StandaloneFileInfo -> json.encodeToString(fileMeta) + is RcvStandaloneFileCreated -> noDetails() is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem)) is RcvFileStart -> withUser(user, json.encodeToString(chatItem)) is RcvFileComplete -> withUser(user, json.encodeToString(chatItem)) - is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem)) + is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem_)) is RcvFileSndCancelled -> withUser(user, json.encodeToString(chatItem)) - is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize") - is RcvFileError -> withUser(user, json.encodeToString(chatItem)) - is SndFileCancelled -> json.encodeToString(chatItem) + is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize") + is RcvStandaloneFileComplete -> withUser(user, targetPath) + is RcvFileError -> withUser(user, json.encodeToString(chatItem_)) + is SndFileCancelled -> json.encodeToString(chatItem_) + is SndStandaloneFileCreated -> noDetails() + is SndFileStartXFTP -> withUser(user, json.encodeToString(chatItem)) is SndFileComplete -> withUser(user, json.encodeToString(chatItem)) - is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem)) + is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem_)) is SndFileStart -> withUser(user, json.encodeToString(chatItem)) - is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nsentSize: $sentSize\ntotalSize: $totalSize") + is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nsentSize: $sentSize\ntotalSize: $totalSize") + is SndFileRedirectStartXFTP -> withUser(user, json.encodeToString(redirectMeta)) is SndFileCompleteXFTP -> withUser(user, json.encodeToString(chatItem)) - is SndFileError -> withUser(user, json.encodeToString(chatItem)) + is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString()) + is SndFileCancelledXFTP -> withUser(user, json.encodeToString(chatItem_)) + is SndFileError -> withUser(user, json.encodeToString(chatItem_)) is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}" is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}" is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}") @@ -4351,6 +4477,7 @@ sealed class CR { is ChatCmdError -> withUser(user_, chatError.string) is ChatRespError -> withUser(user_, chatError.string) is ArchiveImported -> "${archiveErrors.map { it.string } }" + is AppSettingsR -> json.encodeToString(appSettings) is Response -> json is Invalid -> str } @@ -4764,6 +4891,7 @@ sealed class StoreError { is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId" is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP" is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP" + is ExtraFileDescrNotFoundXFTP -> "extraFileDescrNotFoundXFTP" is ConnectionNotFound -> "connectionNotFound" is ConnectionNotFoundById -> "connectionNotFoundById" is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId" @@ -4822,6 +4950,7 @@ sealed class StoreError { @Serializable @SerialName("fileIdNotFoundBySharedMsgId") class FileIdNotFoundBySharedMsgId(val sharedMsgId: String): StoreError() @Serializable @SerialName("sndFileNotFoundXFTP") class SndFileNotFoundXFTP(val agentSndFileId: String): StoreError() @Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError() + @Serializable @SerialName("extraFileDescrNotFoundXFTP") class ExtraFileDescrNotFoundXFTP(val fileId: Long): StoreError() @Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError() @Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError() @Serializable @SerialName("connectionNotFoundByMemberId") class ConnectionNotFoundByMemberId(val groupMemberId: Long): StoreError() @@ -5167,3 +5296,205 @@ enum class NotificationsMode() { val default: NotificationsMode = SERVICE } } + +@Serializable +data class AppSettings( + var networkConfig: NetCfg? = null, + var privacyEncryptLocalFiles: Boolean? = null, + var privacyAcceptImages: Boolean? = null, + var privacyLinkPreviews: Boolean? = null, + var privacyShowChatPreviews: Boolean? = null, + var privacySaveLastDraft: Boolean? = null, + var privacyProtectScreen: Boolean? = null, + var notificationMode: AppSettingsNotificationMode? = null, + var notificationPreviewMode: AppSettingsNotificationPreviewMode? = null, + var webrtcPolicyRelay: Boolean? = null, + var webrtcICEServers: List? = null, + var confirmRemoteSessions: Boolean? = null, + var connectRemoteViaMulticast: Boolean? = null, + var connectRemoteViaMulticastAuto: Boolean? = null, + var developerTools: Boolean? = null, + var confirmDBUpgrades: Boolean? = null, + var androidCallOnLockScreen: AppSettingsLockScreenCalls? = null, + var iosCallKitEnabled: Boolean? = null, + var iosCallKitCallsInRecents: Boolean? = null, +) { + fun prepareForExport(): AppSettings { + val empty = AppSettings() + val def = defaults + if (networkConfig != def.networkConfig) { empty.networkConfig = networkConfig } + if (privacyEncryptLocalFiles != def.privacyEncryptLocalFiles) { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } + if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages } + if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews } + if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews } + if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft } + if (privacyProtectScreen != def.privacyProtectScreen) { empty.privacyProtectScreen = privacyProtectScreen } + if (notificationMode != def.notificationMode) { empty.notificationMode = notificationMode } + if (notificationPreviewMode != def.notificationPreviewMode) { empty.notificationPreviewMode = notificationPreviewMode } + if (webrtcPolicyRelay != def.webrtcPolicyRelay) { empty.webrtcPolicyRelay = webrtcPolicyRelay } + if (webrtcICEServers != def.webrtcICEServers) { empty.webrtcICEServers = webrtcICEServers } + if (confirmRemoteSessions != def.confirmRemoteSessions) { empty.confirmRemoteSessions = confirmRemoteSessions } + if (connectRemoteViaMulticast != def.connectRemoteViaMulticast) { empty.connectRemoteViaMulticast = connectRemoteViaMulticast } + if (connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto) { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto } + if (developerTools != def.developerTools) { empty.developerTools = developerTools } + if (confirmDBUpgrades != def.confirmDBUpgrades) { empty.confirmDBUpgrades = confirmDBUpgrades } + if (androidCallOnLockScreen != def.androidCallOnLockScreen) { empty.androidCallOnLockScreen = androidCallOnLockScreen } + if (iosCallKitEnabled != def.iosCallKitEnabled) { empty.iosCallKitEnabled = iosCallKitEnabled } + if (iosCallKitCallsInRecents != def.iosCallKitCallsInRecents) { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } + return empty + } + + fun importIntoApp() { + val def = appPreferences + var net = networkConfig?.copy() + if (net != null) { + // migrating from iOS BUT shouldn't be here ever because it should be changed on migration stage + if (net.hostMode == HostMode.Onion) { + net = net.copy(hostMode = HostMode.Public, requiredHostMode = true) + } + setNetCfg(net) + } + privacyEncryptLocalFiles?.let { def.privacyEncryptLocalFiles.set(it) } + privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } + privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) } + privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } + privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } + privacyProtectScreen?.let { def.privacyProtectScreen.set(it) } + notificationMode?.let { def.notificationsMode.set(it.toNotificationsMode()) } + notificationPreviewMode?.let { def.notificationPreviewMode.set(it.toNotificationPreviewMode().name) } + webrtcPolicyRelay?.let { def.webrtcPolicyRelay.set(it) } + webrtcICEServers?.let { def.webrtcIceServers.set(it.joinToString(separator = "\n")) } + confirmRemoteSessions?.let { def.confirmRemoteSessions.set(it) } + connectRemoteViaMulticast?.let { def.connectRemoteViaMulticast.set(it) } + connectRemoteViaMulticastAuto?.let { def.connectRemoteViaMulticastAuto.set(it) } + developerTools?.let { def.developerTools.set(it) } + confirmDBUpgrades?.let { def.confirmDBUpgrades.set(it) } + androidCallOnLockScreen?.let { def.callOnLockScreen.set(it.toCallOnLockScreen()) } + iosCallKitEnabled?.let { def.iosCallKitEnabled.set(it) } + iosCallKitCallsInRecents?.let { def.iosCallKitCallsInRecents.set(it) } + } + + companion object { + val defaults: AppSettings + get() = AppSettings( + networkConfig = NetCfg.defaults, + privacyEncryptLocalFiles = true, + privacyAcceptImages = true, + privacyLinkPreviews = true, + privacyShowChatPreviews = true, + privacySaveLastDraft = true, + privacyProtectScreen = false, + notificationMode = AppSettingsNotificationMode.INSTANT, + notificationPreviewMode = AppSettingsNotificationPreviewMode.MESSAGE, + webrtcPolicyRelay = true, + webrtcICEServers = emptyList(), + confirmRemoteSessions = false, + connectRemoteViaMulticast = true, + connectRemoteViaMulticastAuto = true, + developerTools = false, + confirmDBUpgrades = false, + androidCallOnLockScreen = AppSettingsLockScreenCalls.SHOW, + iosCallKitEnabled = true, + iosCallKitCallsInRecents = false + ) + + val current: AppSettings + get() { + val def = appPreferences + return defaults.copy( + networkConfig = getNetCfg(), + privacyEncryptLocalFiles = def.privacyEncryptLocalFiles.get(), + privacyAcceptImages = def.privacyAcceptImages.get(), + privacyLinkPreviews = def.privacyLinkPreviews.get(), + privacyShowChatPreviews = def.privacyShowChatPreviews.get(), + privacySaveLastDraft = def.privacySaveLastDraft.get(), + privacyProtectScreen = def.privacyProtectScreen.get(), + notificationMode = AppSettingsNotificationMode.from(def.notificationsMode.get()), + notificationPreviewMode = AppSettingsNotificationPreviewMode.from(NotificationPreviewMode.valueOf(def.notificationPreviewMode.get()!!)), + webrtcPolicyRelay = def.webrtcPolicyRelay.get(), + webrtcICEServers = def.webrtcIceServers.get()?.lines(), + confirmRemoteSessions = def.confirmRemoteSessions.get(), + connectRemoteViaMulticast = def.connectRemoteViaMulticast.get(), + connectRemoteViaMulticastAuto = def.connectRemoteViaMulticastAuto.get(), + developerTools = def.developerTools.get(), + confirmDBUpgrades = def.confirmDBUpgrades.get(), + androidCallOnLockScreen = AppSettingsLockScreenCalls.from(def.callOnLockScreen.get()), + iosCallKitEnabled = def.iosCallKitEnabled.get(), + iosCallKitCallsInRecents = def.iosCallKitCallsInRecents.get(), + ) + } + } +} + +@Serializable +enum class AppSettingsNotificationMode { + @SerialName("off") OFF, + @SerialName("periodic") PERIODIC, + @SerialName("instant") INSTANT; + + fun toNotificationsMode(): NotificationsMode = + when (this) { + INSTANT -> NotificationsMode.SERVICE + PERIODIC -> NotificationsMode.PERIODIC + OFF -> NotificationsMode.OFF + } + + companion object { + fun from(mode: NotificationsMode): AppSettingsNotificationMode = + when (mode) { + NotificationsMode.SERVICE -> INSTANT + NotificationsMode.PERIODIC -> PERIODIC + NotificationsMode.OFF -> OFF + } + } +} + +@Serializable +enum class AppSettingsNotificationPreviewMode { + @SerialName("message") MESSAGE, + @SerialName("contact") CONTACT, + @SerialName("hidden") HIDDEN; + + fun toNotificationPreviewMode(): NotificationPreviewMode = + when (this) { + MESSAGE -> NotificationPreviewMode.MESSAGE + CONTACT -> NotificationPreviewMode.CONTACT + HIDDEN -> NotificationPreviewMode.HIDDEN + } + + companion object { + val default: AppSettingsNotificationPreviewMode = MESSAGE + + fun from(mode: NotificationPreviewMode): AppSettingsNotificationPreviewMode = + when (mode) { + NotificationPreviewMode.MESSAGE -> MESSAGE + NotificationPreviewMode.CONTACT -> CONTACT + NotificationPreviewMode.HIDDEN -> HIDDEN + } + } +} + +@Serializable +enum class AppSettingsLockScreenCalls { + @SerialName("disable") DISABLE, + @SerialName("show") SHOW, + @SerialName("accept") ACCEPT; + + fun toCallOnLockScreen(): CallOnLockScreen = + when (this) { + DISABLE -> CallOnLockScreen.DISABLE + SHOW -> CallOnLockScreen.SHOW + ACCEPT -> CallOnLockScreen.ACCEPT + } + + companion object { + val default = SHOW + + fun from(mode: CallOnLockScreen): AppSettingsLockScreenCalls = + when (mode) { + CallOnLockScreen.DISABLE -> DISABLE + CallOnLockScreen.SHOW -> SHOW + CallOnLockScreen.ACCEPT -> ACCEPT + } + } +} 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 7e2ba462c9..1a186ce8ad 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 @@ -5,10 +5,12 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword +import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString +import java.io.File import java.nio.ByteBuffer // ghc's rts @@ -137,6 +139,33 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } +fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair { + val dbKey = key ?: randomDatabasePassword() + Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath") + val migrated = chatMigrateInit(dbPath, dbKey, confirmation.value) + val res = runCatching { + json.decodeFromString(migrated[0] as String) + }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } + + return res to migrated[1] as ChatCtrl +} + +fun chatInitControllerRemovingDatabases() { + val dbPath = dbAbsolutePrefixPath + val dbKey = randomDatabasePassword() + Log.d(TAG, "chatInitControllerRemovingDatabases path: $dbPath") + val migrated = chatMigrateInit(dbPath, dbKey, MigrationConfirmation.Error.value) + val res = runCatching { + json.decodeFromString(migrated[0] as String) + }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } + + val ctrl = migrated[1] as Long + chatController.ctrl = ctrl + // We need only controller, not databases + File(dbPath + "_chat.db").delete() + File(dbPath + "_agent.db").delete() +} + fun showStartChatAfterRestartAlert(): CompletableDeferred { val deferred = CompletableDeferred() AlertManager.shared.showAlertDialog( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index a6c93cc2f3..7ae2ab23dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -66,6 +66,8 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) { } } +fun getMigrationTempFilesDirectory(): File = File(dataDir, "migration_temp_files") + fun getAppFilePath(fileName: String): String { val rh = chatModel.currentRemoteHost.value val s = File.separator 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 8ce92f6154..03878a19d1 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 @@ -1,5 +1,6 @@ package chat.simplex.common.platform +import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatId import chat.simplex.common.model.NotificationsMode @@ -16,6 +17,7 @@ interface PlatformInterface { fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {} fun androidPictureInPictureAllowed(): Boolean = true fun androidCallEnded() {} + @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true } /** diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 7bd9fbc66f..7ee9442b11 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -1,9 +1,8 @@ package chat.simplex.common.views.database import SectionBottomSpacer -import SectionItemView import SectionItemViewSpaceBetween -import SectionTextFooter +import SectionSpacer import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -24,20 +23,22 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.* import chat.simplex.common.model.* +import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.* -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock import kotlin.math.log2 @Composable -fun DatabaseEncryptionView(m: ChatModel) { +fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) { val progressIndicator = remember { mutableStateOf(false) } val prefs = m.controller.appPrefs val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } @@ -61,9 +62,10 @@ fun DatabaseEncryptionView(m: ChatModel) { storedKey, initialRandomDBPassphrase, progressIndicator, + migration, onConfirmEncrypt = { withLongRunningApi { - encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator) + encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator, migration) } } ) @@ -95,24 +97,34 @@ fun DatabaseEncryptionLayout( storedKey: MutableState, initialRandomDBPassphrase: MutableState, progressIndicator: MutableState, + migration: Boolean, onConfirmEncrypt: () -> Unit, ) { Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + if (!migration) Modifier.fillMaxWidth().verticalScroll(rememberScrollState()) else Modifier.fillMaxWidth(), ) { - AppBarTitle(stringResource(MR.strings.database_passphrase)) - SectionView(null) { - SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked -> + if (!migration) { + AppBarTitle(stringResource(MR.strings.database_passphrase)) + } else { + ChatStoppedView() + SectionSpacer() + } + SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) { + SavePassphraseSetting( + useKeychain.value, + initialRandomDBPassphrase.value, + storedKey.value, + enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration + ) { checked -> if (checked) { - setUseKeychain(true, useKeychain, prefs) - } else if (storedKey.value) { + setUseKeychain(true, useKeychain, prefs, migration) + } else if (storedKey.value && !migration) { + // Don't show in migration process since it will remove the key after successful encryption removePassphraseAlert { - DatabaseUtils.ksDatabasePassword.remove() - setUseKeychain(false, useKeychain, prefs) - storedKey.value = false + removePassphraseFromKeyChain(useKeychain, prefs, storedKey, false) } } else { - setUseKeychain(false, useKeychain, prefs) + setUseKeychain(false, useKeychain, prefs, migration) } } @@ -169,12 +181,12 @@ fun DatabaseEncryptionLayout( ) SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) { - Text(generalGetString(MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + Text(generalGetString(if (migration) MR.strings.set_passphrase else MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } Column { - DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase) + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration) } SectionBottomSpacer() } @@ -211,8 +223,9 @@ expect fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, - progressIndicator: Boolean, minHeight: Dp = TextFieldDefaults.MinHeight, + enabled: Boolean, + smallPadding: Boolean = true, onCheckedChange: (Boolean) -> Unit, ) @@ -222,8 +235,18 @@ expect fun DatabaseEncryptionFooter( chatDbEncrypted: Boolean?, storedKey: MutableState, initialRandomDBPassphrase: MutableState, + migration: Boolean, ) +@Composable +fun ChatStoppedView() { + SettingsActionItem( + icon = painterResource(MR.images.ic_report_filled), + text = stringResource(MR.strings.chat_is_stopped), + iconColor = Color.Red, + ) +} + fun resetFormAfterEncryption( m: ChatModel, initialRandomDBPassphrase: MutableState, @@ -242,9 +265,18 @@ fun resetFormAfterEncryption( m.controller.appPrefs.initialRandomDBPassphrase.set(false) } -fun setUseKeychain(value: Boolean, useKeychain: MutableState, prefs: AppPreferences) { +fun setUseKeychain(value: Boolean, useKeychain: MutableState, prefs: AppPreferences, migration: Boolean) { useKeychain.value = value - prefs.storeDBPassphrase.set(value) + // Postpone it when migrating to the end of encryption process + if (!migration) { + prefs.storeDBPassphrase.set(value) + } +} + +private fun removePassphraseFromKeyChain(useKeychain: MutableState, prefs: AppPreferences, storedKey: MutableState, migration: Boolean) { + DatabaseUtils.ksDatabasePassword.remove() + setUseKeychain(false, useKeychain, prefs, migration) + storedKey.value = false } fun storeSecurelySaved() = generalGetString(MR.strings.store_passphrase_securely) @@ -267,6 +299,7 @@ fun PassphraseField( isValid: (String) -> Boolean, keyboardActions: KeyboardActions = KeyboardActions(), dependsOn: State? = null, + requestFocus: Boolean = false, ) { var valid by remember { mutableStateOf(validKey(key.value)) } var showKey by remember { mutableStateOf(false) } @@ -295,6 +328,7 @@ fun PassphraseField( val color = MaterialTheme.colors.onBackground val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) val interactionSource = remember { MutableInteractionSource() } + val focusRequester = remember { FocusRequester() } BasicTextField( value = state.value, modifier = modifier @@ -304,7 +338,8 @@ fun PassphraseField( .defaultMinSize( minWidth = TextFieldDefaults.MinWidth, minHeight = TextFieldDefaults.MinHeight - ), + ) + .focusRequester(focusRequester), onValueChange = { state.value = it key.value = it.text @@ -347,6 +382,12 @@ fun PassphraseField( ) } ) + LaunchedEffect(Unit) { + if (requestFocus) { + delay(200) + focusRequester.requestFocus() + } + } LaunchedEffect(Unit) { snapshotFlow { dependsOn?.value } .distinctUntilChanged() @@ -363,13 +404,17 @@ suspend fun encryptDatabase( initialRandomDBPassphrase: MutableState, useKeychain: MutableState, storedKey: MutableState, - progressIndicator: MutableState + progressIndicator: MutableState, + migration: Boolean, ): Boolean { val m = ChatModel val prefs = ChatController.appPrefs progressIndicator.value = true return try { prefs.encryptionStartedAt.set(Clock.System.now()) + if (!m.chatDbChanged.value) { + m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) + } val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) prefs.encryptionStartedAt.set(null) val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError @@ -393,9 +438,14 @@ suspend fun encryptDatabase( } else -> { val new = newKey.value + if (migration) { + appPreferences.storeDBPassphrase.set(useKeychain.value) + } resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) if (useKeychain.value) { DatabaseUtils.ksDatabasePassword.set(new) + } else if (migration) { + removePassphraseFromKeyChain(useKeychain, prefs, storedKey, true) } operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) @@ -474,6 +524,7 @@ fun PreviewDatabaseEncryptionLayout() { storedKey = remember { mutableStateOf(true) }, initialRandomDBPassphrase = remember { mutableStateOf(true) }, progressIndicator = remember { mutableStateOf(false) }, + migration = false, onConfirmEncrypt = {}, ) } 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 0c208c06e8..a22e6399f7 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 @@ -206,6 +206,14 @@ private fun runChat( is DBMigrationResult.OK -> { platform.androidChatStartedAfterBeingOff() } + null -> {} + else -> showErrorOnMigrationIfNeeded(status) + } +} + +fun showErrorOnMigrationIfNeeded(status: DBMigrationResult) = + when (status) { + is DBMigrationResult.OK -> {} is DBMigrationResult.ErrorNotADatabase -> AlertManager.shared.showAlertMsg(generalGetString(MR.strings.wrong_passphrase_title), generalGetString(MR.strings.enter_correct_passphrase)) is DBMigrationResult.ErrorSQL -> @@ -217,9 +225,7 @@ private fun runChat( is DBMigrationResult.InvalidConfirmation -> AlertManager.shared.showAlertMsg(generalGetString(MR.strings.invalid_migration_confirmation)) is DBMigrationResult.ErrorMigration -> {} - null -> {} } -} private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean { val startedAt = prefs.encryptionStartedAt.get() ?: return false @@ -246,7 +252,7 @@ private fun restoreDb(restoreDbFromBackup: MutableState, prefs: AppPref } } -private fun mtrErrorDescription(err: MTRError): String = +fun mtrErrorDescription(err: MTRError): String = when (err) { is MTRError.NoDown -> String.format(generalGetString(MR.strings.mtr_error_no_down_migration), err.dbMigrations.joinToString(", ")) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 8680c98d46..8d7d9f8166 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -211,7 +211,7 @@ fun DatabaseLayout( if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), - click = showSettingsModal() { DatabaseEncryptionView(it) }, + click = showSettingsModal() { DatabaseEncryptionView(it, false) }, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) @@ -486,6 +486,7 @@ fun deleteChatDatabaseFilesAndState() { filesDir.mkdir() remoteHostsDir.deleteRecursively() tmpDir.deleteRecursively() + getMigrationTempFilesDirectory().deleteRecursively() tmpDir.mkdir() DatabaseUtils.ksDatabasePassword.remove() controller.appPrefs.storeDBPassphrase.set(true) @@ -509,7 +510,7 @@ private fun exportArchive( progressIndicator.value = true withLongRunningApi { try { - val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile) + val archiveFile = exportChatArchive(m, null, chatArchiveName, chatArchiveTime, chatArchiveFile) chatArchiveFile.value = archiveFile saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator)) progressIndicator.value = false @@ -520,8 +521,9 @@ private fun exportArchive( } } -private suspend fun exportChatArchive( +suspend fun exportChatArchive( m: ChatModel, + storagePath: File?, chatArchiveName: MutableState, chatArchiveTime: MutableState, chatArchiveFile: MutableState @@ -529,13 +531,19 @@ private suspend fun exportChatArchive( val archiveTime = Clock.System.now() val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant())) val archiveName = "simplex-chat.$ts.zip" - val archivePath = "${filesDir.absolutePath}${File.separator}$archiveName" + val archivePath = "${(storagePath ?: filesDir).absolutePath}${File.separator}$archiveName" val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) + // Settings should be saved before changing a passphrase, otherwise the database needs to be migrated first + if (!m.chatDbChanged.value) { + controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) + } m.controller.apiExportArchive(config) - deleteOldArchive(m) - m.controller.appPrefs.chatArchiveName.set(archiveName) + if (storagePath == null) { + deleteOldArchive(m) + m.controller.appPrefs.chatArchiveName.set(archiveName) + m.controller.appPrefs.chatArchiveTime.set(archiveTime) + } chatArchiveName.value = archiveName - m.controller.appPrefs.chatArchiveTime.set(archiveTime) chatArchiveTime.value = archiveTime chatArchiveFile.value = archivePath return archivePath diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 52dc2c0658..0ad7af439f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -64,7 +64,7 @@ object DatabaseUtils { return dbKey } - private fun randomDatabasePassword(): String { + fun randomDatabasePassword(): String { val s = ByteArray(32) SecureRandom().nextBytes(s) return s.toBase64StringForPassphrase().replace("\n", "") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt index 104a01150f..675584ae13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt @@ -15,7 +15,7 @@ fun DefaultProgressView(description: String?) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator( Modifier - .padding(bottom = DEFAULT_PADDING) + .padding(bottom = if (description != null) DEFAULT_PADDING else 0.dp) .size(30.dp), color = MaterialTheme.colors.secondary, strokeWidth = 2.5.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index ce4d8da47f..887a5bfdd9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -19,17 +19,18 @@ import kotlin.math.min fun ModalView( close: () -> Unit, showClose: Boolean = true, + enableClose: Boolean = true, background: Color = MaterialTheme.colors.background, modifier: Modifier = Modifier, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit, ) { if (showClose) { - BackHandler(onBack = close) + BackHandler(enabled = enableClose, onBack = close) } Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) { Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) { - CloseSheetBar(close, showClose, endButtons = endButtons) + CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons) Box(modifier) { content() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt new file mode 100644 index 0000000000..52698a6e47 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt @@ -0,0 +1,721 @@ +package chat.simplex.common.views.migration + +import SectionBottomSpacer +import SectionItemView +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import chat.simplex.common.model.* +import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_STAGE +import chat.simplex.common.model.ChatController.getNetCfg +import chat.simplex.common.model.ChatController.startChat +import chat.simplex.common.model.ChatCtrl +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* +import kotlinx.datetime.Clock +import kotlinx.datetime.toJavaInstant +import kotlinx.serialization.* +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.max + +@Serializable +sealed class MigrationFromAnotherDeviceState { + @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationFromAnotherDeviceState() + @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() + @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() + @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationFromAnotherDeviceState() + + companion object { + // Here we check whether it's needed to show migration process after app restart or not + // It's important to NOT show the process when archive was corrupted/not fully downloaded + fun transform(): MigrationFromAnotherDeviceState? { + val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_STAGE) + var state: MigrationFromAnotherDeviceState? = if (stage != null) json.decodeFromString(stage) else null + if (state is DownloadProgress) { + // No migration happens at the moment actually since archive were not downloaded fully + Log.e(TAG, "MigrateFromDevice: archive wasn't fully downloaded, removed broken file") + state = null + } else if (state is Onion) { + state = null + } else if (state is ArchiveImport && !File(getMigrationTempFilesDirectory(), state.archiveName).exists()) { + Log.e(TAG, "MigrateFromDevice: archive was removed unintentionally or state is broken, dropping migration") + state = null + } + if (state == null) { + settings.remove(SHARED_PREFS_MIGRATION_STAGE) + getMigrationTempFilesDirectory().deleteRecursively() + } + return state + } + + fun save(state: MigrationFromAnotherDeviceState?) { + if (state != null) { + appPreferences.migrationStage.set(json.encodeToString(state)) + } else { + appPreferences.migrationStage.set(null) + } + chatModel.migrationState.value = state + } + } +} + +@Serializable +private sealed class MigrationState { + @Serializable object PasteOrScanLink: MigrationState() + @Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationState() + @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationState() + @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationState() + @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationState() + @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() + @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() +} + +private var MutableState.state: MigrationState + get() = value + set(v) { value = v } + +@Composable +fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceState? = null, close: () -> Unit) { + val migrationState = rememberSaveable(stateSaver = serializableSaver()) { + mutableStateOf( + when (state) { + null -> MigrationState.PasteOrScanLink + is MigrationFromAnotherDeviceState.Onion -> { + MigrationState.Onion(state.link, state.socksProxy, state.hostMode, state.requiredHostMode) + } + is MigrationFromAnotherDeviceState.DownloadProgress -> { + val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) + // SHOULDN'T BE HERE because the app checks this before opening migration screen and will not open it in this case. + // See analyzeMigrationState() + MigrationState.DownloadFailed(totalBytes = 0, link = state.link, archivePath = archivePath.absolutePath, state.netCfg) + } + is MigrationFromAnotherDeviceState.ArchiveImport -> { + val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) + MigrationState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg) + } + is MigrationFromAnotherDeviceState.Passphrase -> { + MigrationState.Passphrase("", state.netCfg) + } + } + ) + } + // Prevent from hiding the view until migration is finished or app deleted + val backDisabled = remember { + derivedStateOf { + val s = chatModel.migrationState.value + s is MigrationFromAnotherDeviceState.ArchiveImport || + s is MigrationFromAnotherDeviceState.Passphrase || + migrationState.value is MigrationState.DatabaseInit + } + } + val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) } + ModalView( + enableClose = !backDisabled.value, + close = { + withBGApi { + migrationState.cleanUpOnBack(chatReceiver.value) + close() + } + }, + ) { + MigrateFromAnotherDeviceLayout( + migrationState = migrationState, + chatReceiver = chatReceiver, + close = close, + ) + } +} + +@Composable +private fun ModalData.MigrateFromAnotherDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState, + close: () -> Unit, +) { + val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), + ) { + AppBarTitle(stringResource(MR.strings.migrate_here)) + SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close) + SectionBottomSpacer() + } + platform.androidLockPortraitOrientation() +} + +@Composable +private fun ModalData.SectionByState( + migrationState: MutableState, + tempDatabaseFile: File, + chatReceiver: MutableState, + close: () -> Unit +) { + when (val s = migrationState.value) { + is MigrationState.PasteOrScanLink -> migrationState.PasteOrScanLinkView() + is MigrationState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState) + is MigrationState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg) + is MigrationState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg) + is MigrationState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes) + is MigrationState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg) + is MigrationState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg) + is MigrationState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg) + is MigrationState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg) + is MigrationState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg) + is MigrationState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close) + } +} + +@Composable +private fun MutableState.PasteOrScanLinkView() { + if (appPlatform.isAndroid) { + SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) { + QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text -> + withBGApi { checkUserLink(text) } + } + } + SectionSpacer() + } + + if (appPlatform.isDesktop || appPreferences.developerTools.get()) { + SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) { + PasteLinkView() + } + } +} + +@Composable +private fun MutableState.PasteLinkView() { + val clipboard = LocalClipboardManager.current + SectionItemView({ + val str = clipboard.getText()?.text ?: return@SectionItemView + withBGApi { checkUserLink(str) } + }) { + Text(stringResource(MR.strings.tap_to_paste_link)) + } +} + +@Composable +private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { + val onionHosts = remember { stateGetOrPut("onionHosts") { + getNetCfg().copy(socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts + } } + val networkUseSocksProxy = remember { stateGetOrPut("networkUseSocksProxy") { socksProxy != null } } + val sessionMode = remember { stateGetOrPut("sessionMode") { TransportSessionMode.User} } + val networkProxyHostPort = remember { stateGetOrPut("networkHostProxyPort") { + var proxy = (socksProxy ?: chatModel.controller.appPrefs.networkProxyHostPort.get()) + if (proxy?.startsWith(":") == true) proxy = "localhost$proxy" + proxy + } + } + val proxyPort = remember { derivedStateOf { networkProxyHostPort.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } } + + val netCfg = rememberSaveable(stateSaver = serializableSaver()) { + mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = socksProxy, sessionMode = sessionMode.value)) + } + + SectionView(stringResource(MR.strings.migration_from_device_confirm_network_settings).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_check), + text = stringResource(MR.strings.migration_from_device_apply_onion), + textColor = MaterialTheme.colors.primary, + click = { + val updated = netCfg.value + .withOnionHosts(onionHosts.value) + .withHostPort(if (networkUseSocksProxy.value) networkProxyHostPort.value else null, null) + .copy( + sessionMode = sessionMode.value + ) + withBGApi { + state.value = MigrationState.DatabaseInit(link, updated) + } + } + ){} + SectionTextFooter(stringResource(MR.strings.migration_from_device_confirm_network_settings_footer)) + } + + SectionSpacer() + + val networkProxyHostPortPref = SharedPreference(get = { networkProxyHostPort.value }, set = { + networkProxyHostPort.value = it + }) + SectionView(stringResource(MR.strings.network_settings_title).uppercase()) { + OnionRelatedLayout( + appPreferences.developerTools.get(), + networkUseSocksProxy, + onionHosts, + sessionMode, + networkProxyHostPortPref, + proxyPort, + toggleSocksProxy = { enable -> + networkUseSocksProxy.value = enable + }, + useOnion = { + onionHosts.value = it + }, + updateSessionMode = { + sessionMode.value = it + } + ) + } +} + +@Composable +private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_database_init).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + prepareDatabase(link, tempDatabaseFile, netCfg) + } +} + +@Composable +private fun MutableState.LinkDownloadingView( + link: String, + ctrl: ChatCtrl, + user: User, + archivePath: String, + tempDatabaseFile: File, + chatReceiver: MutableState, + netCfg: NetCfg +) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_downloading_details).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + startDownloading(0, ctrl, user, tempDatabaseFile, chatReceiver, link, archivePath, netCfg) + } +} + +@Composable +private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_downloading_archive).uppercase()) { + val ratio = downloadedBytes.toFloat() / max(totalBytes, 1) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_from_device_bytes_downloaded).format(formatBytes(downloadedBytes))) + } + } +} + +@Composable +private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationFromChatReceiver?, archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migration_from_device_download_failed).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_download), + text = stringResource(MR.strings.migration_from_device_repeat_download), + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationState.DatabaseInit(link, netCfg) + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + } + LaunchedEffect(Unit) { + chatReceiver?.stopAndCleanUp() + File(archivePath).delete() + MigrationFromAnotherDeviceState.save(null) + } +} + +@Composable +private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_importing_archive).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + importArchive(archivePath, netCfg) + } +} + +@Composable +private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migration_from_device_import_failed).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_download), + text = stringResource(MR.strings.migration_from_device_repeat_import), + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationState.ArchiveImport(archivePath, netCfg) + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + } +} + +@Composable +private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) { + val currentKey = rememberSaveable { mutableStateOf(currentKey) } + val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } + val useKeychain = rememberSaveable { mutableStateOf(appPreferences.storeDBPassphrase.get()) } + + Box { + val view = LocalMultiplatformView() + SectionView(stringResource(MR.strings.migration_from_device_enter_passphrase).uppercase()) { + SavePassphraseSetting( + useKeychain.value, + false, + false, + enabled = !verifyingPassphrase.value, + smallPadding = false + ) { checked -> useKeychain.value = checked } + + PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) + + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_vpn_key_filled), + text = stringResource(MR.strings.open_chat), + textColor = MaterialTheme.colors.primary, + disabled = verifyingPassphrase.value || currentKey.value.isEmpty(), + click = { + verifyingPassphrase.value = true + hideKeyboard(view) + withBGApi { + val (status, _) = chatInitTemporaryDatabase(dbAbsolutePrefixPath, key = currentKey.value, confirmation = MigrationConfirmation.YesUp) + val success = status == DBMigrationResult.OK || status == DBMigrationResult.InvalidConfirmation + if (success) { + state = MigrationState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg) + } else if (status is DBMigrationResult.ErrorMigration) { + state = MigrationState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg) + } else { + showErrorOnMigrationIfNeeded(status) + } + verifyingPassphrase.value = false + } + } + ) {} + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true) + } + if (verifyingPassphrase.value) { + ProgressView() + } + } +} + +@Composable +private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) { + data class Tuple4(val a: A, val b: B, val c: C, val d: D) + val (header: String, button: String?, footer: String, confirmation: MigrationConfirmation?) = when (status) { + is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) { + is MigrationError.Upgrade -> + Tuple4( + generalGetString(MR.strings.database_upgrade), + generalGetString(MR.strings.upgrade_and_open_chat), + "", + MigrationConfirmation.YesUp + ) + is MigrationError.Downgrade -> + Tuple4( + generalGetString(MR.strings.database_downgrade), + generalGetString(MR.strings.downgrade_and_open_chat), + generalGetString(MR.strings.database_downgrade_warning), + MigrationConfirmation.YesUpDown + ) + is MigrationError.Error -> + Tuple4( + generalGetString(MR.strings.incompatible_database_version), + null, + mtrErrorDescription(err.mtrError), + null + ) + } + else -> Tuple4(generalGetString(MR.strings.error), null, generalGetString(MR.strings.unknown_error), null) + } + SectionView(header.uppercase()) { + if (button != null && confirmation != null) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_download), + text = button, + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationState.Migration(passphrase, confirmation, useKeychain, netCfg) + } + ) {} + } + SectionTextFooter(footer) + } +} + +@Composable +private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_migrating).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + startChat(passphrase, confirmation, useKeychain, netCfg, close) + } +} + +@Composable +private fun ProgressView() { + DefaultProgressView(null) +} + +private suspend fun MutableState.checkUserLink(link: String) { + if (strHasSimplexFileLink(link.trim())) { + val data = MigrationFileLinkData.readFromLink(link) + val hasOnionConfigured = data?.networkConfig?.hasOnionConfigured() ?: false + val networkConfig = data?.networkConfig?.transformToPlatformSupported() + // If any of iOS or Android had onion enabled, show onion screen + if (hasOnionConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { + state = MigrationState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) + } else { + val current = getNetCfg() + state = MigrationState.DatabaseInit(link.trim(), current.copy( + socksProxy = networkConfig?.socksProxy, + hostMode = networkConfig?.hostMode ?: current.hostMode, + requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode + )) + } + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_file_link), + text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) + ) + } +} + +private fun MutableState.prepareDatabase( + link: String, + tempDatabaseFile: File, + netCfg: NetCfg, +) { + withLongRunningApi { + val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, netCfg) + if (ctrlAndUser == null) { + state = MigrationState.DownloadFailed(0, link, archivePath(), netCfg) + return@withLongRunningApi + } + + val (ctrl, user) = ctrlAndUser + state = MigrationState.LinkDownloading(link, ctrl, user, archivePath(), netCfg) + } +} + +private fun MutableState.startDownloading( + totalBytes: Long, + ctrl: ChatCtrl, + user: User, + tempDatabaseFile: File, + chatReceiver: MutableState, + link: String, + archivePath: String, + netCfg: NetCfg, +) { + withBGApi { + chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> + when (msg) { + is CR.RcvFileProgressXFTP -> { + state = MigrationState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.DownloadProgress(link, File(archivePath).name, netCfg)) + } + is CR.RcvStandaloneFileComplete -> { + delay(500) + state = MigrationState.ArchiveImport(archivePath, netCfg) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.ArchiveImport(File(archivePath).name, netCfg)) + } + is CR.RcvFileError -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migration_from_device_download_failed), + generalGetString(MR.strings.migration_from_device_file_delete_or_link_invalid) + ) + state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + } + else -> Log.d(TAG, "unsupported event: ${msg.responseType}") + } + } + chatReceiver.value?.start() + + val (res, error) = controller.downloadStandaloneFile(user, link, CryptoFile.plain(File(archivePath).path), ctrl) + if (res == null) { + state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migration_from_device_error_downloading_archive), + error + ) + } + } +} + +private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg) { + withLongRunningApi { + try { + if (ChatController.ctrl == null || ChatController.ctrl == -1L) { + chatInitControllerRemovingDatabases() + } + controller.apiDeleteStorage() + try { + val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) + val archiveErrors = controller.apiImportArchive(config) + if (archiveErrors.isNotEmpty()) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.chat_database_imported), + generalGetString(MR.strings.non_fatal_errors_occured_during_import) + ) + } + state = MigrationState.Passphrase("", netCfg) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Passphrase(netCfg)) + } catch (e: Exception) { + state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_importing_database), e.stackTraceToString()) + } + } catch (e: Exception) { + state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_deleting_database), e.stackTraceToString()) + } + } +} + +private suspend fun stopArchiveDownloading(fileId: Long, ctrl: ChatCtrl) { + controller.apiCancelFile(null, fileId, ctrl) +} + +private fun startChat(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { + if (useKeychain) { + ksDatabasePassword.set(passphrase) + } else { + ksDatabasePassword.remove() + } + appPreferences.storeDBPassphrase.set(useKeychain) + appPreferences.initialRandomDBPassphrase.set(false) + withBGApi { + try { + initChatController(useKey = passphrase, confirmMigrations = confirmation) { CompletableDeferred(false) } + val appSettings = controller.apiGetAppSettings(AppSettings.current.prepareForExport()).copy( + networkConfig = netCfg + ) + finishMigration(appSettings, close) + } catch (e: Exception) { + hideView(close) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString()) + } + } +} + +private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) { + try { + getMigrationTempFilesDirectory().deleteRecursively() + appSettings.importIntoApp() + val user = chatModel.currentUser.value + if (user != null) { + startChat(user) + } + hideView(close) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migration_from_device_chat_migrated), generalGetString(MR.strings.migration_from_device_finalize_migration)) + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString()) + } + MigrationFromAnotherDeviceState.save(null) +} + +private fun hideView(close: () -> Unit) { + appPreferences.onboardingStage.set(OnboardingStage.OnboardingComplete) + close() +} + +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) { + val state = state + if (state is MigrationState.ArchiveImportFailed) { + // Original database is not exist, nothing is set up correctly for showing to a user yet. Return to clean state + deleteChatDatabaseFilesAndState() + initChatControllerAndRunMigrations() + } else if (state is MigrationState.DownloadProgress && state.ctrl != null) { + stopArchiveDownloading(state.fileId, state.ctrl) + } + chatReceiver?.stopAndCleanUp() + getMigrationTempFilesDirectory().deleteRecursively() + MigrationFromAnotherDeviceState.save(null) +} + +private fun strHasSimplexFileLink(text: String): Boolean = + text.startsWith("simplex:/file") || text.startsWith("https://simplex.chat/file") + +private fun fileForTemporaryDatabase(): File = + File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory())) + +private fun archivePath(): String { + val archiveTime = Clock.System.now() + val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant())) + val archiveName = "simplex-chat.$ts.zip" + val archivePath = File(getMigrationTempFilesDirectory(), archiveName) + return archivePath.absolutePath +} + +private class MigrationFromChatReceiver( + val ctrl: ChatCtrl, + val databaseUrl: File, + var receiveMessages: Boolean = true, + val processReceivedMsg: suspend (CR) -> Unit +) { + fun start() { + Log.d(TAG, "MigrationChatReceiver startReceiver") + CoroutineScope(Dispatchers.IO).launch { + while (receiveMessages) { + try { + val msg = ChatController.recvMsg(ctrl) + if (msg != null && receiveMessages) { + val r = msg.resp + val rhId = msg.remoteHostId + Log.d(TAG, "processReceivedMsg: ${r.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { + processReceivedMsg(r) + } + if (finishedWithoutTimeout == null) { + Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.possible_slow_function_title), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + shareText = true + ) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString()) + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } + } + } + } + + fun stopAndCleanUp() { + Log.d(TAG, "MigrationChatReceiver.stop") + receiveMessages = false + chatCloseStore(ctrl) + File(databaseUrl.absolutePath + "_chat.db").delete() + File(databaseUrl.absolutePath + "_agent.db").delete() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt new file mode 100644 index 0000000000..5db6daf6de --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt @@ -0,0 +1,683 @@ +package chat.simplex.common.views.migration + +import SectionBottomSpacer +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.getNetCfg +import chat.simplex.common.model.ChatController.startChat +import chat.simplex.common.model.ChatController.startChatWithTemporaryDatabase +import chat.simplex.common.model.ChatCtrl +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.LinkTextView +import chat.simplex.common.views.newchat.SimpleXLinkQRCode +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* +import kotlinx.datetime.* +import kotlinx.serialization.* +import java.io.File +import java.net.URLEncoder +import kotlin.math.max + +@Serializable +data class MigrationFileLinkData( + val networkConfig: NetworkConfig?, +) { + @Serializable + data class NetworkConfig( + val socksProxy: String?, + val hostMode: HostMode?, + val requiredHostMode: Boolean? + ) { + fun hasOnionConfigured(): Boolean = socksProxy != null || hostMode == HostMode.Onion + + fun transformToPlatformSupported(): NetworkConfig { + return if (hostMode != null && requiredHostMode != null) { + NetworkConfig( + socksProxy = if (hostMode == HostMode.Onion) socksProxy ?: NetCfg.proxyDefaults.socksProxy else socksProxy, + hostMode = if (hostMode == HostMode.Onion) HostMode.OnionViaSocks else hostMode, + requiredHostMode = requiredHostMode + ) + } else this + } + } + + fun addToLink(link: String) = link + "&data=" + URLEncoder.encode(jsonShort.encodeToString(this), "UTF-8") + + companion object { + suspend fun readFromLink(link: String): MigrationFileLinkData? = + try { + // val data = link.substringAfter("&data=").substringBefore("&") + // json.decodeFromString(URLDecoder.decode(data, "UTF-8")) + controller.standaloneFileInfo(link) + } catch (e: Exception) { + null + } + } +} + + + +@Serializable +private sealed class MigrationToState { + @Serializable object ChatStopInProgress: MigrationToState() + @Serializable data class ChatStopFailed(val reason: String): MigrationToState() + @Serializable object PassphraseNotSet: MigrationToState() + @Serializable object PassphraseConfirmation: MigrationToState() + @Serializable object UploadConfirmation: MigrationToState() + @Serializable object Archiving: MigrationToState() + @Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationToState() + @Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationToState() + @Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationToState() + @Serializable object LinkCreation: MigrationToState() + @Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationToState() + @Serializable data class Finished(val chatDeletion: Boolean): MigrationToState() +} + +private var MutableState.state: MigrationToState + get() = value + set(v) { value = v } + +@Composable +fun MigrateToAnotherDeviceView(close: () -> Unit) { + val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(MigrationToState.ChatStopInProgress) } + // Prevent from hiding the view until migration is finished or app deleted + val backDisabled = remember { + derivedStateOf { + migrationState.value is MigrationToState.DatabaseInit || + migrationState.value is MigrationToState.Archiving || + migrationState.value is MigrationToState.LinkCreation || + migrationState.value is MigrationToState.LinkShown || + migrationState.value is MigrationToState.Finished + } + } + val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) } + ModalView( + enableClose = !backDisabled.value, + close = { + withBGApi { + migrationState.cleanUpOnBack(chatReceiver.value) + } + close() + }, + ) { + MigrateToAnotherDeviceLayout( + migrationState = migrationState, + chatReceiver = chatReceiver + ) + } +} + +@Composable +private fun MigrateToAnotherDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState +) { + val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), + ) { + AppBarTitle(stringResource(MR.strings.migrate_to_device)) + SectionByState(migrationState, tempDatabaseFile.value, chatReceiver) + SectionBottomSpacer() + } + platform.androidLockPortraitOrientation() +} + +@Composable +private fun SectionByState( + migrationState: MutableState, + tempDatabaseFile: File, + chatReceiver: MutableState +) { + when (val s = migrationState.value) { + is MigrationToState.ChatStopInProgress -> migrationState.ChatStopInProgressView() + is MigrationToState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason) + is MigrationToState.PassphraseNotSet -> migrationState.PassphraseNotSetView() + is MigrationToState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView() + is MigrationToState.UploadConfirmation -> migrationState.UploadConfirmationView() + is MigrationToState.Archiving -> migrationState.ArchivingView() + is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath) + is MigrationToState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) + is MigrationToState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) + is MigrationToState.LinkCreation -> LinkCreationView() + is MigrationToState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) + is MigrationToState.Finished -> migrationState.FinishedView(s.chatDeletion) + } +} + +@Composable +private fun MutableState.ChatStopInProgressView() { + Box { + SectionView(stringResource(MR.strings.migration_to_device_stopping_chat).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + stopChat() + } +} + +@Composable +private fun MutableState.ChatStopFailedView(reason: String) { + SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) { + Text(reason) + SectionSpacer() + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_report_filled), + text = stringResource(MR.strings.auth_stop_chat), + textColor = MaterialTheme.colors.error, + click = ::stopChat + ){} + SectionTextFooter(stringResource(MR.strings.migration_to_device_chat_should_be_stopped)) + } +} + +@Composable +private fun MutableState.PassphraseNotSetView() { + DatabaseEncryptionView(chatModel, true) + KeyChangeEffect(appPreferences.initialRandomDBPassphrase.state.value) { + if (!appPreferences.initialRandomDBPassphrase.get()) { + state = MigrationToState.UploadConfirmation + } + } +} + +@Composable +private fun MutableState.PassphraseConfirmationView() { + val useKeychain = remember { appPreferences.storeDBPassphrase.get() } + val currentKey = rememberSaveable { mutableStateOf("") } + val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } + Box { + val view = LocalMultiplatformView() + Column { + ChatStoppedView() + SectionSpacer() + + SectionView(stringResource(MR.strings.migration_to_device_verify_database_passphrase).uppercase()) { + PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) + + SettingsActionItemWithContent( + icon = painterResource(if (useKeychain) MR.images.ic_vpn_key_filled else MR.images.ic_lock), + text = stringResource(MR.strings.migration_to_device_verify_passphrase), + textColor = MaterialTheme.colors.primary, + disabled = verifyingPassphrase.value || currentKey.value.isEmpty(), + click = { + verifyingPassphrase.value = true + hideKeyboard(view) + withBGApi { + verifyDatabasePassphrase(currentKey.value) + verifyingPassphrase.value = false + } + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_to_device_confirm_you_remember_passphrase)) + } + } + if (verifyingPassphrase.value) { + ProgressView() + } + } +} + +@Composable +private fun MutableState.UploadConfirmationView() { + SectionView(stringResource(MR.strings.migration_to_device_confirm_upload).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_ios_share), + text = stringResource(MR.strings.migration_to_device_archive_and_upload), + textColor = MaterialTheme.colors.primary, + click = { state = MigrationToState.Archiving } + ){} + SectionTextFooter(stringResource(MR.strings.migration_to_device_all_data_will_be_uploaded)) + } +} + +@Composable +private fun MutableState.ArchivingView() { + Box { + SectionView(stringResource(MR.strings.migration_to_device_archiving_database).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + exportArchive() + } +} + +@Composable +private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { + Box { + SectionView(stringResource(MR.strings.migration_to_device_database_init).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + prepareDatabase(tempDatabaseFile, totalBytes, archivePath) + } +} + +@Composable +private fun MutableState.UploadProgressView( + uploadedBytes: Long, + totalBytes: Long, + ctrl: ChatCtrl, + user: User, + tempDatabaseFile: File, + chatReceiver: MutableState, + archivePath: String, +) { + Box { + SectionView(stringResource(MR.strings.migration_to_device_uploading_archive).uppercase()) { + val ratio = uploadedBytes.toFloat() / max(totalBytes, 1) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_to_device_bytes_uploaded).format(formatBytes(uploadedBytes))) + } + } + LaunchedEffect(Unit) { + startUploading(totalBytes, ctrl, user, tempDatabaseFile, chatReceiver, archivePath) + } +} + +@Composable +private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationToChatReceiver?) { + SectionView(stringResource(MR.strings.migration_to_device_upload_failed).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_ios_share), + text = stringResource(MR.strings.migration_to_device_repeat_upload), + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationToState.DatabaseInit(totalBytes, archivePath) + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_to_device_try_again)) + } + LaunchedEffect(Unit) { + chatReceiver?.stopAndCleanUp() + } +} + +@Composable +private fun LinkCreationView() { + Box { + SectionView(stringResource(MR.strings.migration_to_device_creating_archive_link).uppercase()) {} + ProgressView() + } +} + +@Composable +private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { + SectionView { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_close), + text = stringResource(MR.strings.migration_to_device_cancel_migration), + textColor = MaterialTheme.colors.error, + click = { + cancelMigration(fileId, ctrl) + } + ) {} + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_check), + text = stringResource(MR.strings.migration_to_device_finalize_migration), + textColor = MaterialTheme.colors.primary, + click = { + finishMigration(fileId, ctrl) + } + ) {} + SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_choose_migrate_from_another_device)) + } + SectionSpacer() + SectionView(stringResource(MR.strings.show_QR_code).uppercase()) { + SimpleXLinkQRCode(link, onShare = {}) + } + SectionSpacer() + SectionView(stringResource(MR.strings.migration_to_device_or_share_this_file_link).uppercase()) { + LinkTextView(link, true) + } +} + +@Composable +private fun MutableState.FinishedView(chatDeletion: Boolean) { + Box { + SectionView(stringResource(MR.strings.migration_to_device_migration_complete).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_delete_forever), + text = stringResource(MR.strings.migration_to_device_delete_database_from_device), + textColor = MaterialTheme.colors.primary, + click = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.delete_chat_profile_question), + text = generalGetString(MR.strings.delete_chat_profile_action_cannot_be_undone_warning), + confirmText = generalGetString(MR.strings.delete_verb), + onConfirm = { + deleteChatAndDismiss() + } + ) + } + ) {} + + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_play_arrow_filled), + text = stringResource(MR.strings.migration_to_device_start_chat), + textColor = MaterialTheme.colors.error, + click = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.start_chat_question), + text = generalGetString(MR.strings.migration_to_device_starting_chat_on_multiple_devices_unsupported), + confirmText = generalGetString(MR.strings.migration_to_device_start_chat), + onConfirm = { + withLongRunningApi { startChatAndDismiss() } + } + ) + } + ) {} + SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_using_on_two_device_breaks_encryption)) + } + if (chatDeletion) { + ProgressView() + } + } +} + +@Composable +private fun ProgressView() { + DefaultProgressView(null) +} + +@Composable +fun LargeProgressView(value: Float, title: String, description: String) { + Box(Modifier.padding(DEFAULT_PADDING).fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = value, + (if (appPlatform.isDesktop) Modifier.size(DEFAULT_START_MODAL_WIDTH) else Modifier.size(windowWidth() - DEFAULT_PADDING * 2)) + .rotate(-90f), + color = MaterialTheme.colors.primary, + strokeWidth = 25.dp + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(description, color = Color.Transparent) + Text(title, style = MaterialTheme.typography.h1.copy(fontSize = 50.sp, fontWeight = FontWeight.Bold), color = MaterialTheme.colors.primary) + Text(description, style = MaterialTheme.typography.subtitle1) + } + } +} + +private fun MutableState.stopChat() { + withBGApi { + try { + stopChatAsync(chatModel) + try { + controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) + state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationToState.PassphraseNotSet else MigrationToState.PassphraseConfirmation + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.migrate_to_device_error_saving_settings), + text = e.stackTraceToString() + ) + state = MigrationToState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_to_device_error_saving_settings)) + } + } catch (e: Exception) { + state = MigrationToState.ChatStopFailed(reason = e.stackTraceToString().take(10)) + } + } +} + +private suspend fun MutableState.verifyDatabasePassphrase(dbKey: String) { + if (controller.testStorageEncryption(dbKey)) { + state = MigrationToState.UploadConfirmation + } else { + showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase("")) + } +} + +private fun MutableState.exportArchive() { + withLongRunningApi { + try { + getMigrationTempFilesDirectory().mkdir() + val archivePath = exportChatArchive(chatModel, getMigrationTempFilesDirectory(), mutableStateOf(""), mutableStateOf(Instant.DISTANT_PAST), mutableStateOf("")) + val totalBytes = File(archivePath).length() + if (totalBytes > 0L) { + state = MigrationToState.DatabaseInit(totalBytes, archivePath) + } else { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_exported_file_doesnt_exist)) + state = MigrationToState.UploadConfirmation + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.migrate_to_device_error_exporting_archive), + text = e.stackTraceToString() + ) + state = MigrationToState.UploadConfirmation + } + } +} + +suspend fun initTemporaryDatabase(tempDatabaseFile: File, netCfg: NetCfg): Pair? { + val (status, ctrl) = chatInitTemporaryDatabase(tempDatabaseFile.absolutePath) + showErrorOnMigrationIfNeeded(status) + try { + if (ctrl != null) { + val user = startChatWithTemporaryDatabase(ctrl, netCfg) + return if (user != null) ctrl to user else null + } + } catch (e: Throwable) { + Log.e(TAG, "Error while starting chat in temporary database: ${e.stackTraceToString()}") + } + return null +} + +private fun MutableState.prepareDatabase( + tempDatabaseFile: File, + totalBytes: Long, + archivePath: String, +) { + withLongRunningApi { + val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, getNetCfg()) + if (ctrlAndUser == null) { + state = MigrationToState.UploadFailed(totalBytes, archivePath) + return@withLongRunningApi + } + + val (ctrl, user) = ctrlAndUser + state = MigrationToState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user) + } +} + +private fun MutableState.startUploading( + totalBytes: Long, + ctrl: ChatCtrl, + user: User, + tempDatabaseFile: File, + chatReceiver: MutableState, + archivePath: String, +) { + withBGApi { + chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> + when (msg) { + is CR.SndFileProgressXFTP -> { + val s = state + if (s is MigrationToState.UploadProgress && s.uploadedBytes != s.totalBytes) { + state = MigrationToState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) + } + } + is CR.SndFileRedirectStartXFTP -> { + delay(500) + state = MigrationToState.LinkCreation + } + is CR.SndStandaloneFileComplete -> { + delay(500) + val cfg = getNetCfg() + val data = MigrationFileLinkData( + networkConfig = MigrationFileLinkData.NetworkConfig( + socksProxy = cfg.socksProxy, + hostMode = cfg.hostMode, + requiredHostMode = cfg.requiredHostMode + ) + ) + state = MigrationToState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + } + else -> { + Log.d(TAG, "unsupported event: ${msg.responseType}") + } + } + } + + chatReceiver.value?.start() + + val (res, error) = controller.uploadStandaloneFile(user, CryptoFile.plain(File(archivePath).name), ctrl) + if (res == null) { + state = MigrationToState.UploadFailed(totalBytes, archivePath) + return@withBGApi AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migration_to_device_error_uploading_archive), + error + ) + } + state = MigrationToState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user) + } +} + +private suspend fun cancelUploadedArchive(fileId: Long, ctrl: ChatCtrl) { + controller.apiCancelFile(null, fileId, ctrl) +} + +private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) { + withBGApi { + cancelUploadedArchive(fileId, ctrl) + startChatAndDismiss() + } +} + +private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { + withBGApi { + cancelUploadedArchive(fileId, ctrl) + state = MigrationToState.Finished(false) + } +} + +private fun MutableState.deleteChatAndDismiss() { + withBGApi { + try { + deleteChatAsync(chatModel) + chatModel.chatDbChanged.value = true + state = MigrationToState.Finished(true) + try { + initChatController(startChat = { CompletableDeferred(false) }) + chatModel.chatDbChanged.value = false + ModalManager.fullscreen.closeModals() + } catch (e: Exception) { + throw Exception(generalGetString(MR.strings.error_starting_chat) + "\n" + e.stackTraceToString()) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.migration_to_device_error_deleting_database), + text = e.stackTraceToString() + ) + } + } +} + +private suspend fun startChatAndDismiss(dismiss: Boolean = true) { + try { + val user = chatModel.currentUser.value + if (chatModel.chatDbChanged.value) { + initChatController() + chatModel.chatDbChanged.value = false + } else if (user != null) { + startChat(user) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_starting_chat), + text = e.stackTraceToString() + ) + } + // Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered + if (dismiss || chatModel.chatDbStatus.value != DBMigrationResult.OK) { + ModalManager.fullscreen.closeModals() + } +} + +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) { + val s = state + if (s !is MigrationToState.LinkShown && s !is MigrationToState.Finished) { + chatModel.switchingUsersAndHosts.value = true + startChatAndDismiss(false) + chatModel.switchingUsersAndHosts.value = false + } + if (s is MigrationToState.UploadProgress) { + cancelUploadedArchive(s.fileId, s.ctrl) + } + chatReceiver?.stopAndCleanUp() + getMigrationTempFilesDirectory().deleteRecursively() +} + +private fun fileForTemporaryDatabase(): File = + File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory())) + +private class MigrationToChatReceiver( + val ctrl: ChatCtrl, + val databaseUrl: File, + var receiveMessages: Boolean = true, + val processReceivedMsg: suspend (CR) -> Unit +) { + fun start() { + Log.d(TAG, "MigrationChatReceiver startReceiver") + CoroutineScope(Dispatchers.IO).launch { + while (receiveMessages) { + try { + val msg = ChatController.recvMsg(ctrl) + if (msg != null && receiveMessages) { + val r = msg.resp + val rhId = msg.remoteHostId + Log.d(TAG, "processReceivedMsg: ${r.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { + processReceivedMsg(r) + } + if (finishedWithoutTimeout == null) { + Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.possible_slow_function_title), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + shareText = true + ) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString()) + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } + } + } + } + + fun stopAndCleanUp() { + Log.d(TAG, "MigrationChatReceiver.stop") + receiveMessages = false + chatCloseStore(ctrl) + File(databaseUrl.absolutePath + "_chat.db").delete() + File(databaseUrl.absolutePath + "_agent.db").delete() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 3b4bb86e66..bf154acca8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -301,7 +301,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC } @Composable -private fun LinkTextView(link: String, share: Boolean) { +fun LinkTextView(link: String, share: Boolean) { val clipboard = LocalClipboardManager.current Row(Modifier.fillMaxWidth().heightIn(min = 46.dp).padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.weight(1f).clickable { 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 9ae34eb180..905bf77989 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 @@ -58,7 +58,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { prefs.storeDBPassphrase.set(false) val newKeyValue = newKey.value - val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator) + val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator, false) if (success) { startChat(newKeyValue) nextStep() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 2aad2556af..0fe756d8fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -16,8 +16,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @@ -62,17 +64,32 @@ fun SimpleXInfoLayout( OnboardingActionButton(user, onboardingStage) } Spacer(Modifier.fillMaxHeight().weight(1f)) + + Box( + Modifier + .fillMaxWidth() + .padding(top = DEFAULT_PADDING), contentAlignment = Alignment.Center + ) { + SimpleButtonDecorated(text = stringResource(MR.strings.migrate_from_another_device), icon = painterResource(MR.images.ic_download), + click = { ModalManager.fullscreen.showCustomModal { close -> MigrateFromAnotherDeviceView(chatModel.migrationState.value, close) } }) + } } Box( Modifier .fillMaxWidth() - .padding(bottom = DEFAULT_PADDING.times(1.5f), top = DEFAULT_PADDING), contentAlignment = Alignment.Center + .padding(bottom = DEFAULT_PADDING.times(1.5f), top = if (onboardingStage == null) DEFAULT_PADDING else 0.dp), contentAlignment = Alignment.Center ) { SimpleButtonDecorated(text = stringResource(MR.strings.how_it_works), icon = painterResource(MR.images.ic_info), click = showModal { HowItWorks(user, onboardingStage) }) } } + LaunchedEffect(Unit) { + val state = chatModel.migrationState.value + if (state != null && !ModalManager.fullscreen.hasModalsOpen()) { + ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateFromAnotherDeviceView(state, close) } + } + } } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 66b4a0e839..27e5c80cde 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -33,12 +33,7 @@ import chat.simplex.common.views.helpers.annotatedStringResource import chat.simplex.res.MR @Composable -fun NetworkAndServersView( - chatModel: ChatModel, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit), -) { +fun NetworkAndServersView() { val currentRemoteHost by remember { chatModel.currentRemoteHost } // It's not a state, just a one-time value. Shouldn't be used in any state-related situations val netCfg = remember { chatModel.controller.getNetCfg() } @@ -55,9 +50,6 @@ fun NetworkAndServersView( onionHosts = onionHosts, sessionMode = sessionMode, proxyPort = proxyPort, - showModal = showModal, - showSettingsModal = showSettingsModal, - showCustomModal = showCustomModal, toggleSocksProxy = { enable -> if (enable) { AlertManager.shared.showAlertDialog( @@ -154,13 +146,11 @@ fun NetworkAndServersView( onionHosts: MutableState, sessionMode: MutableState, proxyPort: State, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit), toggleSocksProxy: (Boolean) -> Unit, useOnion: (OnionHosts) -> Unit, updateSessionMode: (TransportSessionMode) -> Unit, ) { + val m = chatModel Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp) @@ -168,17 +158,18 @@ fun NetworkAndServersView( AppBarTitle(stringResource(MR.strings.network_and_servers)) if (!chatModel.desktopNoUserNoRemote) { SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } }) - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } }) if (currentRemoteHost == null) { - UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) - UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) + val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } + UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false) + UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion) if (developerTools) { - SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + SessionModePicker(sessionMode, showModal, updateSessionMode) } - SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) + SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showModal { AdvancedNetworkSettingsView(m) } }) } } } @@ -196,18 +187,39 @@ fun NetworkAndServersView( } SectionView(generalGetString(MR.strings.settings_section_title_calls)) { - SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), showModal { RTCServersView(it) }) + SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } }) } SectionBottomSpacer() } } +@Composable fun OnionRelatedLayout( + developerTools: Boolean, + networkUseSocksProxy: MutableState, + onionHosts: MutableState, + sessionMode: MutableState, + networkProxyHostPort: SharedPreference, + proxyPort: State, + toggleSocksProxy: (Boolean) -> Unit, + useOnion: (OnionHosts) -> Unit, + updateSessionMode: (TransportSessionMode) -> Unit, +) { + val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.fullscreen.showModal(content = it) } + UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, networkProxyHostPort, true) + UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion) + if (developerTools) { + SessionModePicker(sessionMode, showModal, updateSessionMode) + } +} + @Composable fun UseSocksProxySwitch( networkUseSocksProxy: MutableState, proxyPort: State, toggleSocksProxy: (Boolean) -> Unit, - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) + showModal: (@Composable ModalData.() -> Unit) -> Unit, + networkProxyHostPort: SharedPreference = chatModel.controller.appPrefs.networkProxyHostPort, + migration: Boolean = false, ) { Row( Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), @@ -227,8 +239,11 @@ fun UseSocksProxySwitch( val text = buildAnnotatedString { append(generalGetString(MR.strings.network_socks_toggle_use_socks_proxy) + " (") val style = SpanStyle(color = MaterialTheme.colors.primary) + val disabledStyle = SpanStyle(color = MaterialTheme.colors.onBackground) withAnnotation(tag = "PORT", annotation = generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) { - withStyle(style) { append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) } + withStyle(if (networkUseSocksProxy.value || !migration) style else disabledStyle) { + append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) + } } append(")") } @@ -238,7 +253,9 @@ fun UseSocksProxySwitch( onClick = { offset -> text.getStringAnnotations(tag = "PORT", start = offset, end = offset) .firstOrNull()?.let { _ -> - showSettingsModal { SockProxySettings(it) }() + if (networkUseSocksProxy.value || !migration) { + showModal { SockProxySettings(chatModel, networkProxyHostPort, migration) } + } } }, shouldConsumeEvent = { offset -> @@ -254,7 +271,11 @@ fun UseSocksProxySwitch( } @Composable -fun SockProxySettings(m: ChatModel) { +fun SockProxySettings( + m: ChatModel, + networkProxyHostPort: SharedPreference = m.controller.appPrefs.networkProxyHostPort, + migration: Boolean, +) { Column( Modifier .fillMaxWidth() @@ -262,17 +283,17 @@ fun SockProxySettings(m: ChatModel) { ) { val defaultHostPort = remember { "localhost:9050" } AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings)) - val hostPort by remember { m.controller.appPrefs.networkProxyHostPort.state } + val hostPortSaved by remember { networkProxyHostPort.state } val hostUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(hostPort?.split(":")?.firstOrNull() ?: "localhost")) + mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.firstOrNull() ?: "localhost")) } val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(hostPort?.split(":")?.lastOrNull() ?: "9050")) + mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.lastOrNull() ?: "9050")) } val save = { withBGApi { - m.controller.appPrefs.networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text) - if (m.controller.appPrefs.networkUseSocksProxy.get()) { + networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text) + if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) { m.controller.apiSetNetworkConfig(m.controller.getNetCfg()) } } @@ -281,21 +302,21 @@ fun SockProxySettings(m: ChatModel) { SectionItemView { ResetToDefaultsButton({ val reset = { - m.controller.appPrefs.networkProxyHostPort.set(defaultHostPort) + networkProxyHostPort.set(defaultHostPort) val newHost = defaultHostPort.split(":").first() val newPort = defaultHostPort.split(":").last() hostUnsaved.value = hostUnsaved.value.copy(newHost, TextRange(newHost.length)) portUnsaved.value = portUnsaved.value.copy(newPort, TextRange(newPort.length)) save() } - if (m.controller.appPrefs.networkUseSocksProxy.get()) { + if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) { showUpdateNetworkSettingsDialog { reset() } } else { reset() } - }, disabled = hostPort == defaultHostPort) + }, disabled = hostPortSaved == defaultHostPort) } SectionItemView { DefaultConfigurableTextField( @@ -321,14 +342,14 @@ fun SockProxySettings(m: ChatModel) { SectionCustomFooter { NetworkSectionFooter( revert = { - val prevHost = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.firstOrNull() ?: "localhost" - val prevPort = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.lastOrNull() ?: "9050" + val prevHost = hostPortSaved?.split(":")?.firstOrNull() ?: "localhost" + val prevPort = hostPortSaved?.split(":")?.lastOrNull() ?: "9050" hostUnsaved.value = hostUnsaved.value.copy(prevHost, TextRange(prevHost.length)) portUnsaved.value = portUnsaved.value.copy(prevPort, TextRange(prevPort.length)) }, - save = { if (m.controller.appPrefs.networkUseSocksProxy.get()) showUpdateNetworkSettingsDialog { save() } else save() }, - revertDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text), - saveDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text) || + save = { if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) showUpdateNetworkSettingsDialog { save() } else save() }, + revertDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text), + saveDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text) || remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value || remember { derivedStateOf { !validPort(portUnsaved.value.text) } }.value ) @@ -341,7 +362,7 @@ fun SockProxySettings(m: ChatModel) { private fun UseOnionHosts( onionHosts: MutableState, enabled: State, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showModal: (@Composable ModalData.() -> Unit) -> Unit, useOnion: (OnionHosts) -> Unit, ) { val values = remember { @@ -353,29 +374,43 @@ private fun UseOnionHosts( } } } - val onSelected = showModal { - Column( - Modifier.fillMaxWidth(), - ) { - AppBarTitle(stringResource(MR.strings.network_use_onion_hosts)) - SectionViewSelectable(null, onionHosts, values, useOnion) + val onSelected = { + showModal { + Column( + Modifier.fillMaxWidth(), + ) { + AppBarTitle(stringResource(MR.strings.network_use_onion_hosts)) + SectionViewSelectable(null, onionHosts, values, useOnion) + } } } - SectionItemWithValue( - generalGetString(MR.strings.network_use_onion_hosts), - onionHosts, - values, - icon = painterResource(MR.images.ic_security), - enabled = enabled, - onSelected = onSelected - ) + if (enabled.value) { + SectionItemWithValue( + generalGetString(MR.strings.network_use_onion_hosts), + onionHosts, + values, + icon = painterResource(MR.images.ic_security), + enabled = enabled, + onSelected = onSelected + ) + } else { + // In reality, when socks proxy is disabled, this option acts like NEVER regardless of what was chosen before + SectionItemWithValue( + generalGetString(MR.strings.network_use_onion_hosts), + remember { mutableStateOf(OnionHosts.NEVER) }, + listOf(ValueTitleDesc(OnionHosts.NEVER, generalGetString(MR.strings.network_use_onion_hosts_no), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_no_desc)))), + icon = painterResource(MR.images.ic_security), + enabled = enabled, + onSelected = {} + ) + } } @Composable private fun SessionModePicker( sessionMode: MutableState, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showModal: (@Composable ModalData.() -> Unit) -> Unit, updateSessionMode: (TransportSessionMode) -> Unit, ) { val density = LocalDensity.current @@ -393,12 +428,14 @@ private fun SessionModePicker( sessionMode, values, icon = painterResource(MR.images.ic_safety_divider), - onSelected = showModal { - Column( - Modifier.fillMaxWidth(), - ) { - AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation)) - SectionViewSelectable(null, sessionMode, values, updateSessionMode) + onSelected = { + showModal { + Column( + Modifier.fillMaxWidth(), + ) { + AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation)) + SectionViewSelectable(null, sessionMode, values, updateSessionMode) + } } } ) @@ -455,9 +492,6 @@ fun PreviewNetworkAndServersLayout() { developerTools = true, networkUseSocksProxy = remember { mutableStateOf(true) }, proxyPort = remember { mutableStateOf(9050) }, - showModal = { {} }, - showSettingsModal = { {} }, - showCustomModal = { {} }, toggleSocksProxy = {}, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index d92f2f0f13..ffa908e210 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -28,6 +28,8 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView +import chat.simplex.common.views.migration.MigrateToAnotherDeviceView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.remote.ConnectDesktopView @@ -135,12 +137,13 @@ fun SettingsLayout( } else { SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) } + SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_to_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateToAnotherDeviceView(close) } }}, disabled = stopped, extraPadding = true) } SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_settings)) { SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) @@ -366,7 +369,7 @@ fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null, } @Composable -fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) { +fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, textColor: Color = MaterialTheme.colors.onBackground, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) { SectionItemView( click, extraPadding = extraPadding, @@ -382,7 +385,7 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: ( } if (text != null) { val padding = with(LocalDensity.current) { 6.sp.toDp() } - Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground) + Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else textColor) Spacer(Modifier.width(DEFAULT_PADDING)) Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) { content() 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 d074530a5e..f3b7bf4a72 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -245,6 +245,7 @@ Stop chat Open chat console Open chat profiles + Open migration screen SimpleX Lock not enabled! You can turn on SimpleX Lock via Settings. @@ -823,6 +824,7 @@ Open-source protocol and code – anybody can run the servers. Create your profile Make a private connection + Migrate from another device How it works @@ -1081,6 +1083,7 @@ Confirm new passphrase… Update database passphrase Set database passphrase + Set passphrase Please enter correct current passphrase. Your chat database is not encrypted - set passphrase to protect it. Android Keystore is used to securely store passphrase - it allows notification service to work. @@ -1842,4 +1845,64 @@ Internal error Please report it to the developers: \n%s Restart chat + + + + Migrate here + Or paste archive link + Paste archive link + Invalid link + Migrating + Preparing download + Downloading link details + Downloading archive + %s downloaded + Download failed + Repeat download + You can give another try. + Importing archive + Import failed + Repeat import + Enter passphrase + File was deleted or link is invalid + Error downloading the archive + Chat migrated! + Finalize migration on another device. + Confirm network settings + Please confirm that network settings are correct for this device. + Apply + + + Migrate to another device + Error saving settings + Exported file doesn\'t exist + Error exporting chat database + Preparing upload + Error uploading the archive + Error deleting database + Stopping chat + In order to continue, chat should be stopped. + Archive and upload + Confirm upload + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + Archiving database + %s uploaded + Uploading archive + Upload failed + Repeat upload + You can give another try. + Creating archive link + Cancel migration + Finalize migration + Migrate from another device on the new device and scan QR code.]]> + Or securely share this file link + Delete database from this device + Warning: starting chat on multiple devices is not supported and will cause message delivery failures + Start chat + Migration complete + must not use the same database on two devices.]]> + Please note: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]> + Verify database passphrase + Verify passphrase + Confirm that you remember database passphrase to migrate it. \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index af2b269b58..eb93e7c510 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.database import SectionItemView import SectionTextFooter +import TextIconSpaced import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -22,8 +23,9 @@ actual fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, - progressIndicator: Boolean, minHeight: Dp, + enabled: Boolean, + smallPadding: Boolean, onCheckedChange: (Boolean) -> Unit, ) { SectionItemView(minHeight = minHeight) { @@ -33,7 +35,11 @@ actual fun SavePassphraseSetting( stringResource(MR.strings.save_passphrase_in_settings), tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary ) - Spacer(Modifier.padding(horizontal = 4.dp)) + if (smallPadding) { + Spacer(Modifier.padding(horizontal = 4.dp)) + } else { + TextIconSpaced(false) + } Text( stringResource(MR.strings.save_passphrase_in_settings), Modifier.padding(end = 24.dp), @@ -43,7 +49,7 @@ actual fun SavePassphraseSetting( DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, - enabled = !initialRandomDBPassphrase && !progressIndicator + enabled = enabled ) } } @@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter( chatDbEncrypted: Boolean?, storedKey: MutableState, initialRandomDBPassphrase: MutableState, + migration: Boolean, ) { if (chatDbEncrypted == false) { SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) } else if (useKeychain.value) { if (storedKey.value) { SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text)) - if (initialRandomDBPassphrase.value) { + if (initialRandomDBPassphrase.value && !migration) { SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) } else { SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) From 01447716fa966a940c3d6caf490bd461bb96f305 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 12 Mar 2024 12:49:26 +0000 Subject: [PATCH 51/64] core: update remote controller/host versions --- src/Simplex/Chat/Remote.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d5f66224b9..857198a109 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -72,11 +72,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [5, 5, 0, 2] +minRemoteCtrlVersion = AppVersion [5, 6, 0, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [5, 5, 0, 2] +minRemoteHostVersion = AppVersion [5, 6, 0, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From 4f893d9502cccd9fef105447ae278b86cc4ef5f7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:51:02 +0400 Subject: [PATCH 52/64] core: improve getGroupChatItemQuote_ query performance (#3897) --- src/Simplex/Chat/Store/Messages.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 05b1a153b2..bf74add6db 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -471,7 +471,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id - LEFT JOIN chat_items i ON i.group_id = m.group_id + LEFT JOIN chat_items i ON i.user_id = m.user_id + AND i.group_id = m.group_id AND m.group_member_id = i.group_member_id AND i.shared_msg_id = :msg_id WHERE m.user_id = :user_id AND m.group_id = :group_id AND m.member_id = :member_id From 4a404f14d9e5c485378e626677670c0d1080d59e Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:19:40 +0200 Subject: [PATCH 53/64] core: add error message to CRSndFileError (#3894) * core: add error message to CRSndFileError * show snd errors --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 56914d2d9d..f88f1c83a9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3423,7 +3423,7 @@ processAgentMsgSndFile _corrId aFileId msg = liftIO $ updateFileCancelled db user fileId CIFSSndError lookupChatItemByFileId db vr user fileId withAgent (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileError user ci ft + toView $ CRSndFileError user ci ft err splitFileDescr :: ChatMonad m => RcvFileDescrText -> m (NonEmpty FileDescr) splitFileDescr rfdText = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b2d82a0243..ce7c952ded 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -619,7 +619,7 @@ data ChatResponse | CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} | CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]} | CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} + | CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8aa917f044..50dc151aa4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -215,8 +215,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci CRSndFileCancelledXFTP {} -> [] - CRSndFileError u Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft - CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci + CRSndFileError u Nothing ft e -> ttyUser u $ uploadingFileStandalone "error" ft <> [plain e] + CRSndFileError u (Just ci) _ e -> ttyUser u $ uploadingFile "error" ci <> [plain e] CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [plain . LB.toStrict $ J.encode j]) info_ From 96fba950ff12146eb3cc6d9b3ce59e8708080604 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 12 Mar 2024 15:20:39 +0000 Subject: [PATCH 54/64] core: 5.6.0.1, update simplexmq (better ACK handling) --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index 3916c9fb6d..c4f2f102e8 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 78eb4f764fd52385a8687d2605a0e6edc1808431 + tag: 0aa4ae72286237d066c3ce2bff355638523c7095 source-repository-package type: git diff --git a/package.yaml b/package.yaml index a4df72cda8..ab703cc9cd 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.6.0.0 +version: 5.6.0.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index c7678d1201..7672cde62d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."78eb4f764fd52385a8687d2605a0e6edc1808431" = "09nmrk65nbn6mp8mwwk09d5zx9cgm38i6xgmndk6jzlhnfl5fiy6"; + "https://github.com/simplex-chat/simplexmq.git"."0aa4ae72286237d066c3ce2bff355638523c7095" = "1jcy5p8220w8ahi4fgil5rxlj83c9qy44s6mly9jh8n9a2bwdr4d"; "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 26300dc146..a3a78f851f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.6.0.0 +version: 5.6.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From d3b255b7cb8c36e40cf3b6b260656384403bb416 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 12 Mar 2024 23:25:06 +0700 Subject: [PATCH 55/64] ios: migration enhancements (#3893) * onion check * alert and log * correction * refactor * change * refactor * enum * footer * remove non-needed directory if no migration * naming * back * rename everything --------- Co-authored-by: Avently Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 2 +- ...erDevice.swift => MigrateFromDevice.swift} | 49 ++++---- ...therDevice.swift => MigrateToDevice.swift} | 116 +++++++++--------- .../Shared/Views/Onboarding/SimpleXInfo.swift | 20 ++- .../Views/UserSettings/SettingsView.swift | 5 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +-- apps/ios/SimpleXChat/API.swift | 7 +- 7 files changed, 109 insertions(+), 106 deletions(-) rename apps/ios/Shared/Views/Migration/{MigrateToAnotherDevice.swift => MigrateFromDevice.swift} (93%) rename apps/ios/Shared/Views/Migration/{MigrateFromAnotherDevice.swift => MigrateToDevice.swift} (87%) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index bed5d9b2de..462699e407 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -95,7 +95,7 @@ final class ChatModel: ObservableObject { @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? - @Published var migrationState: MigrationFromAnotherDeviceState? = MigrationFromAnotherDeviceState.transform() + @Published var migrationState: MigrationToState? = MigrationToDeviceState.makeMigrationState() // audio recording and playback @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var draft: ComposeState? diff --git a/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift similarity index 93% rename from apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift rename to apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 01a85aa6db..b3b7269d22 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -1,5 +1,5 @@ // -// MigrateToAnotherDevice.swift +// MigrateFromDevice.swift // SimpleX (iOS) // // Created by Avently on 14.02.2024. @@ -9,7 +9,7 @@ import SwiftUI import SimpleXChat -private enum MigrationToState: Equatable { +private enum MigrationFromState: Equatable { case chatStopInProgress case chatStopFailed(reason: String) case passphraseNotSet @@ -23,7 +23,7 @@ private enum MigrationToState: Equatable { case finished(chatDeletion: Bool) } -private enum MigrateToAnotherDeviceViewAlert: Identifiable { +private enum MigrateFromDeviceViewAlert: Identifiable { case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.") case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures") @@ -51,15 +51,15 @@ private enum MigrateToAnotherDeviceViewAlert: Identifiable { } } -struct MigrateToAnotherDevice: View { +struct MigrateFromDevice: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @Binding var showSettings: Bool @Binding var showProgressOnSettings: Bool - @State private var migrationState: MigrationToState = .chatStopInProgress + @State private var migrationState: MigrationFromState = .chatStopInProgress @State private var useKeychain = storeDBPassphraseGroupDefault.get() @AppStorage(GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE, store: groupDefaults) private var initialRandomDBPassphrase: Bool = false - @State private var alert: MigrateToAnotherDeviceViewAlert? + @State private var alert: MigrateFromDeviceViewAlert? @State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) private let tempDatabaseUrl = urlForTemporaryDatabase() @State private var chatReceiver: MigrationChatReceiver? = nil @@ -108,11 +108,8 @@ struct MigrateToAnotherDevice: View { }) .onChange(of: migrationState) { state in backDisabled = switch migrationState { - case .archiving: true - case .linkCreation: true - case .linkShown: true - case .finished: true - default: false + case .chatStopInProgress, .archiving, .linkShown, .finished: true + case .chatStopFailed, .passphraseNotSet, .passphraseConfirmation, .uploadConfirmation, .uploadProgress, .uploadFailed, .linkCreation: false } } .onAppear { @@ -120,7 +117,7 @@ struct MigrateToAnotherDevice: View { } .onDisappear { Task { - if case .linkCreation = migrationState {} else if case .linkShown = migrationState {} else if case .finished = migrationState {} else { + if !backDisabled { await MainActor.run { showProgressOnSettings = true } @@ -252,7 +249,7 @@ struct MigrateToAnotherDevice: View { } } let ratio = Float(uploadedBytes) / Float(totalBytes) - MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded") + MigrateFromDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded") } .onAppear { startUploading(totalBytes, archivePath) @@ -306,7 +303,10 @@ struct MigrateToAnotherDevice: View { } } } footer: { - Text("Choose _Migrate from another device_ on the new device and scan QR code.") + VStack(alignment: .leading, spacing: 16) { + Text("**Warning**: the archive will be removed.") + Text("Choose _Migrate from another device_ on the new device and scan QR code.") + } .font(.callout) } Section("Show QR code") { @@ -498,6 +498,9 @@ struct MigrateToAnotherDevice: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) } + case .sndFileError: + alert = .error(title: "Upload failed", error: "Check your internet connection and try again") + migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) default: logger.debug("unsupported event: \(msg.responseType)") } @@ -587,12 +590,12 @@ struct MigrateToAnotherDevice: View { } private struct PassphraseConfirmationView: View { - @Binding var migrationState: MigrationToState + @Binding var migrationState: MigrationFromState @State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var currentKey: String = "" @State private var verifyingPassphrase: Bool = false @FocusState private var keyboardVisible: Bool - @Binding var alert: MigrateToAnotherDeviceViewAlert? + @Binding var alert: MigrateFromDeviceViewAlert? var body: some View { ZStack { @@ -638,13 +641,17 @@ private struct PassphraseConfirmationView: View { await MainActor.run { migrationState = .uploadConfirmation } - } catch { - showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) + } catch let error { + if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse { + showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) + } else { + alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error))) + } } } } -private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { +private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { switch status { case .invalidConfirmation: alert.wrappedValue = .invalidConfirmation() @@ -720,8 +727,8 @@ private class MigrationChatReceiver { } } -struct MigrateToAnotherDevice_Previews: PreviewProvider { +struct MigrateFromDevice_Previews: PreviewProvider { static var previews: some View { - MigrateToAnotherDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) + MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) } } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift similarity index 87% rename from apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift rename to apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 9022beebd3..9afd0dd406 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -1,5 +1,5 @@ // -// MigrateFromAnotherDevice.swift +// MigrateToDevice.swift // SimpleX (iOS) // // Created by Avently on 23.02.2024. @@ -9,56 +9,47 @@ import SwiftUI import SimpleXChat -enum MigrationFromAnotherDeviceState: Codable, Equatable { +enum MigrationToDeviceState: Codable, Equatable { case downloadProgress(link: String, archiveName: String) case archiveImport(archiveName: String) case passphrase - func makeMigrationState() -> MigrationFromState { - var initial: MigrationFromState = .pasteOrScanLink - //logger.debug("Inited with migrationState: \(String(describing: self))") - switch self { - case let .downloadProgress(link, archiveName): - // iOS changes absolute directory every launch, check this way - let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName - initial = .downloadFailed(totalBytes: 0, link: link, archivePath: archivePath) + // Here we check whether it's needed to show migration process after app restart or not + // It's important to NOT show the process when archive was corrupted/not fully downloaded + static func makeMigrationState() -> MigrationToState? { + let state: MigrationToDeviceState? = UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_TO_STAGE) != nil ? decodeJSON(UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_TO_STAGE)!) : nil + var initial: MigrationToState? = .pasteOrScanLink + //logger.debug("Inited with migrationState: \(String(describing: state))") + switch state { + case nil: + initial = nil + case .downloadProgress: + // No migration happens at the moment actually since archive were not downloaded fully + logger.debug("MigrateToDevice: archive wasn't fully downloaded, removed broken file") + initial = nil case let .archiveImport(archiveName): let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName initial = .archiveImportFailed(archivePath: archivePath) case .passphrase: initial = .passphrase(passphrase: "") } + if initial == nil { + UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_TO_STAGE) + try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) + } return initial } - // Here we check whether it's needed to show migration process after app restart or not - // It's important to NOT show the process when archive was corrupted/not fully downloaded - static func transform() -> MigrationFromAnotherDeviceState? { - let state: MigrationFromAnotherDeviceState? = UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE) != nil ? decodeJSON(UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE)!) : nil - - if case let .downloadProgress(_, archiveName) = state { - // iOS changes absolute directory every launch, check this way - let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName - try? FileManager.default.removeItem(atPath: archivePath) - UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE) - // No migration happens at the moment actually since archive were not downloaded fully - logger.debug("MigrateFromDevice: archive wasn't fully downloaded, removed broken file") - return nil - } - return state - } - - static func save(_ state: MigrationFromAnotherDeviceState?, apply: (MigrationFromAnotherDeviceState?) -> Void) { + static func save(_ state: MigrationToDeviceState?) { if let state { - UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_STAGE) + UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_TO_STAGE) } else { - UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE) + UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_TO_STAGE) } - apply(state) } } -enum MigrationFromState: Equatable { +enum MigrationToState: Equatable { case pasteOrScanLink case linkDownloading(link: String) case downloadProgress(downloadedBytes: Int64, totalBytes: Int64, fileId: Int64, link: String, archivePath: String, ctrl: chat_ctrl?) @@ -71,7 +62,7 @@ enum MigrationFromState: Equatable { case onion(appSettings: AppSettings) } -private enum MigrateFromAnotherDeviceViewAlert: Identifiable { +private enum MigrateToDeviceViewAlert: Identifiable { case chatImportedWithErrors(title: LocalizedStringKey = "Chat database imported", text: LocalizedStringKey = "Some non-fatal errors occurred during import - you may see Chat console for more details.") @@ -98,13 +89,13 @@ private enum MigrateFromAnotherDeviceViewAlert: Identifiable { } } -struct MigrateFromAnotherDevice: View { +struct MigrateToDevice: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - @State var migrationState: MigrationFromState + @Binding var migrationState: MigrationToState? @State private var useKeychain = storeDBPassphraseGroupDefault.get() - @State private var alert: MigrateFromAnotherDeviceViewAlert? + @State private var alert: MigrateToDeviceViewAlert? private let tempDatabaseUrl = urlForTemporaryDatabase() @State private var chatReceiver: MigrationChatReceiver? = nil // Prevent from hiding the view until migration is finished or app deleted @@ -114,6 +105,7 @@ struct MigrateFromAnotherDevice: View { var body: some View { VStack { switch migrationState { + case nil: EmptyView() case .pasteOrScanLink: pasteOrScanLinkView() case let .linkDownloading(link): @@ -138,18 +130,14 @@ struct MigrateFromAnotherDevice: View { } .onAppear { backDisabled = switch migrationState { - case .linkDownloading: false - case .downloadProgress: false - case .archiveImportFailed: false - default: m.migrationState != nil + case nil, .pasteOrScanLink, .linkDownloading, .downloadProgress, .downloadFailed, .archiveImportFailed: false + case .archiveImport, .passphrase, .migrationConfirmation, .migration, .onion: true } } .onChange(of: migrationState) { state in backDisabled = switch state { - case .linkDownloading: false - case .downloadProgress: false - case .archiveImportFailed: false - default: m.migrationState != nil + case nil, .pasteOrScanLink, .linkDownloading, .downloadProgress, .downloadFailed, .archiveImportFailed: false + case .archiveImport, .passphrase, .migrationConfirmation, .migration, .onion: true } } .onDisappear { @@ -164,7 +152,7 @@ struct MigrateFromAnotherDevice: View { chatReceiver?.stopAndCleanUp() if !backDisabled { try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) - MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + MigrationToDeviceState.save(nil) } } } @@ -255,7 +243,7 @@ struct MigrateFromAnotherDevice: View { } } let ratio = Float(downloadedBytes) / Float(max(totalBytes, 1)) - MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded") + MigrateFromDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded") } } @@ -280,7 +268,7 @@ struct MigrateFromAnotherDevice: View { .onAppear { chatReceiver?.stopAndCleanUp() try? FileManager.default.removeItem(atPath: archivePath) - MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + MigrationToDeviceState.save(nil) } } @@ -446,11 +434,16 @@ struct MigrateFromAnotherDevice: View { switch msg { case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer): migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl) - MigrationFromAnotherDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 } + MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) case .rcvStandaloneFileComplete: DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - migrationState = .archiveImport(archivePath: archivePath) - MigrationFromAnotherDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 } + // User closed the whole screen before new state was saved + if migrationState == nil { + MigrationToDeviceState.save(nil) + } else { + migrationState = .archiveImport(archivePath: archivePath) + MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) + } } case .rcvFileError: alert = .error(title: "Download failed", error: "File was deleted or link is invalid") @@ -487,7 +480,7 @@ struct MigrateFromAnotherDevice: View { } await MainActor.run { migrationState = .passphrase(passphrase: "") - MigrationFromAnotherDeviceState.save(.passphrase) { m.migrationState = $0 } + MigrationToDeviceState.save(.passphrase) } } catch let error { await MainActor.run { @@ -523,11 +516,12 @@ struct MigrateFromAnotherDevice: View { resetChatCtrl() try initializeChat(start: false, confirmStart: false, dbKey: passphrase, refreshInvitations: true, confirmMigrations: confirmation) var appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport()) + let hasOnionConfigured = appSettings.networkConfig?.socksProxy != nil || appSettings.networkConfig?.hostMode == .onionHost + appSettings.networkConfig?.socksProxy = nil + appSettings.networkConfig?.hostMode = .publicHost + appSettings.networkConfig?.requiredHostMode = true await MainActor.run { - if appSettings.networkConfig?.hostMode == .onionViaSocks || appSettings.networkConfig?.hostMode == .onionHost || appSettings.networkConfig?.socksProxy != nil { - appSettings.networkConfig?.socksProxy = nil - appSettings.networkConfig?.hostMode = .publicHost - appSettings.networkConfig?.requiredHostMode = true + if hasOnionConfigured { migrationState = .onion(appSettings: appSettings) } else { finishMigration(appSettings) @@ -543,7 +537,7 @@ struct MigrateFromAnotherDevice: View { private func finishMigration(_ appSettings: AppSettings) { do { try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) - MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + MigrationToDeviceState.save(nil) appSettings.importIntoApp() try SimpleX.startChat(refreshInvitations: true) AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.") @@ -569,12 +563,12 @@ struct MigrateFromAnotherDevice: View { } private struct PassphraseEnteringView: View { - @Binding var migrationState: MigrationFromState + @Binding var migrationState: MigrationToState? @State private var useKeychain = true @State var currentKey: String @State private var verifyingPassphrase: Bool = false @FocusState private var keyboardVisible: Bool - @Binding var alert: MigrateFromAnotherDeviceViewAlert? + @Binding var alert: MigrateToDeviceViewAlert? var body: some View { ZStack { @@ -643,7 +637,7 @@ private struct PassphraseEnteringView: View { } } -private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { +private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { switch status { case .invalidConfirmation: alert.wrappedValue = .invalidConfirmation() @@ -713,8 +707,8 @@ private class MigrationChatReceiver { } } -struct MigrateFromAnotherDevice_Previews: PreviewProvider { +struct MigrateToDevice_Previews: PreviewProvider { static var previews: some View { - MigrateFromAnotherDevice(migrationState: .pasteOrScanLink) + MigrateToDevice(migrationState: Binding.constant(.pasteOrScanLink)) } } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index b68c1279b0..94e281be7d 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -13,8 +13,6 @@ struct SimpleXInfo: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var showHowItWorks = false - @State private var migrationState: MigrationFromState? = nil - @State private var migrateFromAnotherDevice: Bool = false var onboarding: Bool var body: some View { @@ -49,8 +47,7 @@ struct SimpleXInfo: View { Spacer() Button { - migrationState = nil - migrateFromAnotherDevice = true + m.migrationState = .pasteOrScanLink } label: { Label("Migrate from another device", systemImage: "tray.and.arrow.down") .font(.subheadline) @@ -71,16 +68,15 @@ struct SimpleXInfo: View { } .frame(minHeight: g.size.height) } - .onAppear { - if m.migrationState != nil { - migrationState = m.migrationState?.makeMigrationState() - migrateFromAnotherDevice = true - } - } - .sheet(isPresented: $migrateFromAnotherDevice) { + .sheet(isPresented: Binding( + get: { m.migrationState != nil }, + set: { _ in + m.migrationState = nil + MigrationToDeviceState.save(nil) } + )) { NavigationView { VStack(alignment: .leading) { - MigrateFromAnotherDevice(migrationState: migrationState ?? .pasteOrScanLink) + MigrateToDevice(migrationState: $m.migrationState) } .navigationTitle("Migrate here") .background(colorScheme == .light ? Color(uiColor: .tertiarySystemGroupedBackground) : .clear) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 842ccaab4c..1799d8136a 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -51,7 +51,8 @@ let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" -let DEFAULT_MIGRATION_STAGE = "migrationStage" +let DEFAULT_MIGRATION_TO_STAGE = "migrationToStage" +let DEFAULT_MIGRATION_FROM_STAGE = "migrationFromStage" let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" @@ -212,7 +213,7 @@ struct SettingsView: View { } NavigationLink { - MigrateToAnotherDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress) + MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress) .navigationTitle("Migrate device") .navigationBarTitleDisplayMode(.large) } label: { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5f6fe6c65a..eb96807b6f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -186,8 +186,8 @@ 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; }; 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; - 8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */; }; - 8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */; }; + 8C7D949A2B88952700B7B9E1 /* MigrateToDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */; }; + 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; @@ -477,8 +477,8 @@ 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; 8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = ""; }; 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromAnotherDevice.swift; sourceTree = ""; }; - 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAnotherDevice.swift; sourceTree = ""; }; + 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToDevice.swift; sourceTree = ""; }; + 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromDevice.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -904,8 +904,8 @@ 8C7D94982B8894D300B7B9E1 /* Migration */ = { isa = PBXGroup; children = ( - 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */, - 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */, + 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */, + 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */, ); path = Migration; sourceTree = ""; @@ -1141,7 +1141,7 @@ 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */, 6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, - 8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */, + 8C7D949A2B88952700B7B9E1 /* MigrateToDevice.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, @@ -1239,7 +1239,7 @@ 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, - 8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */, + 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 64249fe09b..3c9f77d791 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -68,14 +68,19 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation public func chatInitControllerRemovingDatabases() { let dbPath = getAppDatabasePath().path + let fm = FileManager.default + // Remove previous databases, otherwise, can be .errorNotADatabase with nil controller + try? fm.removeItem(atPath: dbPath + CHAT_DB) + try? fm.removeItem(atPath: dbPath + AGENT_DB) + let dbKey = randomDatabasePassword() logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)") var cPath = dbPath.cString(using: .utf8)! var cKey = dbKey.cString(using: .utf8)! var cConfirm = MigrationConfirmation.error.rawValue.cString(using: .utf8)! chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &chatController) + // We need only controller, not databases - let fm = FileManager.default try? fm.removeItem(atPath: dbPath + CHAT_DB) try? fm.removeItem(atPath: dbPath + AGENT_DB) } From 5fd8e6e4fec6120ff6d2d35bc109d3ba8636f335 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 12 Mar 2024 17:33:28 +0000 Subject: [PATCH 56/64] ui: exclude muted chats from filtered chats (#3900) --- apps/ios/Shared/Views/ChatList/ChatListView.swift | 4 +++- .../kotlin/chat/simplex/common/views/chatlist/ChatListView.kt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 22807f6182..38aabdc21d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -264,7 +264,9 @@ struct ChatListView: View { } func filtered(_ chat: Chat) -> Bool { - (chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat + (chat.chatInfo.chatSettings?.favorite ?? false) || + chat.chatStats.unreadChat || + (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) } func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { 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 1380f9ccc4..417050db35 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 @@ -535,7 +535,9 @@ private fun filteredChats( } private fun filtered(chat: Chat): Boolean = - (chat.chatInfo.chatSettings?.favorite ?: false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat + (chat.chatInfo.chatSettings?.favorite ?: false) || + chat.chatStats.unreadChat || + (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean = cInfo.chatViewName.lowercase().contains(s.lowercase()) From 7fa2f2f72e71c216d71f5f510f13c7e452abeb05 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:47:38 +0200 Subject: [PATCH 57/64] core: organize withAckMessage (#3889) * core: organize withAckMessage * mark critical sections * differentiate DB internal error from chat * throw CRITICALs * only CRIT on SEDatabaseError * normalize errors * shift MonadError into ExceptT * simplify * split critical handlers * names, CRITICAL error in withAckMessage, comments * only show critical alerts when database was locked or busy and message failed to process --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat.hs | 73 ++++++++++++++++++++------------ src/Simplex/Chat/Controller.hs | 35 +++++++-------- src/Simplex/Chat/Store/Shared.hs | 2 + 3 files changed, 62 insertions(+), 48 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f88f1c83a9..83ca9adde6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3291,10 +3291,24 @@ processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = do vr <- chatVersionRange - withStore' (`getUserByAConnId` AgentConnId connId) >>= \case + -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here + critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) +-- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. +-- SEDBBusyError will only be thrown on IO exceptions or SQLError during DB queries, +-- e.g. when database is locked or busy for longer than 3s. +-- In this case there is no better mitigation than showing alert: +-- - without ACK the message delivery will be stuck, +-- - with ACK message will be lost, as it failed to be saved. +-- Full app restart is likely to resolve database condition and the message will be received and processed again. +critical :: ChatMonad m => m a -> m a +critical a = + a `catchChatError` \case + ChatErrorStore SEDBBusyError {message} -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing + e -> throwError e + processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> m () processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h @@ -3482,9 +3496,13 @@ processAgentMsgRcvFile _corrId aFileId msg = agentXFTPDeleteRcvFile aFileId fileId toView $ CRRcvFileError user ci e ft -processAgentMessageConn :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () +processAgentMessageConn :: forall m . ChatMonad m => (PQSupport -> VersionRangeChat) -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do - entity <- withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus + -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, + -- as in this case no need to ACK message - we can't process messages for this connection anyway. + -- SEDBException will be re-trown as CRITICAL as it is likely to indicate a temporary database condition + -- that will be resolved with app restart. + entity <- critical $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct @@ -3547,12 +3565,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processINFOpqSupport conn pqSupport _conn' <- saveConnInfo conn connInfo pure () - MSG meta _msgFlags msgBody -> do - cmdId <- createAckCmd conn + MSG meta _msgFlags msgBody -> -- 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 + withAckMessage agentConnId conn meta False $ \cmdId -> do (_conn', _) <- saveDirectRcvMSG conn meta cmdId msgBody pure False SENT msgId -> @@ -3584,12 +3601,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ contData $ \(hostConnId, xGrpMemIntroCont) -> sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" - MSG msgMeta _msgFlags msgBody -> do - let MsgMeta {pqEncryption} = msgMeta - (ct', conn') <- updateContactPQRcv user ct conn pqEncryption - checkIntegrityCreateItem (CDDirectRcv ct') msgMeta - cmdId <- createAckCmd conn' - withAckMessage agentConnId cmdId msgMeta $ do + MSG msgMeta _msgFlags msgBody -> + withAckMessage agentConnId conn msgMeta True $ \cmdId -> do + let MsgMeta {pqEncryption} = msgMeta + (ct', conn') <- updateContactPQRcv user ct conn pqEncryption + checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchChatError` \_ -> pure () (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta cmdId msgBody let ct'' = ct' {activeConn = Just conn''} :: Contact assertDirectAllowed user MDRcv ct'' $ toCMEventTag event @@ -3995,10 +4011,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta - cmdId <- createAckCmd conn - let aChatMsgs = parseChatMessages msgBody - withAckMessage agentConnId cmdId msgMeta $ do + withAckMessage agentConnId conn msgMeta True $ \cmdId -> do + checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> processEvent cmdId chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e @@ -4010,6 +4024,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = [Right (ACMsg _ chatMsg)] -> forwardMsg_ chatMsg _ -> pure () where + aChatMsgs = parseChatMessages msgBody brokerTs = metaBrokerTs msgMeta processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m () processEvent cmdId chatMsg = do @@ -4046,12 +4061,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) checkSendRcpt :: [AChatMessage] -> m Bool - checkSendRcpt aChatMsgs = do + checkSendRcpt aMsgs = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo pure $ fromMaybe (sendRcptsSmallGroups user) sendRcpts - && any aChatMsgHasReceipt aChatMsgs + && any aChatMsgHasReceipt aMsgs && currentMemCount <= smallGroupsRcptsMemLimit where aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = @@ -4241,6 +4256,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () CON _ -> startReceivingFile user fileId MSG meta _ msgBody -> do + -- XXX: not all branches do ACK parseFileChunk msgBody >>= receiveFileChunk ft (Just conn) meta OK -> -- [async agent commands] continuation on receiving OK @@ -4384,19 +4400,22 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withAckMessage' :: ConnId -> Connection -> MsgMeta -> m () -> m () withAckMessage' cId conn msgMeta action = do - cmdId <- createAckCmd conn - withAckMessage cId cmdId msgMeta $ action $> False + withAckMessage cId conn msgMeta False $ \_cmdId -> action $> False - withAckMessage :: ConnId -> CommandId -> MsgMeta -> m Bool -> m () - withAckMessage cId cmdId msgMeta action = do + withAckMessage :: ConnId -> Connection -> MsgMeta -> Bool -> (CommandId -> m Bool) -> m () + withAckMessage cId conn msgMeta showCritical action = do + cmdId <- createAckCmd conn `catchChatError` \e -> throwError $ ChatErrorAgent (CRITICAL True $ show e) Nothing -- [async agent commands] command should be asynchronous, continuation is ackMsgDeliveryEvent -- TODO catching error and sending ACK after an error, particularly if it is a database error, will result in the message not processed (and no notification to the user). -- Possible solutions are: -- 1) retry processing several times -- 2) stabilize database -- 3) show screen of death to the user asking to restart - tryChatError action >>= \case + tryChatError (action cmdId) >>= \case Right withRcpt -> ackMsg cId cmdId msgMeta $ if withRcpt then Just "" else Nothing + -- If showCritical is True, then these errors don't result in ACK and show user visible alert + -- This prevents losing the message that failed to be processed. + Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing Left e -> ackMsg cId cmdId msgMeta Nothing >> throwError e ackMsg :: ConnId -> CommandId -> MsgMeta -> Maybe MsgReceiptInfo -> m () @@ -4997,9 +5016,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m () checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of MsgOk -> pure () - MsgError e -> - createInternalChatItem user cd (CIRcvIntegrityError e) (Just brokerTs) - `catchChatError` \_ -> pure () + MsgError e -> createInternalChatItem user cd (CIRcvIntegrityError e) (Just brokerTs) xInfo :: Contact -> Profile -> m () xInfo c p' = void $ processContactProfileUpdate c p' True @@ -5719,7 +5736,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete @@ -5731,7 +5748,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- - 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 + checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ce7c952ded..62caa8a6ab 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,6 +46,8 @@ import Data.Time (NominalDiffTime, UTCTime) import Data.Time.Clock.System (systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) +import Database.SQLite.Simple (SQLError) +import qualified Database.SQLite.Simple as SQL import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -80,7 +82,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Cor import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) +import Simplex.Messaging.Util (allFinally, catchAllErrors, liftIOEither, tryAllErrors, (<$$>)) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types @@ -1296,30 +1298,23 @@ withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a -withStoreCtx ctx_ action = do +withStoreCtx _ctx action = do ChatController {chatStore} <- ask - liftEitherError ChatErrorStore $ case ctx_ of - Nothing -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" - -- uncomment to debug store performance - -- Just ctx -> do - -- t1 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx - -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") - -- t2 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) - -- pure r - Just _ -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" - where - handleInternal :: String -> SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr + liftIOEither $ withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors 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 + liftIO $ withTransaction chatStore $ mapM (`E.catches` handleDBErrors) . actions + +handleDBErrors :: [E.Handler IO (Either ChatError a)] +handleDBErrors = + [ E.Handler $ \(e :: SQLError) -> + let se = SQL.sqlError e + busy = se == SQL.ErrorBusy || se == SQL.ErrorLocked + in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e, + E.Handler $ \(E.SomeException e) -> pure . Left . ChatErrorStore . SEDBException $ show e + ] withStoreBatch' :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO a)) -> m (t (Either ChatError a)) withStoreBatch' actions = withStoreBatch $ fmap (fmap Right) . actions diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6d5c41c1a5..fd628d09ee 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -95,6 +95,8 @@ data StoreError | SEUniqueID | SELargeMsg | SEInternalError {message :: String} + | SEDBException {message :: String} + | SEDBBusyError {message :: String} | SEBadChatItem {itemId :: ChatItemId, itemTs :: Maybe ChatItemTs} | SEChatItemNotFound {itemId :: ChatItemId} | SEChatItemNotFoundByText {text :: Text} From e75be71d9ac9153b0e6389e4bb16b41e58148b5c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 13 Mar 2024 01:25:02 +0700 Subject: [PATCH 58/64] android, desktop: migration enhancements (#3901) * adapted iOS logic * rename everything * title * title * text * rename * rename * alert on strange error --- .../chat/simplex/common/model/ChatModel.kt | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 12 +- .../chat/simplex/common/platform/Core.kt | 4 + ...oAnotherDevice.kt => MigrateFromDevice.kt} | 264 ++++++++------- ...romAnotherDevice.kt => MigrateToDevice.kt} | 316 +++++++++--------- .../common/views/onboarding/SimpleXInfo.kt | 12 +- .../common/views/usersettings/SettingsView.kt | 5 +- .../commonMain/resources/MR/base/strings.xml | 113 ++++--- 8 files changed, 392 insertions(+), 339 deletions(-) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/{MigrateToAnotherDevice.kt => MigrateFromDevice.kt} (64%) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/{MigrateFromAnotherDevice.kt => MigrateToDevice.kt} (64%) 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 faa4200555..a8a5797d71 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 @@ -13,7 +13,8 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.migration.MigrationFromAnotherDeviceState +import chat.simplex.common.views.migration.MigrationToDeviceState +import chat.simplex.common.views.migration.MigrationToState import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -105,7 +106,7 @@ object ChatModel { // currently showing invitation val showingInvitation = mutableStateOf(null as ShowingInvitation?) - val migrationState: MutableState by lazy { mutableStateOf(MigrationFromAnotherDeviceState.transform()) } + val migrationState: MutableState by lazy { mutableStateOf(MigrationToDeviceState.makeMigrationState()) } var draft = mutableStateOf(null as ComposeState?) var draftChatId = mutableStateOf(null as String?) 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 08d30fe086..85ce8dc00b 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 @@ -147,7 +147,8 @@ class AppPreferences { val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } } - val migrationStage = mkStrPreference(SHARED_PREFS_MIGRATION_STAGE, null) + val migrationToStage = mkStrPreference(SHARED_PREFS_MIGRATION_TO_STAGE, null) + val migrationFromStage = mkStrPreference(SHARED_PREFS_MIGRATION_FROM_STAGE, null) val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true) val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false) val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null) @@ -286,7 +287,8 @@ class AppPreferences { private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" - const val SHARED_PREFS_MIGRATION_STAGE = "MigrationStage" + const val SHARED_PREFS_MIGRATION_TO_STAGE = "MigrationToStage" + const val SHARED_PREFS_MIGRATION_FROM_STAGE = "MigrationFromStage" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_CHAT_STOPPED = "ChatStopped" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" @@ -704,9 +706,11 @@ object ChatController { throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): Boolean { + suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): CR.ChatCmdError? { val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl) - return r is CR.CmdOk + if (r is CR.CmdOk) return null + else if (r is CR.ChatCmdError) return r + throw Exception("failed to test storage encryption: ${r.responseType} ${r.details}") } suspend fun apiGetChats(rh: Long?): List { 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 1a186ce8ad..f1a6d35e45 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 @@ -152,6 +152,10 @@ fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: fun chatInitControllerRemovingDatabases() { val dbPath = dbAbsolutePrefixPath + // Remove previous databases, otherwise, can be .errorNotADatabase with null controller + File(dbPath + "_chat.db").delete() + File(dbPath + "_agent.db").delete() + val dbKey = randomDatabasePassword() Log.d(TAG, "chatInitControllerRemovingDatabases path: $dbPath") val migrated = chatMigrateInit(dbPath, dbKey, MigrationConfirmation.Error.value) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt similarity index 64% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 5db6daf6de..da6e7181d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -79,39 +79,49 @@ data class MigrationFileLinkData( @Serializable -private sealed class MigrationToState { - @Serializable object ChatStopInProgress: MigrationToState() - @Serializable data class ChatStopFailed(val reason: String): MigrationToState() - @Serializable object PassphraseNotSet: MigrationToState() - @Serializable object PassphraseConfirmation: MigrationToState() - @Serializable object UploadConfirmation: MigrationToState() - @Serializable object Archiving: MigrationToState() - @Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationToState() - @Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationToState() - @Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationToState() - @Serializable object LinkCreation: MigrationToState() - @Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationToState() - @Serializable data class Finished(val chatDeletion: Boolean): MigrationToState() +private sealed class MigrationFromState { + @Serializable object ChatStopInProgress: MigrationFromState() + @Serializable data class ChatStopFailed(val reason: String): MigrationFromState() + @Serializable object PassphraseNotSet: MigrationFromState() + @Serializable object PassphraseConfirmation: MigrationFromState() + @Serializable object UploadConfirmation: MigrationFromState() + @Serializable object Archiving: MigrationFromState() + @Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationFromState() + @Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationFromState() + @Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationFromState() + @Serializable object LinkCreation: MigrationFromState() + @Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationFromState() + @Serializable data class Finished(val chatDeletion: Boolean): MigrationFromState() } -private var MutableState.state: MigrationToState +private var MutableState.state: MigrationFromState get() = value set(v) { value = v } @Composable -fun MigrateToAnotherDeviceView(close: () -> Unit) { - val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(MigrationToState.ChatStopInProgress) } +fun MigrateFromDeviceView(close: () -> Unit) { + val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(MigrationFromState.ChatStopInProgress) } // Prevent from hiding the view until migration is finished or app deleted val backDisabled = remember { derivedStateOf { - migrationState.value is MigrationToState.DatabaseInit || - migrationState.value is MigrationToState.Archiving || - migrationState.value is MigrationToState.LinkCreation || - migrationState.value is MigrationToState.LinkShown || - migrationState.value is MigrationToState.Finished + when (migrationState.value) { + is MigrationFromState.ChatStopInProgress, + is MigrationFromState.DatabaseInit, + is MigrationFromState.Archiving, + is MigrationFromState.LinkShown, + is MigrationFromState.Finished -> true + + is MigrationFromState.ChatStopFailed, + is MigrationFromState.PassphraseNotSet, + is MigrationFromState.PassphraseConfirmation, + is MigrationFromState.UploadConfirmation, + is MigrationFromState.UploadProgress, + is MigrationFromState.UploadFailed, + is MigrationFromState.LinkCreation -> false + } } } - val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) } + val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) } ModalView( enableClose = !backDisabled.value, close = { @@ -121,7 +131,7 @@ fun MigrateToAnotherDeviceView(close: () -> Unit) { close() }, ) { - MigrateToAnotherDeviceLayout( + MigrateFromDeviceLayout( migrationState = migrationState, chatReceiver = chatReceiver ) @@ -129,16 +139,16 @@ fun MigrateToAnotherDeviceView(close: () -> Unit) { } @Composable -private fun MigrateToAnotherDeviceLayout( - migrationState: MutableState, - chatReceiver: MutableState +private fun MigrateFromDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState ) { val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), ) { - AppBarTitle(stringResource(MR.strings.migrate_to_device)) + AppBarTitle(stringResource(MR.strings.migrate_from_device_title)) SectionByState(migrationState, tempDatabaseFile.value, chatReceiver) SectionBottomSpacer() } @@ -147,30 +157,30 @@ private fun MigrateToAnotherDeviceLayout( @Composable private fun SectionByState( - migrationState: MutableState, + migrationState: MutableState, tempDatabaseFile: File, - chatReceiver: MutableState + chatReceiver: MutableState ) { when (val s = migrationState.value) { - is MigrationToState.ChatStopInProgress -> migrationState.ChatStopInProgressView() - is MigrationToState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason) - is MigrationToState.PassphraseNotSet -> migrationState.PassphraseNotSetView() - is MigrationToState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView() - is MigrationToState.UploadConfirmation -> migrationState.UploadConfirmationView() - is MigrationToState.Archiving -> migrationState.ArchivingView() - is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath) - is MigrationToState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) - is MigrationToState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) - is MigrationToState.LinkCreation -> LinkCreationView() - is MigrationToState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) - is MigrationToState.Finished -> migrationState.FinishedView(s.chatDeletion) + is MigrationFromState.ChatStopInProgress -> migrationState.ChatStopInProgressView() + is MigrationFromState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason) + is MigrationFromState.PassphraseNotSet -> migrationState.PassphraseNotSetView() + is MigrationFromState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView() + is MigrationFromState.UploadConfirmation -> migrationState.UploadConfirmationView() + is MigrationFromState.Archiving -> migrationState.ArchivingView() + is MigrationFromState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath) + is MigrationFromState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) + is MigrationFromState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) + is MigrationFromState.LinkCreation -> LinkCreationView() + is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) + is MigrationFromState.Finished -> migrationState.FinishedView(s.chatDeletion) } } @Composable -private fun MutableState.ChatStopInProgressView() { +private fun MutableState.ChatStopInProgressView() { Box { - SectionView(stringResource(MR.strings.migration_to_device_stopping_chat).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_stopping_chat).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -179,7 +189,7 @@ private fun MutableState.ChatStopInProgressView() { } @Composable -private fun MutableState.ChatStopFailedView(reason: String) { +private fun MutableState.ChatStopFailedView(reason: String) { SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) { Text(reason) SectionSpacer() @@ -189,22 +199,22 @@ private fun MutableState.ChatStopFailedView(reason: String) { textColor = MaterialTheme.colors.error, click = ::stopChat ){} - SectionTextFooter(stringResource(MR.strings.migration_to_device_chat_should_be_stopped)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_chat_should_be_stopped)) } } @Composable -private fun MutableState.PassphraseNotSetView() { +private fun MutableState.PassphraseNotSetView() { DatabaseEncryptionView(chatModel, true) KeyChangeEffect(appPreferences.initialRandomDBPassphrase.state.value) { if (!appPreferences.initialRandomDBPassphrase.get()) { - state = MigrationToState.UploadConfirmation + state = MigrationFromState.UploadConfirmation } } } @Composable -private fun MutableState.PassphraseConfirmationView() { +private fun MutableState.PassphraseConfirmationView() { val useKeychain = remember { appPreferences.storeDBPassphrase.get() } val currentKey = rememberSaveable { mutableStateOf("") } val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } @@ -214,12 +224,12 @@ private fun MutableState.PassphraseConfirmationView() { ChatStoppedView() SectionSpacer() - SectionView(stringResource(MR.strings.migration_to_device_verify_database_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_verify_database_passphrase).uppercase()) { PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) SettingsActionItemWithContent( icon = painterResource(if (useKeychain) MR.images.ic_vpn_key_filled else MR.images.ic_lock), - text = stringResource(MR.strings.migration_to_device_verify_passphrase), + text = stringResource(MR.strings.migrate_from_device_verify_passphrase), textColor = MaterialTheme.colors.primary, disabled = verifyingPassphrase.value || currentKey.value.isEmpty(), click = { @@ -231,7 +241,7 @@ private fun MutableState.PassphraseConfirmationView() { } } ) {} - SectionTextFooter(stringResource(MR.strings.migration_to_device_confirm_you_remember_passphrase)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_confirm_you_remember_passphrase)) } } if (verifyingPassphrase.value) { @@ -241,22 +251,22 @@ private fun MutableState.PassphraseConfirmationView() { } @Composable -private fun MutableState.UploadConfirmationView() { - SectionView(stringResource(MR.strings.migration_to_device_confirm_upload).uppercase()) { +private fun MutableState.UploadConfirmationView() { + SectionView(stringResource(MR.strings.migrate_from_device_confirm_upload).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), - text = stringResource(MR.strings.migration_to_device_archive_and_upload), + text = stringResource(MR.strings.migrate_from_device_archive_and_upload), textColor = MaterialTheme.colors.primary, - click = { state = MigrationToState.Archiving } + click = { state = MigrationFromState.Archiving } ){} - SectionTextFooter(stringResource(MR.strings.migration_to_device_all_data_will_be_uploaded)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_all_data_will_be_uploaded)) } } @Composable -private fun MutableState.ArchivingView() { +private fun MutableState.ArchivingView() { Box { - SectionView(stringResource(MR.strings.migration_to_device_archiving_database).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_archiving_database).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -265,9 +275,9 @@ private fun MutableState.ArchivingView() { } @Composable -private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { +private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { Box { - SectionView(stringResource(MR.strings.migration_to_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_database_init).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -276,19 +286,19 @@ private fun MutableState.DatabaseInitView(tempDatabaseFile: Fi } @Composable -private fun MutableState.UploadProgressView( +private fun MutableState.UploadProgressView( uploadedBytes: Long, totalBytes: Long, ctrl: ChatCtrl, user: User, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, archivePath: String, ) { Box { - SectionView(stringResource(MR.strings.migration_to_device_uploading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_uploading_archive).uppercase()) { val ratio = uploadedBytes.toFloat() / max(totalBytes, 1) - LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_to_device_bytes_uploaded).format(formatBytes(uploadedBytes))) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_from_device_bytes_uploaded).format(formatBytes(uploadedBytes))) } } LaunchedEffect(Unit) { @@ -297,17 +307,17 @@ private fun MutableState.UploadProgressView( } @Composable -private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationToChatReceiver?) { - SectionView(stringResource(MR.strings.migration_to_device_upload_failed).uppercase()) { +private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationFromChatReceiver?) { + SectionView(stringResource(MR.strings.migrate_from_device_upload_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), - text = stringResource(MR.strings.migration_to_device_repeat_upload), + text = stringResource(MR.strings.migrate_from_device_repeat_upload), textColor = MaterialTheme.colors.primary, click = { - state = MigrationToState.DatabaseInit(totalBytes, archivePath) + state = MigrationFromState.DatabaseInit(totalBytes, archivePath) } ) {} - SectionTextFooter(stringResource(MR.strings.migration_to_device_try_again)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_try_again)) } LaunchedEffect(Unit) { chatReceiver?.stopAndCleanUp() @@ -317,17 +327,17 @@ private fun MutableState.UploadFailedView(totalBytes: Long, ar @Composable private fun LinkCreationView() { Box { - SectionView(stringResource(MR.strings.migration_to_device_creating_archive_link).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_creating_archive_link).uppercase()) {} ProgressView() } } @Composable -private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { +private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { SectionView { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_close), - text = stringResource(MR.strings.migration_to_device_cancel_migration), + text = stringResource(MR.strings.migrate_from_device_cancel_migration), textColor = MaterialTheme.colors.error, click = { cancelMigration(fileId, ctrl) @@ -335,31 +345,32 @@ private fun MutableState.LinkShownView(fileId: Long, link: Str ) {} SettingsActionItemWithContent( icon = painterResource(MR.images.ic_check), - text = stringResource(MR.strings.migration_to_device_finalize_migration), + text = stringResource(MR.strings.migrate_from_device_finalize_migration), textColor = MaterialTheme.colors.primary, click = { finishMigration(fileId, ctrl) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_choose_migrate_from_another_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_choose_migrate_from_another_device)) } SectionSpacer() SectionView(stringResource(MR.strings.show_QR_code).uppercase()) { SimpleXLinkQRCode(link, onShare = {}) } SectionSpacer() - SectionView(stringResource(MR.strings.migration_to_device_or_share_this_file_link).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_or_share_this_file_link).uppercase()) { LinkTextView(link, true) } } @Composable -private fun MutableState.FinishedView(chatDeletion: Boolean) { +private fun MutableState.FinishedView(chatDeletion: Boolean) { Box { - SectionView(stringResource(MR.strings.migration_to_device_migration_complete).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_migration_complete).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_delete_forever), - text = stringResource(MR.strings.migration_to_device_delete_database_from_device), + text = stringResource(MR.strings.migrate_from_device_delete_database_from_device), textColor = MaterialTheme.colors.primary, click = { AlertManager.shared.showAlertDialog( @@ -375,21 +386,21 @@ private fun MutableState.FinishedView(chatDeletion: Boolean) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_play_arrow_filled), - text = stringResource(MR.strings.migration_to_device_start_chat), + text = stringResource(MR.strings.migrate_from_device_start_chat), textColor = MaterialTheme.colors.error, click = { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.start_chat_question), - text = generalGetString(MR.strings.migration_to_device_starting_chat_on_multiple_devices_unsupported), - confirmText = generalGetString(MR.strings.migration_to_device_start_chat), + text = generalGetString(MR.strings.migrate_from_device_starting_chat_on_multiple_devices_unsupported), + confirmText = generalGetString(MR.strings.migrate_from_device_start_chat), onConfirm = { withLongRunningApi { startChatAndDismiss() } } ) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_you_must_not_start_database_on_two_device)) - SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_using_on_two_device_breaks_encryption)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) } if (chatDeletion) { ProgressView() @@ -420,52 +431,58 @@ fun LargeProgressView(value: Float, title: String, description: String) { } } -private fun MutableState.stopChat() { +private fun MutableState.stopChat() { withBGApi { try { stopChatAsync(chatModel) try { controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) - state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationToState.PassphraseNotSet else MigrationToState.PassphraseConfirmation + state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationFromState.PassphraseNotSet else MigrationFromState.PassphraseConfirmation } catch (e: Exception) { AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.migrate_to_device_error_saving_settings), + title = generalGetString(MR.strings.migrate_from_device_error_saving_settings), text = e.stackTraceToString() ) - state = MigrationToState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_to_device_error_saving_settings)) + state = MigrationFromState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_from_device_error_saving_settings)) } } catch (e: Exception) { - state = MigrationToState.ChatStopFailed(reason = e.stackTraceToString().take(10)) + state = MigrationFromState.ChatStopFailed(reason = e.stackTraceToString().take(10)) } } } -private suspend fun MutableState.verifyDatabasePassphrase(dbKey: String) { - if (controller.testStorageEncryption(dbKey)) { - state = MigrationToState.UploadConfirmation - } else { +private suspend fun MutableState.verifyDatabasePassphrase(dbKey: String) { + val error = controller.testStorageEncryption(dbKey) + if (error == null) { + state = MigrationFromState.UploadConfirmation + } else if (((error.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorOpen)?.sqliteError is SQLiteError.ErrorNotADatabase) { showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase("")) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error), + text = generalGetString(MR.strings.migrate_from_device_error_verifying_passphrase) + " " + error.details + ) } } -private fun MutableState.exportArchive() { +private fun MutableState.exportArchive() { withLongRunningApi { try { getMigrationTempFilesDirectory().mkdir() val archivePath = exportChatArchive(chatModel, getMigrationTempFilesDirectory(), mutableStateOf(""), mutableStateOf(Instant.DISTANT_PAST), mutableStateOf("")) val totalBytes = File(archivePath).length() if (totalBytes > 0L) { - state = MigrationToState.DatabaseInit(totalBytes, archivePath) + state = MigrationFromState.DatabaseInit(totalBytes, archivePath) } else { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_exported_file_doesnt_exist)) - state = MigrationToState.UploadConfirmation + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_from_device_exported_file_doesnt_exist)) + state = MigrationFromState.UploadConfirmation } } catch (e: Exception) { AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.migrate_to_device_error_exporting_archive), + title = generalGetString(MR.strings.migrate_from_device_error_exporting_archive), text = e.stackTraceToString() ) - state = MigrationToState.UploadConfirmation + state = MigrationFromState.UploadConfirmation } } } @@ -484,7 +501,7 @@ suspend fun initTemporaryDatabase(tempDatabaseFile: File, netCfg: NetCfg): Pair< return null } -private fun MutableState.prepareDatabase( +private fun MutableState.prepareDatabase( tempDatabaseFile: File, totalBytes: Long, archivePath: String, @@ -492,35 +509,35 @@ private fun MutableState.prepareDatabase( withLongRunningApi { val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, getNetCfg()) if (ctrlAndUser == null) { - state = MigrationToState.UploadFailed(totalBytes, archivePath) + state = MigrationFromState.UploadFailed(totalBytes, archivePath) return@withLongRunningApi } val (ctrl, user) = ctrlAndUser - state = MigrationToState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user) + state = MigrationFromState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user) } } -private fun MutableState.startUploading( +private fun MutableState.startUploading( totalBytes: Long, ctrl: ChatCtrl, user: User, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, archivePath: String, ) { withBGApi { - chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> + chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> when (msg) { is CR.SndFileProgressXFTP -> { val s = state - if (s is MigrationToState.UploadProgress && s.uploadedBytes != s.totalBytes) { - state = MigrationToState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) + if (s is MigrationFromState.UploadProgress && s.uploadedBytes != s.totalBytes) { + state = MigrationFromState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) } } is CR.SndFileRedirectStartXFTP -> { delay(500) - state = MigrationToState.LinkCreation + state = MigrationFromState.LinkCreation } is CR.SndStandaloneFileComplete -> { delay(500) @@ -532,7 +549,14 @@ private fun MutableState.startUploading( requiredHostMode = cfg.requiredHostMode ) ) - state = MigrationToState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + state = MigrationFromState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + } + is CR.SndFileError -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migrate_from_device_upload_failed), + generalGetString(MR.strings.migrate_from_device_check_connection_and_try_again) + ) + state = MigrationFromState.UploadFailed(totalBytes, archivePath) } else -> { Log.d(TAG, "unsupported event: ${msg.responseType}") @@ -544,13 +568,13 @@ private fun MutableState.startUploading( val (res, error) = controller.uploadStandaloneFile(user, CryptoFile.plain(File(archivePath).name), ctrl) if (res == null) { - state = MigrationToState.UploadFailed(totalBytes, archivePath) + state = MigrationFromState.UploadFailed(totalBytes, archivePath) return@withBGApi AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.migration_to_device_error_uploading_archive), + generalGetString(MR.strings.migrate_from_device_error_uploading_archive), error ) } - state = MigrationToState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user) + state = MigrationFromState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user) } } @@ -565,19 +589,19 @@ private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) { } } -private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { +private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { withBGApi { cancelUploadedArchive(fileId, ctrl) - state = MigrationToState.Finished(false) + state = MigrationFromState.Finished(false) } } -private fun MutableState.deleteChatAndDismiss() { +private fun MutableState.deleteChatAndDismiss() { withBGApi { try { deleteChatAsync(chatModel) chatModel.chatDbChanged.value = true - state = MigrationToState.Finished(true) + state = MigrationFromState.Finished(true) try { initChatController(startChat = { CompletableDeferred(false) }) chatModel.chatDbChanged.value = false @@ -587,7 +611,7 @@ private fun MutableState.deleteChatAndDismiss() { } } catch (e: Exception) { AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.migration_to_device_error_deleting_database), + title = generalGetString(MR.strings.migrate_from_device_error_deleting_database), text = e.stackTraceToString() ) } @@ -615,14 +639,14 @@ private suspend fun startChatAndDismiss(dismiss: Boolean = true) { } } -private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) { +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) { val s = state - if (s !is MigrationToState.LinkShown && s !is MigrationToState.Finished) { + if (s !is MigrationFromState.LinkShown && s !is MigrationFromState.Finished) { chatModel.switchingUsersAndHosts.value = true startChatAndDismiss(false) chatModel.switchingUsersAndHosts.value = false } - if (s is MigrationToState.UploadProgress) { + if (s is MigrationFromState.UploadProgress) { cancelUploadedArchive(s.fileId, s.ctrl) } chatReceiver?.stopAndCleanUp() @@ -632,7 +656,7 @@ private suspend fun MutableState.cleanUpOnBack(chatReceiver: M private fun fileForTemporaryDatabase(): File = File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory())) -private class MigrationToChatReceiver( +private class MigrationFromChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt similarity index 64% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 52698a6e47..e24570ba8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import chat.simplex.common.model.* -import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_STAGE +import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_TO_STAGE import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.startChat import chat.simplex.common.model.ChatCtrl @@ -39,100 +39,97 @@ import java.util.* import kotlin.math.max @Serializable -sealed class MigrationFromAnotherDeviceState { - @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationFromAnotherDeviceState() - @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() - @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() - @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationFromAnotherDeviceState() +sealed class MigrationToDeviceState { + @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationToDeviceState() + @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationToDeviceState() + @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationToDeviceState() + @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationToDeviceState() companion object { // Here we check whether it's needed to show migration process after app restart or not // It's important to NOT show the process when archive was corrupted/not fully downloaded - fun transform(): MigrationFromAnotherDeviceState? { - val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_STAGE) - var state: MigrationFromAnotherDeviceState? = if (stage != null) json.decodeFromString(stage) else null - if (state is DownloadProgress) { - // No migration happens at the moment actually since archive were not downloaded fully - Log.e(TAG, "MigrateFromDevice: archive wasn't fully downloaded, removed broken file") - state = null - } else if (state is Onion) { - state = null - } else if (state is ArchiveImport && !File(getMigrationTempFilesDirectory(), state.archiveName).exists()) { - Log.e(TAG, "MigrateFromDevice: archive was removed unintentionally or state is broken, dropping migration") - state = null + fun makeMigrationState(): MigrationToState? { + val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_TO_STAGE) + val state: MigrationToDeviceState? = if (stage != null) json.decodeFromString(stage) else null + val initial: MigrationToState? = when(state) { + null -> null + is DownloadProgress -> { + // No migration happens at the moment actually since archive were not downloaded fully + Log.e(TAG, "MigrateToDevice: archive wasn't fully downloaded, removed broken file") + null + } + is Onion -> null + is ArchiveImport -> { + if (!File(getMigrationTempFilesDirectory(), state.archiveName).exists()) { + Log.e(TAG, "MigrateToDevice: archive was removed unintentionally or state is broken, dropping migration") + null + } else { + val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) + MigrationToState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg) + } + } + is Passphrase -> MigrationToState.Passphrase("", state.netCfg) } - if (state == null) { - settings.remove(SHARED_PREFS_MIGRATION_STAGE) + if (initial == null) { + settings.remove(SHARED_PREFS_MIGRATION_TO_STAGE) getMigrationTempFilesDirectory().deleteRecursively() } - return state + return initial } - fun save(state: MigrationFromAnotherDeviceState?) { + fun save(state: MigrationToDeviceState?) { if (state != null) { - appPreferences.migrationStage.set(json.encodeToString(state)) + appPreferences.migrationToStage.set(json.encodeToString(state)) } else { - appPreferences.migrationStage.set(null) + appPreferences.migrationToStage.set(null) } - chatModel.migrationState.value = state } } } @Serializable -private sealed class MigrationState { - @Serializable object PasteOrScanLink: MigrationState() - @Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationState() - @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationState() - @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationState() - @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationState() - @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() - @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() +sealed class MigrationToState { + @Serializable object PasteOrScanLink: MigrationToState() + @Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationToState() + @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationToState() + @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationToState() + @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationToState() } -private var MutableState.state: MigrationState +private var MutableState.state: MigrationToState? get() = value set(v) { value = v } @Composable -fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceState? = null, close: () -> Unit) { - val migrationState = rememberSaveable(stateSaver = serializableSaver()) { - mutableStateOf( - when (state) { - null -> MigrationState.PasteOrScanLink - is MigrationFromAnotherDeviceState.Onion -> { - MigrationState.Onion(state.link, state.socksProxy, state.hostMode, state.requiredHostMode) - } - is MigrationFromAnotherDeviceState.DownloadProgress -> { - val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) - // SHOULDN'T BE HERE because the app checks this before opening migration screen and will not open it in this case. - // See analyzeMigrationState() - MigrationState.DownloadFailed(totalBytes = 0, link = state.link, archivePath = archivePath.absolutePath, state.netCfg) - } - is MigrationFromAnotherDeviceState.ArchiveImport -> { - val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) - MigrationState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg) - } - is MigrationFromAnotherDeviceState.Passphrase -> { - MigrationState.Passphrase("", state.netCfg) - } - } - ) - } +fun ModalData.MigrateToDeviceView(close: () -> Unit) { + val migrationState = remember { chatModel.migrationState } // Prevent from hiding the view until migration is finished or app deleted val backDisabled = remember { derivedStateOf { - val s = chatModel.migrationState.value - s is MigrationFromAnotherDeviceState.ArchiveImport || - s is MigrationFromAnotherDeviceState.Passphrase || - migrationState.value is MigrationState.DatabaseInit + when (chatModel.migrationState.value) { + null, + is MigrationToState.PasteOrScanLink, + is MigrationToState.Onion, + is MigrationToState.LinkDownloading, + is MigrationToState.DownloadProgress, + is MigrationToState.DownloadFailed, + is MigrationToState.ArchiveImportFailed -> false + + is MigrationToState.ArchiveImport, + is MigrationToState.DatabaseInit, + is MigrationToState.Migration, + is MigrationToState.MigrationConfirmation, + is MigrationToState.Passphrase -> true + } } } - val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) } + val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) } ModalView( enableClose = !backDisabled.value, close = { @@ -142,7 +139,7 @@ fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceStat } }, ) { - MigrateFromAnotherDeviceLayout( + MigrateToDeviceLayout( migrationState = migrationState, chatReceiver = chatReceiver, close = close, @@ -151,9 +148,9 @@ fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceStat } @Composable -private fun ModalData.MigrateFromAnotherDeviceLayout( - migrationState: MutableState, - chatReceiver: MutableState, +private fun ModalData.MigrateToDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState, close: () -> Unit, ) { val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } @@ -161,7 +158,7 @@ private fun ModalData.MigrateFromAnotherDeviceLayout( Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), ) { - AppBarTitle(stringResource(MR.strings.migrate_here)) + AppBarTitle(stringResource(MR.strings.migrate_to_device_title)) SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close) SectionBottomSpacer() } @@ -170,28 +167,29 @@ private fun ModalData.MigrateFromAnotherDeviceLayout( @Composable private fun ModalData.SectionByState( - migrationState: MutableState, + migrationState: MutableState, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, close: () -> Unit ) { when (val s = migrationState.value) { - is MigrationState.PasteOrScanLink -> migrationState.PasteOrScanLinkView() - is MigrationState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState) - is MigrationState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg) - is MigrationState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg) - is MigrationState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes) - is MigrationState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg) - is MigrationState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg) - is MigrationState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg) - is MigrationState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg) - is MigrationState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg) - is MigrationState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close) + null -> {} + is MigrationToState.PasteOrScanLink -> migrationState.PasteOrScanLinkView() + is MigrationToState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState) + is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg) + is MigrationToState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg) + is MigrationToState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes) + is MigrationToState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg) + is MigrationToState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg) + is MigrationToState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg) + is MigrationToState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg) + is MigrationToState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg) + is MigrationToState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close) } } @Composable -private fun MutableState.PasteOrScanLinkView() { +private fun MutableState.PasteOrScanLinkView() { if (appPlatform.isAndroid) { SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) { QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text -> @@ -209,7 +207,7 @@ private fun MutableState.PasteOrScanLinkView() { } @Composable -private fun MutableState.PasteLinkView() { +private fun MutableState.PasteLinkView() { val clipboard = LocalClipboardManager.current SectionItemView({ val str = clipboard.getText()?.text ?: return@SectionItemView @@ -220,7 +218,7 @@ private fun MutableState.PasteLinkView() { } @Composable -private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { +private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { val onionHosts = remember { stateGetOrPut("onionHosts") { getNetCfg().copy(socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts } } @@ -238,10 +236,10 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = socksProxy, sessionMode = sessionMode.value)) } - SectionView(stringResource(MR.strings.migration_from_device_confirm_network_settings).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_confirm_network_settings).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_check), - text = stringResource(MR.strings.migration_from_device_apply_onion), + text = stringResource(MR.strings.migrate_to_device_apply_onion), textColor = MaterialTheme.colors.primary, click = { val updated = netCfg.value @@ -251,11 +249,11 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos sessionMode = sessionMode.value ) withBGApi { - state.value = MigrationState.DatabaseInit(link, updated) + state.value = MigrationToState.DatabaseInit(link, updated) } } ){} - SectionTextFooter(stringResource(MR.strings.migration_from_device_confirm_network_settings_footer)) + SectionTextFooter(stringResource(MR.strings.migrate_to_device_confirm_network_settings_footer)) } SectionSpacer() @@ -285,9 +283,9 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos } @Composable -private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) { +private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) { Box { - SectionView(stringResource(MR.strings.migration_from_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_database_init).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -296,17 +294,17 @@ private fun MutableState.DatabaseInitView(link: String, tempData } @Composable -private fun MutableState.LinkDownloadingView( +private fun MutableState.LinkDownloadingView( link: String, ctrl: ChatCtrl, user: User, archivePath: String, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, netCfg: NetCfg ) { Box { - SectionView(stringResource(MR.strings.migration_from_device_downloading_details).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_downloading_details).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -317,37 +315,37 @@ private fun MutableState.LinkDownloadingView( @Composable private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { Box { - SectionView(stringResource(MR.strings.migration_from_device_downloading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_downloading_archive).uppercase()) { val ratio = downloadedBytes.toFloat() / max(totalBytes, 1) - LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_from_device_bytes_downloaded).format(formatBytes(downloadedBytes))) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_to_device_bytes_downloaded).format(formatBytes(downloadedBytes))) } } } @Composable -private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationFromChatReceiver?, archivePath: String, netCfg: NetCfg) { - SectionView(stringResource(MR.strings.migration_from_device_download_failed).uppercase()) { +private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationToChatReceiver?, archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migrate_to_device_download_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), - text = stringResource(MR.strings.migration_from_device_repeat_download), + text = stringResource(MR.strings.migrate_to_device_repeat_download), textColor = MaterialTheme.colors.primary, click = { - state = MigrationState.DatabaseInit(link, netCfg) + state = MigrationToState.DatabaseInit(link, netCfg) } ) {} - SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + SectionTextFooter(stringResource(MR.strings.migrate_to_device_try_again)) } LaunchedEffect(Unit) { chatReceiver?.stopAndCleanUp() File(archivePath).delete() - MigrationFromAnotherDeviceState.save(null) + MigrationToDeviceState.save(null) } } @Composable -private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg) { +private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg) { Box { - SectionView(stringResource(MR.strings.migration_from_device_importing_archive).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_importing_archive).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -356,29 +354,29 @@ private fun MutableState.ArchiveImportView(archivePath: String, } @Composable -private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) { - SectionView(stringResource(MR.strings.migration_from_device_import_failed).uppercase()) { +private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migrate_to_device_import_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), - text = stringResource(MR.strings.migration_from_device_repeat_import), + text = stringResource(MR.strings.migrate_to_device_repeat_import), textColor = MaterialTheme.colors.primary, click = { - state = MigrationState.ArchiveImport(archivePath, netCfg) + state = MigrationToState.ArchiveImport(archivePath, netCfg) } ) {} - SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + SectionTextFooter(stringResource(MR.strings.migrate_to_device_try_again)) } } @Composable -private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) { +private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) { val currentKey = rememberSaveable { mutableStateOf(currentKey) } val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } val useKeychain = rememberSaveable { mutableStateOf(appPreferences.storeDBPassphrase.get()) } Box { val view = LocalMultiplatformView() - SectionView(stringResource(MR.strings.migration_from_device_enter_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_enter_passphrase).uppercase()) { SavePassphraseSetting( useKeychain.value, false, @@ -401,9 +399,9 @@ private fun MutableState.PassphraseEnteringView(currentKey: Stri val (status, _) = chatInitTemporaryDatabase(dbAbsolutePrefixPath, key = currentKey.value, confirmation = MigrationConfirmation.YesUp) val success = status == DBMigrationResult.OK || status == DBMigrationResult.InvalidConfirmation if (success) { - state = MigrationState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg) + state = MigrationToState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg) } else if (status is DBMigrationResult.ErrorMigration) { - state = MigrationState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg) + state = MigrationToState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg) } else { showErrorOnMigrationIfNeeded(status) } @@ -420,7 +418,7 @@ private fun MutableState.PassphraseEnteringView(currentKey: Stri } @Composable -private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) { +private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) { data class Tuple4(val a: A, val b: B, val c: C, val d: D) val (header: String, button: String?, footer: String, confirmation: MigrationConfirmation?) = when (status) { is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) { @@ -455,7 +453,7 @@ private fun MutableState.MigrationConfirmationView(status: DBMig text = button, textColor = MaterialTheme.colors.primary, click = { - state = MigrationState.Migration(passphrase, confirmation, useKeychain, netCfg) + state = MigrationToState.Migration(passphrase, confirmation, useKeychain, netCfg) } ) {} } @@ -466,7 +464,7 @@ private fun MutableState.MigrationConfirmationView(status: DBMig @Composable private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { Box { - SectionView(stringResource(MR.strings.migration_from_device_migrating).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_migrating).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -479,18 +477,18 @@ private fun ProgressView() { DefaultProgressView(null) } -private suspend fun MutableState.checkUserLink(link: String) { +private suspend fun MutableState.checkUserLink(link: String) { if (strHasSimplexFileLink(link.trim())) { val data = MigrationFileLinkData.readFromLink(link) val hasOnionConfigured = data?.networkConfig?.hasOnionConfigured() ?: false val networkConfig = data?.networkConfig?.transformToPlatformSupported() // If any of iOS or Android had onion enabled, show onion screen if (hasOnionConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { - state = MigrationState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) + state = MigrationToState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode) + MigrationToDeviceState.save(MigrationToDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) } else { val current = getNetCfg() - state = MigrationState.DatabaseInit(link.trim(), current.copy( + state = MigrationToState.DatabaseInit(link.trim(), current.copy( socksProxy = networkConfig?.socksProxy, hostMode = networkConfig?.hostMode ?: current.hostMode, requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode @@ -504,7 +502,7 @@ private suspend fun MutableState.checkUserLink(link: String) { } } -private fun MutableState.prepareDatabase( +private fun MutableState.prepareDatabase( link: String, tempDatabaseFile: File, netCfg: NetCfg, @@ -512,43 +510,59 @@ private fun MutableState.prepareDatabase( withLongRunningApi { val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, netCfg) if (ctrlAndUser == null) { - state = MigrationState.DownloadFailed(0, link, archivePath(), netCfg) + state = MigrationToState.DownloadFailed(0, link, archivePath(), netCfg) return@withLongRunningApi } val (ctrl, user) = ctrlAndUser - state = MigrationState.LinkDownloading(link, ctrl, user, archivePath(), netCfg) + state = MigrationToState.LinkDownloading(link, ctrl, user, archivePath(), netCfg) } } -private fun MutableState.startDownloading( +private fun MutableState.startDownloading( totalBytes: Long, ctrl: ChatCtrl, user: User, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, link: String, archivePath: String, netCfg: NetCfg, ) { withBGApi { - chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> + chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> when (msg) { is CR.RcvFileProgressXFTP -> { - state = MigrationState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.DownloadProgress(link, File(archivePath).name, netCfg)) + state = MigrationToState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl) + MigrationToDeviceState.save(MigrationToDeviceState.DownloadProgress(link, File(archivePath).name, netCfg)) } is CR.RcvStandaloneFileComplete -> { delay(500) - state = MigrationState.ArchiveImport(archivePath, netCfg) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.ArchiveImport(File(archivePath).name, netCfg)) + // User closed the whole screen before new state was saved + if (state == null) { + MigrationToDeviceState.save(null) + } else { + state = MigrationToState.ArchiveImport(archivePath, netCfg) + MigrationToDeviceState.save(MigrationToDeviceState.ArchiveImport(File(archivePath).name, netCfg)) + } } is CR.RcvFileError -> { AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.migration_from_device_download_failed), - generalGetString(MR.strings.migration_from_device_file_delete_or_link_invalid) + generalGetString(MR.strings.migrate_to_device_download_failed), + generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) - state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) + } + is CR.ChatRespError -> { + if (msg.chatError is ChatError.ChatErrorChat && msg.chatError.errorType is ChatErrorType.NoRcvFileUser) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migrate_to_device_download_failed), + generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) + ) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) + } else { + Log.d(TAG, "unsupported error: ${msg.responseType}") + } } else -> Log.d(TAG, "unsupported event: ${msg.responseType}") } @@ -557,16 +571,16 @@ private fun MutableState.startDownloading( val (res, error) = controller.downloadStandaloneFile(user, link, CryptoFile.plain(File(archivePath).path), ctrl) if (res == null) { - state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.migration_from_device_error_downloading_archive), + generalGetString(MR.strings.migrate_to_device_error_downloading_archive), error ) } } } -private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg) { +private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg) { withLongRunningApi { try { if (ChatController.ctrl == null || ChatController.ctrl == -1L) { @@ -582,14 +596,14 @@ private fun MutableState.importArchive(archivePath: String, netC generalGetString(MR.strings.non_fatal_errors_occured_during_import) ) } - state = MigrationState.Passphrase("", netCfg) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Passphrase(netCfg)) + state = MigrationToState.Passphrase("", netCfg) + MigrationToDeviceState.save(MigrationToDeviceState.Passphrase(netCfg)) } catch (e: Exception) { - state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + state = MigrationToState.ArchiveImportFailed(archivePath, netCfg) AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_importing_database), e.stackTraceToString()) } } catch (e: Exception) { - state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + state = MigrationToState.ArchiveImportFailed(archivePath, netCfg) AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_deleting_database), e.stackTraceToString()) } } @@ -630,30 +644,32 @@ private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) startChat(user) } hideView(close) - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migration_from_device_chat_migrated), generalGetString(MR.strings.migration_from_device_finalize_migration)) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_chat_migrated), generalGetString(MR.strings.migrate_to_device_finalize_migration)) } catch (e: Exception) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString()) } - MigrationFromAnotherDeviceState.save(null) + MigrationToDeviceState.save(null) } private fun hideView(close: () -> Unit) { appPreferences.onboardingStage.set(OnboardingStage.OnboardingComplete) + chatModel.migrationState.value = null close() } -private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) { +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) { val state = state - if (state is MigrationState.ArchiveImportFailed) { + if (state is MigrationToState.ArchiveImportFailed) { // Original database is not exist, nothing is set up correctly for showing to a user yet. Return to clean state deleteChatDatabaseFilesAndState() initChatControllerAndRunMigrations() - } else if (state is MigrationState.DownloadProgress && state.ctrl != null) { + } else if (state is MigrationToState.DownloadProgress && state.ctrl != null) { stopArchiveDownloading(state.fileId, state.ctrl) } chatReceiver?.stopAndCleanUp() getMigrationTempFilesDirectory().deleteRecursively() - MigrationFromAnotherDeviceState.save(null) + MigrationToDeviceState.save(null) + chatModel.migrationState.value = null } private fun strHasSimplexFileLink(text: String): Boolean = @@ -670,7 +686,7 @@ private fun archivePath(): String { return archivePath.absolutePath } -private class MigrationFromChatReceiver( +private class MigrationToChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 0fe756d8fb..b82852664b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -19,7 +19,8 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView +import chat.simplex.common.views.migration.MigrateToDeviceView +import chat.simplex.common.views.migration.MigrationToState import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @@ -71,7 +72,9 @@ fun SimpleXInfoLayout( .padding(top = DEFAULT_PADDING), contentAlignment = Alignment.Center ) { SimpleButtonDecorated(text = stringResource(MR.strings.migrate_from_another_device), icon = painterResource(MR.images.ic_download), - click = { ModalManager.fullscreen.showCustomModal { close -> MigrateFromAnotherDeviceView(chatModel.migrationState.value, close) } }) + click = { + chatModel.migrationState.value = MigrationToState.PasteOrScanLink + ModalManager.fullscreen.showCustomModal { close -> MigrateToDeviceView(close) } }) } } @@ -85,9 +88,8 @@ fun SimpleXInfoLayout( } } LaunchedEffect(Unit) { - val state = chatModel.migrationState.value - if (state != null && !ModalManager.fullscreen.hasModalsOpen()) { - ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateFromAnotherDeviceView(state, close) } + if (chatModel.migrationState.value != null && !ModalManager.fullscreen.hasModalsOpen()) { + ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateToDeviceView(close) } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index ffa908e210..3ef02f536a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -28,8 +28,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView -import chat.simplex.common.views.migration.MigrateToAnotherDeviceView +import chat.simplex.common.views.migration.MigrateFromDeviceView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.remote.ConnectDesktopView @@ -137,7 +136,7 @@ fun SettingsLayout( } else { SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) } - SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_to_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateToAnotherDeviceView(close) } }}, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } }}, disabled = stopped, extraPadding = true) } SectionDividerSpaced() 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 f3b7bf4a72..fca2adfafb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1846,63 +1846,66 @@ Please report it to the developers: \n%s Restart chat - - - Migrate here + + Migrate here Or paste archive link Paste archive link Invalid link - Migrating - Preparing download - Downloading link details - Downloading archive - %s downloaded - Download failed - Repeat download - You can give another try. - Importing archive - Import failed - Repeat import - Enter passphrase - File was deleted or link is invalid - Error downloading the archive - Chat migrated! - Finalize migration on another device. - Confirm network settings - Please confirm that network settings are correct for this device. - Apply + Migrating + Preparing download + Downloading link details + Downloading archive + %s downloaded + Download failed + Repeat download + You can give another try. + Importing archive + Import failed + Repeat import + Enter passphrase + File was deleted or link is invalid + Error downloading the archive + Chat migrated! + Finalize migration on another device. + Confirm network settings + Please confirm that network settings are correct for this device. + Apply - - Migrate to another device - Error saving settings - Exported file doesn\'t exist - Error exporting chat database - Preparing upload - Error uploading the archive - Error deleting database - Stopping chat - In order to continue, chat should be stopped. - Archive and upload - Confirm upload - All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Archiving database - %s uploaded - Uploading archive - Upload failed - Repeat upload - You can give another try. - Creating archive link - Cancel migration - Finalize migration - Migrate from another device on the new device and scan QR code.]]> - Or securely share this file link - Delete database from this device - Warning: starting chat on multiple devices is not supported and will cause message delivery failures - Start chat - Migration complete - must not use the same database on two devices.]]> - Please note: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]> - Verify database passphrase - Verify passphrase - Confirm that you remember database passphrase to migrate it. + + Migrate device + Migrate to another device + Error saving settings + Exported file doesn\'t exist + Error exporting chat database + Preparing upload + Error uploading the archive + Error deleting database + Stopping chat + In order to continue, chat should be stopped. + Archive and upload + Confirm upload + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + Archiving database + %s uploaded + Uploading archive + Upload failed + Repeat upload + You can give another try. + Creating archive link + Cancel migration + Finalize migration + Migrate from another device on the new device and scan QR code.]]> + Or securely share this file link + Delete database from this device + Warning: starting chat on multiple devices is not supported and will cause message delivery failures + Start chat + Migration complete + must not use the same database on two devices.]]> + Please note: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]> + Verify database passphrase + Verify passphrase + Confirm that you remember database passphrase to migrate it. + Check your internet connection and try again + Warning: the archive will be deleted.]]> + Error verifying passphrase: \ No newline at end of file From d53ef24bf1594068e6f9873088300a11e013b3d9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 12 Mar 2024 18:29:15 +0000 Subject: [PATCH 59/64] core: 5.6.0.2 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index ab703cc9cd..7d61b0314d 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.6.0.1 +version: 5.6.0.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index a3a78f851f..e4daba10f4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.6.0.1 +version: 5.6.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 835944ab24a778efcb718afb9f48c1bb37268dbe Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 12 Mar 2024 21:21:06 +0000 Subject: [PATCH 60/64] 5.6-beta.0: ios 202, android 189, desktop 33 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 64 +++++++++++----------- apps/multiplatform/gradle.properties | 8 +-- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index eb96807b6f..b921c7a09f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -57,11 +57,11 @@ 5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65F341297D3F3600B67AF3 /* VersionView.swift */; }; 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BA666289BD954009B8ECC /* DismissSheets.swift */; }; 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */; }; - 5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746D9F2B9F09AD0049D734 /* libffi.a */; }; - 5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA02B9F09AD0049D734 /* libgmpxx.a */; }; - 5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */; }; - 5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */; }; - 5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA32B9F09AD0049D734 /* libgmp.a */; }; + 5C746DB82BA0DA920049D734 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DB32BA0DA920049D734 /* libffi.a */; }; + 5C746DB92BA0DA920049D734 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DB42BA0DA920049D734 /* libgmpxx.a */; }; + 5C746DBA2BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DB52BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo.a */; }; + 5C746DBB2BA0DA920049D734 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DB62BA0DA920049D734 /* libgmp.a */; }; + 5C746DBC2BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DB72BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo-ghc9.6.3.a */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; @@ -324,11 +324,11 @@ 5C6D183229E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = "pl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C6D183329E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFeaturePreferenceView.swift; sourceTree = ""; }; - 5C746D9F2B9F09AD0049D734 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C746DA02B9F09AD0049D734 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a"; sourceTree = ""; }; - 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a"; sourceTree = ""; }; - 5C746DA32B9F09AD0049D734 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C746DB32BA0DA920049D734 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C746DB42BA0DA920049D734 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C746DB52BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo.a"; sourceTree = ""; }; + 5C746DB62BA0DA920049D734 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C746DB72BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo-ghc9.6.3.a"; sourceTree = ""; }; 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; @@ -520,13 +520,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */, - 5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */, + 5C746DB92BA0DA920049D734 /* libgmpxx.a in Frameworks */, + 5C746DBA2BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */, - 5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */, + 5C746DB82BA0DA920049D734 /* libffi.a in Frameworks */, + 5C746DBC2BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo-ghc9.6.3.a in Frameworks */, + 5C746DBB2BA0DA920049D734 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -589,11 +589,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C746D9F2B9F09AD0049D734 /* libffi.a */, - 5C746DA32B9F09AD0049D734 /* libgmp.a */, - 5C746DA02B9F09AD0049D734 /* libgmpxx.a */, - 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */, - 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */, + 5C746DB32BA0DA920049D734 /* libffi.a */, + 5C746DB62BA0DA920049D734 /* libgmp.a */, + 5C746DB42BA0DA920049D734 /* libgmpxx.a */, + 5C746DB72BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo-ghc9.6.3.a */, + 5C746DB52BA0DA920049D734 /* libHSsimplex-chat-5.6.0.2-CrEKCx0J5BfIirfPSOWUVo.a */, ); path = Libraries; sourceTree = ""; @@ -1529,7 +1529,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 201; + CURRENT_PROJECT_VERSION = 202; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1551,7 +1551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.6; + MARKETING_VERSION = 5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1572,7 +1572,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 201; + CURRENT_PROJECT_VERSION = 202; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1594,7 +1594,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5.6; + MARKETING_VERSION = 5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1653,7 +1653,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 201; + CURRENT_PROJECT_VERSION = 202; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1666,7 +1666,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.6; + MARKETING_VERSION = 5.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1685,7 +1685,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 201; + CURRENT_PROJECT_VERSION = 202; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1698,7 +1698,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5.6; + MARKETING_VERSION = 5.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1717,7 +1717,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 201; + CURRENT_PROJECT_VERSION = 202; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1741,7 +1741,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.6; + MARKETING_VERSION = 5.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1763,7 +1763,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 201; + CURRENT_PROJECT_VERSION = 202; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1787,7 +1787,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5.6; + MARKETING_VERSION = 5.6; 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 5a556d5f82..5d3863f10a 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.5.6 -android.version_code=187 +android.version_name=5.6-beta.0 +android.version_code=189 -desktop.version_name=5.5.6 -desktop.version_code=32 +desktop.version_name=5.6-beta.0 +desktop.version_code=33 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 240ca30f91efaf034558b106b738cd5c4f1f985a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 13 Mar 2024 13:57:17 +0400 Subject: [PATCH 61/64] core: remove withStoreCtx (#3903) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 86 +++++++++++++++++----------------- src/Simplex/Chat/Controller.hs | 8 +--- 4 files changed, 46 insertions(+), 52 deletions(-) diff --git a/cabal.project b/cabal.project index c4f2f102e8..24aace1739 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0aa4ae72286237d066c3ce2bff355638523c7095 + tag: 293a2ca3f10232fa8a5221388344acf68643ad92 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7672cde62d..0a12e6ac20 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."0aa4ae72286237d066c3ce2bff355638523c7095" = "1jcy5p8220w8ahi4fgil5rxlj83c9qy44s6mly9jh8n9a2bwdr4d"; + "https://github.com/simplex-chat/simplexmq.git"."293a2ca3f10232fa8a5221388344acf68643ad92" = "13khaadp6w66rn9pfifd779amcyj6lap2f2c0kns4ijjqqb2c5j5"; "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 83ca9adde6..3c74f58517 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -327,7 +327,7 @@ startChatController mainApp = do asks smpAgent >>= resumeAgentClient unless mainApp $ chatWriteVar subscriptionMode SMOnlyCreate - users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) + users <- fromRight [] <$> runExceptT (withStore' getUsers) restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) @@ -359,7 +359,7 @@ startChatController mainApp = do _ -> pure () startExpireCIs users = forM_ users $ \user -> do - ttl <- fromRight Nothing <$> runExceptT (withStoreCtx' (Just "startExpireCIs, getChatItemTTL") (`getChatItemTTL` user)) + ttl <- fromRight Nothing <$> runExceptT (withStore' (`getChatItemTTL` user)) forM_ ttl $ \_ -> do startExpireCIThread user setExpireCIFlag user True @@ -385,14 +385,14 @@ startFilesToReceive users = do startReceiveUserFiles :: ChatMonad m => User -> m () startReceiveUserFiles user = do - filesToReceive <- withStoreCtx' (Just "startReceiveUserFiles, getRcvFilesToReceive") (`getRcvFilesToReceive` user) + filesToReceive <- withStore' (`getRcvFilesToReceive` user) forM_ filesToReceive $ \ft -> flip catchChatError (toView . CRChatError (Just user)) $ toView =<< receiveFile' user ft Nothing Nothing restoreCalls :: ChatMonad' m => m () restoreCalls = do - savedCalls <- fromRight [] <$> runExceptT (withStoreCtx' (Just "restoreCalls, getCalls") $ \db -> getCalls db) + savedCalls <- fromRight [] <$> runExceptT (withStore' getCalls) let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls calls <- asks currentCalls atomically $ writeTVar calls callsMap @@ -493,13 +493,13 @@ processChatCommand' vr = \case \db -> overwriteProtocolServers db user servers coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 - ListUsers -> CRUsersList <$> withStoreCtx' (Just "ListUsers, getUsersInfo") getUsersInfo + ListUsers -> CRUsersList <$> withStore' getUsersInfo APISetActiveUser userId' viewPwd_ -> do unlessM chatStarted $ throwChatError CEChatNotStarted user_ <- chatReadVar currentUser user' <- privateGetUser userId' validateUserPassword_ user_ user' viewPwd_ - withStoreCtx' (Just "APISetActiveUser, setActiveUser") $ \db -> setActiveUser db userId' + withStore' (`setActiveUser` userId') let user'' = user' {activeUser = True} chatWriteVar currentUser $ Just user'' pure $ CRActiveUser user'' @@ -567,7 +567,7 @@ processChatCommand' vr = \case withAgent foregroundAgent chatWriteVar chatActivated True when restoreChat $ do - users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers + users <- withStore' getUsers void . forkIO $ subscribeUsers True users void . forkIO $ startFilesToReceive users setAllExpireCIFlags True @@ -578,7 +578,7 @@ processChatCommand' vr = \case stopRemoteCtrl withAgent (`suspendAgent` t) ok_ - ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers False >> ok_ + ResubscribeAllConnections -> withStore' getUsers >>= subscribeUsers False >> ok_ -- has to be called before StartChat SetTempFolder tf -> do createDirectoryIfMissing True tf @@ -1221,7 +1221,7 @@ processChatCommand' vr = \case SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ APIGetChatItemTTL userId -> withUserId' userId $ \user -> do - ttl <- withStoreCtx' (Just "APIGetChatItemTTL, getChatItemTTL") (`getChatItemTTL` user) + ttl <- withStore' (`getChatItemTTL` user) pure $ CRChatItemTTL user ttl GetChatItemTTL -> withUser' $ \User {userId} -> do processChatCommand $ APIGetChatItemTTL userId @@ -1491,7 +1491,7 @@ processChatCommand' vr = \case DeleteMyAddress -> withUser $ \User {userId} -> processChatCommand $ APIDeleteMyAddress userId APIShowMyAddress userId -> withUserId' userId $ \user -> - CRUserContactLink user <$> withStoreCtx (Just "APIShowMyAddress, getUserAddress") (`getUserAddress` user) + CRUserContactLink user <$> withStore (`getUserAddress` user) ShowMyAddress -> withUser' $ \User {userId} -> processChatCommand $ APIShowMyAddress userId APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do @@ -2606,7 +2606,7 @@ startExpireCIThread user@User {userId} = do expireFlags <- asks expireCIFlags atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry waitChatStartedAndActivated - ttl <- withStoreCtx' (Just "startExpireCIThread, getChatItemTTL") (`getChatItemTTL` user) + ttl <- withStore' (`getChatItemTTL` user) forM_ ttl $ \t -> expireChatItems user t False liftIO $ threadDelay' interval @@ -2763,11 +2763,11 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI dm <- encodeConnInfo $ XFileAcpt fName connIds <- joinAgentConnectionAsync user True connReq dm subMode filePath <- getRcvFilePath fileId filePath_ fName True - withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnJoined filePath subMode + withStore $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnJoined filePath subMode -- XFTP (Just XFTPRcvFile {}, _) -> do filePath <- getRcvFilePath fileId filePath_ fName False - (ci, rfd) <- withStoreCtx (Just "acceptFileReceive, xftpAcceptRcvFT ...") $ \db -> do + (ci, rfd) <- withStore $ \db -> do -- marking file as accepted and reading description in the same transaction -- to prevent race condition with appending description ci <- xftpAcceptRcvFT db vr user fileId filePath @@ -2777,13 +2777,13 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI pure ci -- group & direct file protocol _ -> do - chatRef <- withStoreCtx (Just "acceptFileReceive, getChatRefByFileId") $ \db -> getChatRefByFileId db user fileId + chatRef <- withStore $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of (ChatRef CTDirect contactId, Nothing) -> do - ct <- withStoreCtx (Just "acceptFileReceive, getContact") $ \db -> getContact db vr user contactId + ct <- withStore $ \db -> getContact db vr user contactId acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId, Just memId) -> do - GroupMember {activeConn} <- withStoreCtx (Just "acceptFileReceive, getGroupMember") $ \db -> getGroupMember db vr user groupId memId + GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId case activeConn of Just conn -> do acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMemberMessage conn msg groupId @@ -2798,7 +2798,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI if | inline -> do -- accepting inline - ci <- withStoreCtx (Just "acceptFile, acceptRcvInlineFT") $ \db -> acceptRcvInlineFT db vr user fileId filePath + ci <- withStore $ \db -> acceptRcvInlineFT db vr user fileId filePath sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId send $ XFileAcptInv sharedMsgId Nothing fName pure ci @@ -2807,7 +2807,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI -- accepting via a new connection subMode <- chatReadVar subscriptionMode connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation subMode - withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnNew filePath subMode + withStore $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnNew filePath subMode receiveInline :: m Bool receiveInline = do ChatConfig {fileChunkSize, inlineFiles = InlineFilesConfig {receiveChunks, offerChunks}} <- asks config @@ -2824,7 +2824,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} rd <- parseFileDescription fileDescrText aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs startReceivingFile user fileId - withStoreCtx' (Just "receiveViaCompleteFD, updateRcvFileAgentId") $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) + withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) receiveViaURI :: ChatMonad m => User -> FileDescriptionURI -> CryptoFile -> m RcvFileTransfer receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile {cryptoArgs} = do @@ -2842,7 +2842,7 @@ receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile startReceivingFile :: ChatMonad m => User -> FileTransferId -> m () startReceivingFile user fileId = do vr <- chatVersionRange - ci <- withStoreCtx (Just "startReceivingFile, updateRcvFileStatus ...") $ \db -> do + ci <- withStore $ \db -> do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 getChatItemByFileId db vr user fileId @@ -2981,7 +2981,7 @@ agentSubscriber = do type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ())) subscribeUserConnections :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> Bool -> AgentBatchSubscribe m -> User -> m () -subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = do +subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do -- get user connections ce <- asks $ subscriptionEvents . config (conns, cts, ucs, gs, ms, sfts, rfts, pcs) <- @@ -3036,32 +3036,32 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = } getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do - cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") (`getUserContacts` vr) + cts <- withStore_ (`getUserContacts` vr) let cts' = mapMaybe (\ct -> (,ct) <$> contactConnId ct) $ filter contactActive cts pure (map fst cts', M.fromList cts') getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do - (cs, ucs) <- unzip <$> withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContactLinks") (`getUserContactLinks` vr) + (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) getGroupMemberConns = do - gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") (`getUserGroups` vr) + gs <- withStore_ (`getUserGroups` vr) let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs pure (gs, map fst mPairs, M.fromList mPairs) getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do - sfts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getLiveSndFileTransfers") getLiveSndFileTransfers + sfts <- withStore_ getLiveSndFileTransfers let connIds = map sndFileTransferConnId sfts pure (connIds, M.fromList $ zip connIds sfts) getRcvFileTransferConns :: m ([ConnId], Map ConnId RcvFileTransfer) getRcvFileTransferConns = do - rfts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getLiveRcvFileTransfers") getLiveRcvFileTransfers + rfts <- withStore_ getLiveRcvFileTransfers let rftPairs = mapMaybe (\ft -> (,ft) <$> liveRcvFileTransferConnId ft) rfts pure (map fst rftPairs, M.fromList rftPairs) getPendingContactConns :: m ([ConnId], Map ConnId PendingContactConnection) getPendingContactConns = do - pcs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getPendingContactConnections") getPendingContactConnections + pcs <- withStore_ getPendingContactConnections let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m () @@ -3131,8 +3131,8 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = rcvFileSubsToView rs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . filterErrors . resultsFor rs pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> m () pendingConnSubsToView rs = toView . CRPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs - withStore_ :: String -> (DB.Connection -> User -> IO [a]) -> m [a] - withStore_ ctx a = withStoreCtx' (Just ctx) (`a` user) `catchChatError` \e -> toView (CRChatError (Just user) e) $> [] + withStore_ :: (DB.Connection -> User -> IO [a]) -> m [a] + withStore_ a = withStore' (`a` user) `catchChatError` \e -> toView (CRChatError (Just user) e) $> [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -3156,7 +3156,7 @@ cleanupManager = do forever $ do flip catchChatError (toView . CRChatError Nothing) $ do waitChatStartedAndActivated - users <- withStoreCtx' (Just "cleanupManager, getUsers 1") getUsers + users <- withStore' getUsers let (us, us') = partition activeUser users forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay @@ -3166,7 +3166,7 @@ cleanupManager = do where runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CRChatError Nothing) $ do waitChatStartedAndActivated - users <- withStoreCtx' (Just "cleanupManager, getUsers 2") getUsers + users <- withStore' getUsers let (us, us') = partition activeUser users forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` (toView . CRChatError (Just u)) @@ -3178,7 +3178,7 @@ cleanupManager = do cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts - timedItems <- withStoreCtx' (Just "cleanupManager, getTimedItems") $ \db -> getTimedItems db user startTimedThreadCutoff + timedItems <- withStore' $ \db -> getTimedItems db user startTimedThreadCutoff forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchChatError` const (pure ()) cleanupDeletedContacts user = do vr <- chatVersionRange @@ -3189,7 +3189,7 @@ cleanupManager = do cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts - withStoreCtx' (Just "cleanupManager, deleteOldMessages") (`deleteOldMessages` cutoffTs) + withStore' (`deleteOldMessages` cutoffTs) cleanupProbes = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(14 * nominalDay)) ts @@ -3248,10 +3248,10 @@ expireChatItems user@User {userId} ttl sync = do -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs waitChatStartedAndActivated - contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") $ \db -> getUserContacts db vr user + contacts <- withStore' $ \db -> getUserContacts db vr user loop contacts $ processContact expirationDate waitChatStartedAndActivated - groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") $ \db -> getUserGroupDetails db vr user Nothing Nothing + groups <- withStore' $ \db -> getUserGroupDetails db vr user Nothing Nothing loop groups $ processGroup vr expirationDate createdAtCutoff where loop :: [a] -> (a -> m ()) -> m () @@ -3270,19 +3270,19 @@ expireChatItems user@User {userId} ttl sync = do processContact :: UTCTime -> Contact -> m () processContact expirationDate ct = do waitChatStartedAndActivated - filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate + filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo - withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate + withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate processGroup :: (PQSupport -> VersionRangeChat) -> UTCTime -> UTCTime -> GroupInfo -> m () processGroup vr expirationDate createdAtCutoff gInfo = do waitChatStartedAndActivated - filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff + filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo - withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db vr user gInfo - forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m + withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessage _ connId (DEL_RCVQ srv qId err_) = @@ -6420,13 +6420,13 @@ mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs for deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse deleteDirectCI user ct ci@ChatItem {file} byUser timed = do deleteCIFile user file - withStoreCtx' (Just "deleteDirectCI, deleteDirectChatItem") $ \db -> deleteDirectChatItem db user ct ci + withStore' $ \db -> deleteDirectChatItem db user ct ci pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDirection (DirectChat ct) ci) Nothing byUser timed deleteGroupCI :: (ChatMonad m, MsgDirectionI d) => User -> GroupInfo -> ChatItem 'CTGroup d -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedTs = do deleteCIFile user file - toCi <- withStoreCtx' (Just "deleteGroupCI, deleteGroupChatItem ...") $ \db -> + toCi <- withStore' $ \db -> case byGroupMember_ of Nothing -> deleteGroupChatItem db user gInfo ci $> Nothing Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 62caa8a6ab..4ca9da094f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1292,13 +1292,7 @@ withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a withStore' action = withStore $ liftIO . action withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a -withStore = withStoreCtx Nothing - -withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a -withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action - -withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a -withStoreCtx _ctx action = do +withStore action = do ChatController {chatStore} <- ask liftIOEither $ withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors From f3eeb9dcc2fa71e130cf2181333a349240dfdfb8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 14 Mar 2024 10:59:20 +0400 Subject: [PATCH 62/64] core: temp-folder option (#3905) --- apps/simplex-broadcast-bot/src/Broadcast/Options.hs | 1 + apps/simplex-directory-service/src/Directory/Options.hs | 1 + src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Mobile.hs | 1 + src/Simplex/Chat/Options.hs | 9 +++++++++ tests/ChatClient.hs | 1 + 6 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index bce0f94972..57986874aa 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -80,6 +80,7 @@ mkChatOpts BroadcastBotOpts {coreOptions} = chatCmdLog = CCLNone, chatServerPort = Nothing, optFilesFolder = Nothing, + optTempDirectory = Nothing, showReactions = False, allowInstantFiles = True, autoAcceptFileSize = 0, diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 78157d7e11..0d64064d7d 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -80,6 +80,7 @@ mkChatOpts DirectoryOpts {coreOptions} = chatCmdLog = CCLNone, chatServerPort = Nothing, optFilesFolder = Nothing, + optTempDirectory = Nothing, showReactions = False, allowInstantFiles = True, autoAcceptFileSize = 0, diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3c74f58517..708f5d4353 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -211,7 +211,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, deviceNameForRemote} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, optTempDirectory, 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} @@ -245,7 +245,7 @@ newChatController chatActivated <- newTVarIO True showLiveItems <- newTVarIO False encryptLocalFiles <- newTVarIO False - tempDirectory <- newTVarIO Nothing + tempDirectory <- newTVarIO optTempDirectory contactMergeEnabled <- newTVarIO True pqExperimentalEnabled <- newTVarIO PQSupportOff pure diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 105dedb32c..5883c6042c 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -207,6 +207,7 @@ mobileChatOpts dbFilePrefix = chatCmdLog = CCLNone, chatServerPort = Nothing, optFilesFolder = Nothing, + optTempDirectory = Nothing, showReactions = False, allowInstantFiles = True, autoAcceptFileSize = 0, diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index a222e2e77b..871e3358ec 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -41,6 +41,7 @@ data ChatOpts = ChatOpts chatCmdLog :: ChatCmdLog, chatServerPort :: Maybe String, optFilesFolder :: Maybe FilePath, + optTempDirectory :: Maybe FilePath, showReactions :: Bool, allowInstantFiles :: Bool, autoAcceptFileSize :: Integer, @@ -258,6 +259,13 @@ chatOptsP appDir defaultDbFileName = do <> metavar "FOLDER" <> help "Folder to use for sent and received files" ) + optTempDirectory <- + optional $ + strOption + ( long "temp-folder" + <> metavar "FOLDER" + <> help "Folder for temporary encrypted files (default: system temp directory)" + ) showReactions <- switch ( long "reactions" @@ -304,6 +312,7 @@ chatOptsP appDir defaultDbFileName = do chatCmdLog, chatServerPort, optFilesFolder, + optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 792f9642d3..1abf59952a 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -74,6 +74,7 @@ testOpts = chatCmdLog = CCLNone, chatServerPort = Nothing, optFilesFolder = Nothing, + optTempDirectory = Nothing, showReactions = True, allowInstantFiles = True, autoAcceptFileSize = 0, From bac4e61997147eccc9fa6057e419377746d472e5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 14 Mar 2024 10:41:24 +0000 Subject: [PATCH 63/64] blog: quantum resistant Signal algorithm (#3906) * blog: quantum resistant Signal algorithm * update, images * finalize * readme * corrections --- README.md | 2 + ...istance-signal-double-ratchet-algorithm.md | 260 ++++++++++++++++++ blog/README.md | 16 ++ blog/images/20240314-comparison.jpg | Bin 0 -> 139267 bytes blog/images/20240314-datacenter.jpg | Bin 0 -> 50203 bytes blog/images/20240314-djb.jpg | Bin 0 -> 16621 bytes blog/images/20240314-kem.jpg | Bin 0 -> 100297 bytes blog/images/20240314-mitm1.jpg | Bin 0 -> 44907 bytes blog/images/20240314-mitm2.jpg | Bin 0 -> 46509 bytes blog/images/20240314-mitm3.jpg | Bin 0 -> 50600 bytes blog/images/20240314-mitm4.jpg | Bin 0 -> 51677 bytes blog/images/20240314-pq1.png | Bin 0 -> 246566 bytes blog/images/20240314-pq2.png | Bin 0 -> 373501 bytes blog/images/20240314-pq3.png | Bin 0 -> 264079 bytes .../src/_includes/blog_previews/20240314.html | 12 + website/src/css/blog.css | 5 + 16 files changed, 295 insertions(+) create mode 100644 blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md create mode 100644 blog/images/20240314-comparison.jpg create mode 100644 blog/images/20240314-datacenter.jpg create mode 100644 blog/images/20240314-djb.jpg create mode 100644 blog/images/20240314-kem.jpg create mode 100644 blog/images/20240314-mitm1.jpg create mode 100644 blog/images/20240314-mitm2.jpg create mode 100644 blog/images/20240314-mitm3.jpg create mode 100644 blog/images/20240314-mitm4.jpg create mode 100644 blog/images/20240314-pq1.png create mode 100644 blog/images/20240314-pq2.png create mode 100644 blog/images/20240314-pq3.png create mode 100644 website/src/_includes/blog_previews/20240314.html diff --git a/README.md b/README.md index f6630ebbfd..4cd7b2b787 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,8 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) + [Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md) [Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md). diff --git a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md new file mode 100644 index 0000000000..ea4249f250 --- /dev/null +++ b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md @@ -0,0 +1,260 @@ +--- +layout: layouts/article.html +title: "SimpleX Chat v5.6 (beta): adding quantum resistance to Signal double ratchet algorithm" +date: 2024-03-14 +previewBody: blog_previews/20240314.html +image: images/20240314-kem.jpg +imageWide: true +permalink: "/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.html" +--- + +# SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm + +This is a major upgrade for SimpleX messaging protocols, we are really proud to present the results of the hard work of our whole team on the [Pi day](https://en.wikipedia.org/wiki/Pi_Day). + +This post also covers various aspects of end-to-end encryption, compares different messengers, and explains why and how quantum-resistant encryption is added to SimpleX Chat: + +- [Why do we need end-to-end encryption?](#why-do-we-need-end-to-end-encryption) +- [Why encryption is even allowed?](#why-encryption-is-even-allowed) +- [End-to-end encryption security: attacks and defense.](#end-to-end-encryption-security-attacks-and-defense) + - Compromised message size - mitigated by padding messages to a fixed block size. + - Compromised confidentiality - mitigated by repudiation (deniability). + - Compromised message keys - mitigated by forward secrecy. + - Compromised long-term or session - mitigated by break-in recovery. + - Man-in-the-middle attack - mitigated by two-factor key exchange. + - "Record now, decrypt later" attacks - mitigated by post-quantum cryptography. +- [How secure is encryption in different messengers?](#how-secure-is-end-to-end-encryption-in-different-messengers) +- [Adding quantum resistance to Signal double ratchet algorithm.](#adding-quantum-resistance-to-signal-double-ratchet-algorithm) +- [When can you start using quantum resistant chats?](#when-can-you-start-using-quantum-resistant-chats) +- [Next for post-quantum crypto - all direct chats, small groups and security audit.](#next-for-post-quantum-crypto---all-direct-chats-small-groups-and-security-audit) + +## Why do we need end-to-end encryption? + +The objective of end-to-end encryption is to make any potential attackers, such as traffic observers or communication providers who pass the messages between senders and recipients, unable to recover *any* message content or meaningful information about the messages, even if these attackers possess very advanced computing and mathematical capabilities. + +While human eyes are unable to see any difference between simply scrambled and encrypted messages, the difference between unreadable scrambling and unbreakable encryption can be as huge as just a few seconds to unscramble a message on an average laptop and more time than the Universe existed required to break the encryption on the most powerful computer in the world. + +Achieving the latter requires a lot of mathematical precision in both the cryptographic algorithms and in how they are used, and effectively makes encrypted messages indistinguishable from random noise, without any discoverable patterns or statistical irregularities that a computer could use to break the message encryption any faster than it it would take to try every possible combination of bits in the key. + +End-to-end encryption is an important component of our individual and business security, privacy and sovereignty. Having our private communications protected from any observers is both the natural condition and our inalienable human right. + +It's very sad to see the same people who keep their financial affairs private to protect from financial crimes, lock their doors to protect from thieves, and curtain their windows to protect from the occasional prying eyes, when it comes to protecting their personal lives from the data criminals say "we don't care about privacy, we have nothing to hide". Everybody's safety depends on keeping their affairs and relations private, not visible to a vast and ruthless data gathering machines, that abuse our data for commercial gain, without any regard to our interests or even [the safety of our families and children](https://nmdoj.gov/press-release/attorney-general-raul-torrez-files-lawsuit-against-meta-platforms-and-mark-zuckerberg-to-protect-children-from-sexual-abuse-and-human-trafficking/). + +## Why encryption is even allowed? + + + +If encryption is such a powerful tool to protect our lives, it also can be used to conceal crimes, so why the governments don't consider it similar to arms, and don't heavily regulate its use? + +Prior to 1996 the cryptography was considered munition, and its export from the United States was controlled under this category, [alongside flamethrowers and B-1 bombers](https://cr.yp.to/export/1995/0303-eff.txt). When [Daniel J. Bernstein](https://en.wikipedia.org/wiki/Daniel_J._Bernstein) (DJB), then a student of Mathematics at University of California, Berkeley, wanted to publish the paper and the source code of his Snuffle encryption system, the Office of Defense Trade Controls of the Department of State (DOS) after more than a year of correspondence requested that DJB registers as the arms dealer. + +In 1995 DJB represented by the Electronic Frontier Foundation brought a case against the DOS to overturn cryptography restrictions. The ruling in the case declared that the export control over cryptographic software and related technical data constitute [an impermissible infringement on speech in violation of the First Amendment](https://cr.yp.to/export/1996/1206-order.txt). This decision resulted in regulatory changes, reducing controls on encryption exports, particularly for open-source algorithms. The case continued until 2003, when it was put on hold after the commitment from the US government not to enforce any remaining regulations. + +This case is very important for the whole industry, as to this day we can freely create and use open-source cryptography without export control restrictions. It also shows the importance of engaging with the system and challenging its views in an open dialogue, rather than either blindly complying or violating regulations. + +DJB role for cryptography and open-source goes beyond this case – many cryptographic algorithms that are considered to be the most advanced, and many of which we use in SimpleX Chat, were designed and developed by him: + +- Ed25519 cryptographic signature algorithm we use to authorize commands to the servers. +- NaCL library with cryptobox and secretbox constructions that combine X25519 Diffie-Hellman key agreement with Salsa20 encryption and Poly1305 authentication. We use cryptobox to encrypt messages in two of three encryption layers and secretbox to encrypt files. +- Streamlined NTRU Prime algorithm for quantum resistant key agreement that we used in the protocol for linking mobile app with desktop, and now added to Signal double ratchet algorithm, as explained below. + +Without DJB's work the world would have been in a much worse place privacy- and security-wise. + +Daniel, we are really grateful for the work you did and continue doing. Thank you, and congratulations on the International Mathematics Day! + +## End-to-end encryption security: attacks and defense + +End-to-end encryption is offered by many messaging apps and protocols, but the security of different implementations are not the same. While many users know about the importance of forward secrecy - the quality of end-to-end encryption that preserves security of the encryption of the past messages, even if the keys used to encrypt some of the messages were compromised - there are many other qualities that protect from different attacks. Below there is the overview of these attacks and the properties of end-to-end encryption schemes that mitigate these attacks. + +### 1. Compromised message size - mitigated by padding messages to a fixed block size + +While the content encryption is the most important, concealing the actual message size is almost as important for several reasons: + +- attacker able to observe even approximate message sizes can use these sizes as an additional signal for machine learning to de-anonymise the users and to categorize the relationships between the users. +- if a messenger conceals the routing of the messages to hide the transport identities (IP addresses) of senders and recipients, message sizes can be used by traffic observers to confirm the fact of communication with a much higher degree of certainty. + +The only effective mitigation to these attacks is to pad all messages to a fixed size. Using space-efficient schemes like Padme, or padding to encryption block size is ineffective for mitigating these attacks, as they still allow differentiating message sizes. + +To the best of our knowledge the only messenger other than SimpleX Chat that padded all messages to a fixed packet size was [Pond](https://github.com/agl/pond) - SimpleX design as an evolution of it. + +### 2. Compromised confidential messages - mitigated by repudiation (deniability) + +Many users are very interested in having ability to irreversibly delete sent messages from the recipients devices. But not only would this ability violate data sovereignty of device owners, it is also completely ineffective, as the recipients could simply put the device offline or use a modified client app to ignore message deletion requests. While SimpleX Chat provides such features as [disappearing messages](./20230103-simplex-chat-v4.4-disappearing-messages.md#disappearing-messages) and the ability to [irreversibly delete sent messages](./20221206-simplex-chat-v4.3-voice-messages.md#irreversible-message-deletion) provided both parties agree to that, these are convenience features, and they cannot be considered security measures. + +The solution to that is well known to cryptographers - it is the quality of the encryption algorithms called "repudiation", sometimes also called "deniability". This is the ability of the senders to plausibly deny having sent any messages, because cryptographic algorithms used to encrypt allow recipients forging these messages on their devices, so while the encryption proves authenticity of the message to the recipient, it cannot be used as a proof to any third party. + +Putting it all in a simpler language - a sender can claim that the recipient forged messages on their device, and deny ever having sent them. The recipient will not be able to provide any cryptographic proof. This quality makes digital conversation having the same qualities as private off-the-record conversation - that's why the family of algorithms that provide these qualities are called off-the-record (OTR) encryption. + +Repudiation is still a rather new concept - the first off-the-record algorithms were proposed in 2004 and were only offered to a wide range of users in Signal messenger. This concept is still quite badly understood by users and society, and yet to have been used as the defense in any public court cases, as legal systems evolve much slower than technology. In high profile cases repudiation can be used as an effective evidence for the defense. + +Repudiation in messaging systems can be undermined by adding cryptographic signature to the protocol, and many messengers that use OTR encryption algorithms do exactly that, unfortunately. SimpleX Chat does not use signature in any part of client-client protocol, but the signature is currently used when authorizing sender's messages to the relays. v5.7 will improve deniability by enabling a different authorization scheme that will provide full-stack repudiation in all protocol layers. + +### 3. Compromised message keys - mitigated by forward secrecy + +The attacker who obtained or broke the keys used to encrypt individual messages, may try to use these keys to decrypt past or future messages. This attack is unlikely to succeed via message interception, and it is likely to require breaking into the device storage. But in any case, if the key was broken or obtained in some other way it's important that this key cannot be used to decrypt other messages - this is achieved by forward secrecy. + +This property is well understood by the users, and most messengers that focus on privacy and security, with the exception of Session, provide forward secrecy as part of their encryption schemes design. + +### 4. Compromised long-term or session - mitigated by break-in recovery + +This attack is much less understood by the users, and forward secrecy does not protect from it. Arguably, it's almost impossible to compromise individual message keys without compromising long-term or session keys. So the ability of the encryption to recover from break-in (attacker making a copy of the device data without retaining the ongoing access) is both very and pragmatic - break-in attacks are simpler to execute on mobile devices during short-term device access than long-term ongoing compromise. + +Out of all encryption algorithms known to us only Signal double ratchet algorithm provides the ability to encryption security after break-ins. This recovery happens automatically and transparently to the users, without them doing anything special even knowing about break-in, by simply sending messages. Every time one of the communication parties replies to another party message, new random keys are generated and previously stolen keys become useless. + +Signal double ratchet algorithm is used in Signal, Cwtch and SimpleX Chat. This is why you cannot use SimpleX Chat profile on more than one device at the same time - the encryption scheme rotates the long term keys, randomly, and keys on another device become useless, as they would become useless for the attacker who stole them. Security always has some costs to the convenience. + +### 5. Man-in-the-middle attack - mitigated by two-factor key exchange + +Many people incorrectly believe that security of end-to-end encryption cannot be broken by communication provider. But end-to-end encryption is as secure as key exchange. While any intermediary passing the keys between senders and recipients cannot recover the private keys from the public keys, they can simply replace the passed public keys with their own and then proxy all communication between the users having full access to the original messages. So instead of having an end-to-end encrypted channel, users would have two half-way encrypted channels - between users and their communication intermediary. + +Pictures below illustrate how this attack works for RSA encryption. + +#### 1) Alice sends the key to Bob (e.g. via p2p network or via the messaging server). + +![Public key is shared](./images/20240314-mitm1.jpg) + +#### 2) Now Bob can send encrypted messages to Alice - he believes they are secure! + +![Message is encrypted](./images/20240314-mitm2.jpg) + +#### 3) But the key could have been intercepted and substituted by Tom (the attacker, or a service provider). + +![Key is intercepted and replaced](./images/20240314-mitm3.jpg) + +#### 4) Now the attacker can read the messages without Alice and Bob knowing. + +![End-to-end encryption is compromised](./images/20240314-mitm4.jpg) + +The attack on Diffie-Hellman (or on quantum-resistant) key exchange, when both parties send their public keys (or public key and ciphertext), requires the attacker to intercept and replace both keys, but the outcome remains the same - if all communication is passed via a single channel, as it is usually the case with communication services, then any attacker that has inside access to the service can selectively compromise some of the conversations. Two years ago I wrote the post about this [vulnerability of end-to-end encryption to MITM attacks](https://www.poberezkin.com/posts/2022-12-07-why-privacy-needs-to-be-redefined.html#e2e-encryption-is-not-bulletproof). + +All known mitigations of this attack require using the secondary communication channel to ensure that the keys have not been substituted. The most secure approach is to make user's key (or key fingerprint) a part of the user's address or connection link, thus making two-factor key exchange non-optional. This approach is used in Session, Cwtch and SimpleX Chat. + +A less secure approach is to provide users an optional way to compare security codes - this is what is done by Signal, Element and many other messengers. The problem with this post-key-exchange verification is that it is optional, and is usually skipped by the majority of the users. Also, this security code can change because the user changed the device, or as a result of the attack via the service provider. When you see in the client app the notification that the security code changed, it's pointless to ask in the same messenger whether the device was changed, as if it were an attack, the attacker would simply confirm it. Instead, the security code needs to be re-validated again via another channel. A good security practice for the users would be to warn their communication partners about the intention to switch the device in advance, before the security code is changed. + +### 6. "Record now, decrypt later" attacks - mitigated by post-quantum cryptography. + +This is the idea based on the assumption that commercially viable quantum computers will become available during the next 10 years, and then they can use time-efficient [Shor's algorithm](https://en.wikipedia.org/wiki/Shor%27s_algorithm) developed in 1994 to break asymmetric encryption with quantum computer (symmetric encryption is not vulnerable to this algorithm). + +Post-quantum cryptography, or encryption algorithms that are resistant to quantum computers, has been the area of ongoing research for several decades, and there are some algorithms that _might_ protect from quantum computers. It's important to account for these limitations: + +- _none of the post-quantum algorithms are proven to be secure_ against quantum or conventional computers. They are usually referred to as "believed to be secure" by the researchers and security experts. There is continuous research to break post-quantum algorithms, and to prove their security, and many of these algorithms are broken every year, often by conventional computers. +- because of the lack of proofs or guarantees that post-quantum cryptography delivers on its promise, these algorithms can only be used in hybrid encryption schemes to augment conventional cryptography, and never to replace it, contrary to some expert recommendations, as DJB explains in this [blog post](https://blog.cr.yp.to/20240102-hybrid.html). +- they are much more computationally expensive and less space efficient, and the encryption schemes have to balance their usability and security. +- many of post-quantum algorithms have known patent claims, so any system deploying them accepts the risks of patent litigation. +- the silver lining to these limitations is that the risk of appearance of commercially viable quantum computers in the next decade may be exaggerated. + +So, to put it bluntly and provocatively, post-quantum cryptography can be compared with a remedy against the illness that nobody has, without any guarantee that it will work. The closest analogy in the history of medicine is _snake oil_. + + + +Does it mean that post-quantum cryptography is useless and should be ignored? Absolutely not. The risks of "record now, decrypt later" attacks are real, particularly for high profile targets, including millions of people - journalists, whistle-blowers, freedom-fighters in oppressive regimes, and even some ordinary people who may become targets of information crimes. Large scale collection of encrypted communication data is ongoing, and this data may be used in the future. So having the solution that _may_ protect you (post-quantum cryptography), as long as it doesn't replace the solution that is _proven_ to protect you (conventional cryptography), is highly beneficial in any communication solution, and has already been deployed in many tools and in some messengers. + +## How secure is end-to-end encryption in different messengers? + +This comparison may be incorrect in some of the columns. We apologize if some of the points are incorrect, please let us know about any mistakes so we can amend them! + +The main objective here is to establish the framework for comparing the security of end-to-end encryption schemes, and to highlight any areas for improvement, not to criticize any implementations. + +![Messengers comparison](./images/20240314-comparison.jpg) + +1 Repudiation in SimpleX Chat will include client-server protocol from v5.7 or v5.8. Currently it is implemented but not enabled yet, as its support requires releasing the relay protocol that breaks backward compatibility. + +2 Post-quantum cryptography is available in beta version, as opt-in only for direct conversations. See below how it will be rolled-out further. + +Some columns are marked with a yellow checkmark: +- when messages are padded, but not to a fixed size. +- when repudiation does not include client-server connection. In case of Cwtch it appears that the presence of cryptographic signatures compromises repudiation (deniability), but it needs to be clarified. +- when 2-factor key exchange is optional, via security code verification. +- when post-quantum cryptography is only added to the initial key agreement, does not protect break-in recovery. + +## Adding quantum resistance to Signal double ratchet algorithm + +We have been exploring post-quantum cryptography since early 2022, when SimpleX Chat was first released, and we did not want to be pioneers here - cryptography is critically important to make it right. + +We hoped to adopt the algorithm that will be standardized by NIST, but the standardization process turned out to be hugely disappointing, and the ML-KEM (Kyber) algorithm that was accepted as a standard was modified to remove an important hashing step (see the lines 304-314 in [the published spec](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.203.ipd.pdf))), that mitigates the attacks via a compromised random numbers generator, ignoring strong criticism from many expert cryptographers, including DJB (see [this discussion](https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/WFRDl8DqYQ4) and [the comments NIST received](https://csrc.nist.gov/files/pubs/fips/203/ipd/docs/fips-203-initial-public-comments-2023.pdf)). To make it even worse, the calculation of security levels of Kyber appears to have been done incorrectly, and overall, the chosen Kyber seems worse than rejected NTRU according to [the analysis by DJB](https://blog.cr.yp.to/20231003-countcorrectly.html). + +We also analyzed the encryption schemes proposed in Tutanota in 2021, and another scheme adopted by Signal last year, and published the design of [quantum resistant double ratchet algorithm](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2023-09-30-pq-double-ratchet.md) that we believe provides better security than these schemes: + +- unlike Tutanota design, it augments rather than replaces conventional cryptography, and also avoids using signatures when the new keys are agreed (ratchet steps). +- unlike other messengers that adopted or plan to adopt ML-KEM, we used Streamlined NTRU Prime algorithm (specifically, strnup761) that has no problems of ML-KEM, no known patent claims, and seems less likely to be compromised than other algorithms - it is exactly the same algorithm that is used in SSH. You can review the comparison of [the risks of various post-quantum algorithms](https://ntruprime.cr.yp.to/warnings.html). +- unlike Signal design that only added quantum resistance to the initial key exchange by replacing X3DH key agreement scheme with post-quantum [PQXDH](https://signal.org/docs/specifications/pqxdh/), but did not improve Signal algorithm itself, our design added quantum-resistant key agreements inside double algorithm, making its break-in recovery property also quantum resistant. + +The we could make break-in recovery property of Signal algorithm quantum-resistant, and why, probably, Signal didn't, is because irrespective of the message size SimpleX Chat uses a fixed block size of 16kb to provide security and privacy against any traffic observers and against messaging relays. So we had an extra space to accommodate additional ~2.2kb worth of keys in each message without any additional traffic costs. + +In case when the message is larger than the remaining block size, e.g. when the message contains image or link preview, or a large text, we used [zstd compression](https://en.wikipedia.org/wiki/Zstd) to provide additional space for the required keys without reducing image preview quality or creating additional traffic - our previously inefficient JSON encoding of chat messages was helpful in this case. + +Double KEM agreement + +The additional challenge in adding sntrup761 was that unlike Diffie-Hellman key exchange, which is symmetric (that is, the parties can share their public keys in any order and the shared secret can be computed from two public keys), sntrup761 is interactive key-encapsulation mechanism (KEM) that requires that one party shares its public key, and another party uses it to encapsulate (which is a fancy term for "encrypt" - that is why it has asterisks in the image) a random shared secret, and sends it back - making it somewhat similar to RSA cryptography. But this asymmetric design does not fit the symmetric operation of Signal double ratchet algorithm, where both sides need to generate random public keys and to compute new shared secrets every time messaging direction changes for them. So to achieve that symmetry we had to use two KEM key agreements running in parallel, in a lock-step fashion, as shown on the diagram. In this case both parties generate random public keys and also use the public key of another party to encapsulate the random shared secret. Effectively, this design adds a double quantum-resistant key agreement to double ratchet algorithm steps that provide break-in recovery. + +## When can you start using quantum resistant chats? + + + +Quantum resistant double ratchet algorithm is already available in v5.6 (beta) of SimpleX Chat as an optional feature that can be enabled for the new and, separately, for the existing direct conversations. + +The reason it is released as opt-in is because once the conversation is upgraded to be quantum resistant, it will no longer work in the previous version of the app, and we see this ability to downgrade the app if something is not working correctly as very important for the users who use the app for critical communications. + +**To enable quantum resistance for the new conversations**: +- open the app settings (tap user avatar in the top left corner). +- scroll down to _Developer tools_ and open them. +- enable _Show developer options_ toggle. +- now you will see _Post-quantum E2EE_ toggle - enable it as well. + +Now all new contacts you add to the app will use quantum resistant Signal double ratchet algorithm. + +Once you have enabled it for the new contacts, you can also **enable it for some of the existing contacts**: +- open the chat with the contact you want to upgrade to be quantum resistant. +- tap contact name above the chat. +- tap Allow PQ encryption. +- exchange several messages back and forth with that contact - the quantum resistant double ratchet will kick in after 3-5 messages (depending on how many messages you send in each direction), and you will see the notice in the chat once it enables. + +## Next for post-quantum crypto - all direct chats, small groups and security audit + +We will be making quantum resistance default for all direct chats in v5.7, and they will be upgraded for all users without any action. + +We will also be adding quantum resistance to small groups up to 10-20 members. Computing cryptographic keys is much slower, in comparison, and it would be very inefficient (and completely unnecessary) for large public groups. + +We have also arranged a 3rd party cryptographic review of our protocol and encryption schemes design for June/July 2024 - it will cover the additions to SimpleX protocols since [the previous security audit](./20221108-simplex-chat-v4.2-security-audit-new-website.md) in November 2022, including [XFTP protocol](./20230301-simplex-file-transfer-protocol.md) we use for file transfers and quantum resistant Signal double ratchet algorithm we just released in this beta version. + +In November 2024 we will be conducting further implementation audit, with double the scope of our 2022 audit. + +Security audits are very expensive, as they require employing exceptionally competent engineers and cryptographers, and it does stretch our budgets - so any donations to help us cover the costs would be hugely helpful. + +That's it for now! + +Thank you for helping us improve the app, and look forward to your feedback. + +## SimpleX network + +Some links to answer the most common questions: + +[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers). + +[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users). + +[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-technical-details-and-limitations). + +[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions). + +Please also see our [website](https://simplex.chat). + +## Help us with donations + +Huge thank you to everybody who donates to SimpleX Chat! + +As I wrote, we are planning a 3rd party security audit for the protocols and cryptography design, and also for an app implementation, and it would hugely help us if some part of this $50,000+ expense is covered with donations. + +We are prioritizing users privacy and security - it would be impossible without your support. + +Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX network based on the same principles as email and web, but much more private and secure. + +Your donations help us raise more funds – any amount, even the price of the cup of coffee, makes a big difference for us. + +See [this section](https://github.com/simplex-chat/simplex-chat/tree/master#help-us-with-donations) for the ways to donate. + +Thank you, + +Evgeny + +SimpleX Chat founder diff --git a/blog/README.md b/blog/README.md index 8066f0592a..3b89628211 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,21 @@ # Blog +Mar 14, 2024 [SimpleX Chat v5.6 (beta): adding quantum resistance to Signal double ratchet algorithm](./20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) + +This is a major upgrade for SimpleX Chat messaging protocol stack, I am really proud to present this work of the whole team. + +This post also covers various aspects of end-to-end encryption, compares different messengers, and explains how and why quantum-resistant encryption is added to SimpleX Chat: + +- Why do we need end-to-end encryption? +- Why encryption is even allowed? +- End-to-end encryption security: attacks and defense. +- How secure is encryption in different messengers? +- Adding quantum resistance to Signal double ratchet algorithm. +- When can you start using quantum resistant chats? +- Next for post-quantum crypto: all direct chats, small groups and security audit. + +--- + Jan 24, 2024 [SimpleX Chat: free infrastructure from Linode, v5.5 released](./20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md) SimpleX Chat infrastructure on Linode: diff --git a/blog/images/20240314-comparison.jpg b/blog/images/20240314-comparison.jpg new file mode 100644 index 0000000000000000000000000000000000000000..34027a43a7595aa33f53fc9545a1ff9d24100590 GIT binary patch literal 139267 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

B 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�ze{e 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 z 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 M_vxSgPyZPE7ZHyXEC2ui literal 0 HcmV?d00001 diff --git a/blog/images/20240314-datacenter.jpg b/blog/images/20240314-datacenter.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9bb55c47c2fa9793e49ed95f41c2be9033bf499b GIT binary patch literal 50203 zcma%ib8u!sv-cC**2cCswrv|5o12Y|C$^o9ZQHhOY;4@TZ{51}{rUCGRGsSSnlp3y zSJTteeZE${b^yrI;!@%O5KsWX^t%AQE&wQ^t|r!=01yB;007|qW?cagg-q>@%>br7 z-yD#ybwCgR5*!>H0vr+o0ul!DyTL(2LPEpA!@|PB!onlN|Cf;A5s{FQ5aH3#(9zM* z@bU2Q@CpBqK%k(Y5a1E8k&&@+F;FmY|F7f!_u;DtfC3GI4eAC4f&u_V0Rck+`5FM^ ze9sR81O()Jp8reWAfR9nkN_xX5CGVB$N$swy=X9S2uNfAC#lG5L{!+w=UlK%oCE7s zQN9NU1^aLN-}iu^fD)sEp?!}7j&4Na&+@effd7vG3K$AN0MNjkHSD+rC9*DhW9uCk z9JdjCYB$y$?^ANo)LsRpeiDv5k-R@iM1m$~KqVXZkaO80&Inu=St8irrBPzrB!ute z3qbdQbicjolL;#@A+V@`J#Kxf@~QM$_D_gMjPQ`~tkA~AXfEeP;`J1f3cdP4;?+qh zis}o%^aYUeNwfR{Wcn6%OLsQ-X8Pv!W5_Yy#k6;&G1{#OObZx$ZO{7nCHUmW+*$da ztkQWvs}JRW0Spi*yNo?*12mD6+t^a0?Pb!50os<~9HosvE6m_y$w$HGM_q(f18uF2 zCN2&V-RQVK*-Q|$e<5K? zcH63}Ec{c(YPyAT1WoPLqHCY+wcFiPk%88es--;=nBZQNjyR;tpTmppU4EJye3q}+^&I$>l_A1wCUBMM9 z6k;isIz}6zj~jZ2z6j6&^{rLM*6)vhwFdSVap@i`sr`gy%TH$pT22lTJ?gSAfZ_}+ zQjtJtSU=ckO_6C|u$KzkF}H#oT>D!-m-`;2q!JrSyG(Fj9IUdqk>U)5feMktPZ(kl zWREA600!{1oU?lB6YOU7}BnYKWq04{-#7US5dz%o0!v)Sn@DN@|P6G&)2faG1 zipSeV6m(EU40n-+?)By?!V?*KJ;4y)7A7P5MU`pogj#-#RV2eVX*#yCVErvxhhmqN zLdiN5yy9mwEo0{AM%B9Fv0d{)eA3Hq#l`V?$z?9 ze0l9#gZfGGd-9tA zb!$0|{Uxt=7zKNKg>nsdtreMMU?E|-?+bq^LQCcT)IArZ2U!61F98Bla?#;t;2IPW zxE1k#E5h0GS|Acofqx@F(N;Pj073Pgsu4X)a#PS1WC2RwpY|_r@;!X~J$##g3NGxN zBHVxZhz%$%#iDFGMAjK9h#OW8o(x&{hNg2++Dt5RLs9T;Uw|xZ9FOjj7JZJ}wE>NU z@x{?f*jO0-EIHMZ1X-c|I*#1?lK5hDy>*$Y54O%S`-27F}k|SL9MpblQS{-Am>o7%gpY)3Y+rDvscVBGtHZ2f(yJbS|Jda1(UWkafG!wu~@V$MtYcztXz)0$MG zQ!$ATdckLGxx>V@#xIE*=w&gwqG&IN^D_e$iK!XfQV4a64MjW=2Tt2h(E?)5?-Byw zH-orkfw(P5Y&`=lS(Ez{PZ7>59W4AmfFx*8gazc|SQ_$3GZ7Tgz&J{d{}*ZhH9Cn< zig5D6?%E!Rjo64YN`5{fLax+!NERrqJ^Buh@t3Cc%t`>=bCN+!G3`JvlzgRM(8`)?7! zHa2ow%d7MbXe-1foo;L6@6yD|ZED!}EttMhn1HN_Rj&vZkj$fM2u}sNwc(CiiLpR> zPvn2{-9K!^cxcT^^KaVO(fh)>;MK-W-ixf7sA!WD_E1X^IqHKdKIRA7)WSJ0qC^3)I*x8=#|K%WM|0GNTThGHR(eLS(5iWyN(E

&1x? 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 literal 0 HcmV?d00001 diff --git a/blog/images/20240314-djb.jpg b/blog/images/20240314-djb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..33551ec365228b6c9d0a2af3d70cdba6d54cdc2b GIT binary patch literal 16621 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 MnE%+AxcB4#1?9>@-2eap literal 0 HcmV?d00001 diff --git a/blog/images/20240314-mitm3.jpg b/blog/images/20240314-mitm3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a20b37765afd1b5c04d65c3d6fc1c27788bbcda1 GIT binary patch literal 50600 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