From 3e29c664ac39d1cd75420fb244daf9b2e05b98ca Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 27 Sep 2023 11:41:02 +0300 Subject: [PATCH 001/174] core: remote host/controller types (#3104) * Start sprinkling ZoneId everywhere * Draft zone/satellite/host api * Add zone dispatching * Add command relaying handler * Parse commands and begin DB * Implement discussed things * Resolve some comments * Resolve more stuff * Make bots ignore remoteHostId from queues * Fix tests and stub more * Untangle cmd relaying * Resolve comments * Add more http2 client funs * refactor, rename * rename * remove empty tests --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/simplex-bot-advanced/Main.hs | 2 +- .../src/Broadcast/Bot.hs | 2 +- apps/simplex-chat/Server.hs | 2 +- .../src/Directory/Service.hs | 8 +- package.yaml | 2 + simplex-chat.cabal | 18 ++++ src/Simplex/Chat.hs | 62 +++++++++++-- src/Simplex/Chat/Bot.hs | 2 +- src/Simplex/Chat/Controller.hs | 91 +++++++++++++++++- src/Simplex/Chat/Core.hs | 2 +- .../Migrations/M20230922_remote_controller.hs | 31 +++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 14 +++ src/Simplex/Chat/Mobile.hs | 23 ++++- src/Simplex/Chat/Remote.hs | 92 +++++++++++++++++++ src/Simplex/Chat/Remote/Types.hs | 46 ++++++++++ src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Remote.hs | 28 ++++++ src/Simplex/Chat/Terminal/Input.hs | 2 +- src/Simplex/Chat/Terminal/Output.hs | 2 +- src/Simplex/Chat/Types.hs | 2 +- src/Simplex/Chat/View.hs | 3 + 21 files changed, 413 insertions(+), 25 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230922_remote_controller.hs create mode 100644 src/Simplex/Chat/Remote.hs create mode 100644 src/Simplex/Chat/Remote/Types.hs create mode 100644 src/Simplex/Chat/Store/Remote.hs diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 04d8e4ffa1..8d596c9707 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -41,7 +41,7 @@ mySquaringBot :: User -> ChatController -> IO () mySquaringBot _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ contact _ -> do contactConnected contact diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 04b6627f38..afeb116f7c 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -35,7 +35,7 @@ broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ ct _ -> do contactConnected ct diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 6f198340f8..d32005e573 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -84,7 +84,7 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do >>= processCommand >>= atomically . writeTBQueue sndQ output ChatClient {sndQ} = forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc atomically $ writeTBQueue sndQ ChatSrvResponse {corrId = Nothing, resp} receive ws ChatClient {rcvQ, sndQ} = forever $ do s <- WS.receiveData ws diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 46abc4652d..7ed39847a0 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -59,7 +59,7 @@ welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" opts@DirectoryOpts {coreOptions = CoreChatOpts {dbFilePrefix}, testing} <- getDirectoryOpts appDir "simplex_directory_service" - unless testing $ do + unless testing $ do putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" pure opts @@ -68,7 +68,7 @@ directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {userId} cc = do initializeBotAddress' (not testing) cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc forM_ (crDirectoryEvent resp) $ \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole @@ -161,7 +161,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { badRolesMsg :: GroupRolesStatus -> Maybe String badRolesMsg = \case GRSOk -> Nothing - GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group" + GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group" GRSContactNotOwner -> Just "You must grant directory service *admin* role to register the group" GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" @@ -352,7 +352,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { groupRef = groupReference g srvRole = "*" <> B.unpack (strEncode serviceRole) <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." - whenContactIsOwner gr action = + whenContactIsOwner gr action = getGroupMember gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) diff --git a/package.yaml b/package.yaml index 406f9aabaa..5c8c11a698 100644 --- a/package.yaml +++ b/package.yaml @@ -19,6 +19,7 @@ dependencies: - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 + - binary >= 0.8 && < 0.9 - bytestring == 0.11.* - composition == 1.0.* - constraints >= 0.12 && < 0.14 @@ -30,6 +31,7 @@ dependencies: - exceptions == 0.10.* - filepath == 1.4.* - http-types == 0.12.* + - http2 - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 338346b655..eda9c371d3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -113,6 +113,7 @@ library Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes + Simplex.Chat.Migrations.M20230922_remote_controller Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -120,6 +121,8 @@ library Simplex.Chat.Options Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol + Simplex.Chat.Remote + Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections Simplex.Chat.Store.Direct @@ -128,6 +131,7 @@ library Simplex.Chat.Store.Messages Simplex.Chat.Store.Migrations Simplex.Chat.Store.Profiles + Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared Simplex.Chat.Styled Simplex.Chat.Terminal @@ -151,6 +155,7 @@ library , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -162,6 +167,7 @@ library , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -199,6 +205,7 @@ executable simplex-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -210,6 +217,7 @@ executable simplex-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -248,6 +256,7 @@ executable simplex-bot-advanced , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -259,6 +268,7 @@ executable simplex-bot-advanced , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -299,6 +309,7 @@ executable simplex-broadcast-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -310,6 +321,7 @@ executable simplex-broadcast-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -349,6 +361,7 @@ executable simplex-chat , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -360,6 +373,7 @@ executable simplex-chat , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* @@ -403,6 +417,7 @@ executable simplex-directory-service , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -414,6 +429,7 @@ executable simplex-directory-service , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -476,6 +492,7 @@ test-suite simplex-chat-test , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -489,6 +506,7 @@ test-suite simplex-chat-test , filepath ==1.4.* , hspec ==2.11.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e74eaa0f5c..d14ab59700 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -62,6 +62,8 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol +import Simplex.Chat.Remote +import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct @@ -204,6 +206,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty + remoteHostSessions <- atomically TM.empty + remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder chatStoreChanged <- newTVarIO False expireCIThreads <- newTVarIO M.empty @@ -213,7 +217,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, remoteHostSessions, remoteCtrlSession, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -340,12 +344,14 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, mapM_ hClose fs atomically $ writeTVar files M.empty -execChatCommand :: ChatMonad' m => ByteString -> m ChatResponse -execChatCommand s = do +execChatCommand :: ChatMonad' m => Maybe RemoteHostId -> ByteString -> m ChatResponse +execChatCommand rh s = do u <- readTVarIO =<< asks currentUser case parseChatCommand s of Left e -> pure $ chatCmdError u e - Right cmd -> execChatCommand_ u cmd + Right cmd -> case rh of + Nothing -> execChatCommand_ u cmd + Just remoteHostId -> execRemoteCommand u remoteHostId (s, cmd) execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) @@ -353,14 +359,26 @@ execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` c execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) +execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> (ByteString, ChatCommand) -> m ChatResponse +execRemoteCommand u rh scmd = either (CRChatCmdError u) id <$> runExceptT (withRemoteHostSession rh $ \rhs -> processRemoteCommand rhs scmd) + parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace +-- | Emit local events. toView :: ChatMonad' m => ChatResponse -> m () -toView event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, event) +toView = toView_ Nothing +-- | Used by transport to mark remote events with source. +toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () +toViewRemote = toView_ . Just + +toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () +toView_ rh event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, rh, event) + +-- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser @@ -1830,6 +1848,24 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} + CreateRemoteHost _displayName -> pure $ chatCmdError Nothing "not supported" + ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" + StartRemoteHost rh -> do + RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" + (fingerprint, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) + _announcer <- async $ error "TODO: run announcer" fingerprint + hostAsync <- async $ error "TODO: runServer" storePath sessionCreds + chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {hostAsync, storePath, ctrlClient = undefined} + pure $ chatCmdError Nothing "not supported" + StopRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" + ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" + StartRemoteCtrl -> pure $ chatCmdError Nothing "not supported" + ConfirmRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + RejectRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + StopRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + DisposeRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -5599,6 +5635,17 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, + "/create remote host" *> (CreateRemoteHost <$> textP), + "/list remote hosts" $> ListRemoteHosts, + "/start remote host " *> (StartRemoteHost <$> A.decimal), + "/stop remote host " *> (StopRemoteHost <$> A.decimal), + "/dispose remote host " *> (DisposeRemoteHost <$> A.decimal), + "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), + "/start remote ctrl" $> StartRemoteCtrl, + "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), + "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), + "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), + "/dispose remote ctrl " *> (DisposeRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -5716,6 +5763,7 @@ chatCommandP = srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char + remoteHostOOBP = RemoteHostOOB <$> textP adminContactReq :: ConnReqContact adminContactReq = diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index ecd1659bca..c5c5ff7eed 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -25,7 +25,7 @@ chatBotRepl :: String -> (Contact -> String -> IO String) -> User -> ChatControl chatBotRepl welcome answer _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ contact _ -> do contactConnected contact diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2c829e4a95..c6405990a3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,6 +46,7 @@ import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -173,7 +174,7 @@ data ChatController = ChatController chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, - outputQ :: TBQueue (Maybe CorrId, ChatResponse), + outputQ :: TBQueue (Maybe CorrId, Maybe RemoteHostId, ChatResponse), notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), subscriptionMode :: TVar SubscriptionMode, @@ -181,6 +182,8 @@ data ChatController = ChatController sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, + remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts + remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, expireCIThreads :: TMap UserId (Maybe (Async ())), @@ -410,6 +413,18 @@ data ChatCommand | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) + | CreateRemoteHost Text -- ^ Configure a new remote host + | ListRemoteHosts + | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host + | StopRemoteHost RemoteHostId -- ^ Shut down a running session + | DisposeRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data + | RegisterRemoteCtrl Text RemoteHostOOB -- ^ Register OOB data for satellite discovery and handshake + | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers + | ListRemoteCtrls + | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm discovered data and store confirmation + | RejectRemoteCtrl RemoteCtrlId -- ^ Reject discovered data (and blacklist?) + | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session + | DisposeRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat | ShowVersion | DebugLocks @@ -580,6 +595,17 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} + | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteHostOOB} + | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup + | CRRemoteHostStarted {remoteHostId :: RemoteHostId} + | CRRemoteHostStopped {remoteHostId :: RemoteHostId} + | CRRemoteHostDisposed {remoteHostId :: RemoteHostId} + | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} + | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlDisconnected {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -616,10 +642,32 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False +instance FromJSON ChatResponse where + parseJSON todo = pure $ CRCmdOk Nothing -- TODO: actually use the instances + instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data RemoteHostOOB = RemoteHostOOB + { fingerprint :: Text -- CA key fingerprint + } + deriving (Show, Generic, ToJSON) + +data RemoteHostInfo = RemoteHostInfo + { remoteHostId :: RemoteHostId, + displayName :: Text, + sessionActive :: Bool + } + deriving (Show, Generic, ToJSON) + +data RemoteCtrlInfo = RemoteCtrlInfo + { remoteCtrlId :: RemoteCtrlId, + displayName :: Text, + sessionActive :: Bool + } + deriving (Show, Generic, ToJSON) + newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -858,6 +906,8 @@ data ChatError | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} + | ChatErrorRemoteCtrl {remoteCtrlId :: RemoteCtrlId, remoteControllerError :: RemoteCtrlError} + | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) instance ToJSON ChatError where @@ -967,6 +1017,41 @@ instance ToJSON SQLiteError where throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase +-- TODO review errors, some of it can be covered by HTTP2 errors +data RemoteHostError + = RHMissing -- ^ No remote session matches this identifier + | RHBusy -- ^ A session is already running + | RHRejected -- ^ A session attempt was rejected by a host + | RHTimeout -- ^ A discovery or a remote operation has timed out + | RHDisconnected {reason :: Text} -- ^ A session disconnected by a host + | RHConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues + deriving (Show, Exception, Generic) + +instance FromJSON RemoteHostError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RH" + +instance ToJSON RemoteHostError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RH" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RH" + +-- TODO review errors, some of it can be covered by HTTP2 errors +data RemoteCtrlError + = RCEMissing -- ^ No remote session matches this identifier + | RCEBusy -- ^ A session is already running + | RCETimeout -- ^ Remote operation timed out + | RCEDisconnected {reason :: Text} -- ^ A session disconnected by a controller + | RCEConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues + | RCECertificateExpired -- ^ A connection or CA certificate in a chain have bad validity period + | RCECertificateUntrusted -- ^ TLS is unable to validate certificate chain presented for a connection + deriving (Show, Exception, Generic) + +instance FromJSON RemoteCtrlError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RCE" + +instance ToJSON RemoteCtrlError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -979,6 +1064,10 @@ chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) {-# INLINE chatWriteVar #-} +chatModifyVar :: ChatMonad' m => (ChatController -> TVar a) -> (a -> a) -> m () +chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue) +{-# INLINE chatModifyVar #-} + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 4af161ab41..5f5a27e772 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -40,7 +40,7 @@ runSimplexChat ChatOpts {maintenance} u cc chat waitEither_ a1 a2 sendChatCmdStr :: ChatController -> String -> IO ChatResponse -sendChatCmdStr cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc +sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs new file mode 100644 index 0000000000..070a4e35cb --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230922_remote_controller where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230922_remote_controller :: Query +m20230922_remote_controller = + [sql| +CREATE TABLE remote_hosts ( -- hosts known to a controlling app + remote_host_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + store_path TEXT NOT NULL, + ca_cert BLOB NOT NULL, + ca_key BLOB NOT NULL +); + +CREATE TABLE remote_controllers ( -- controllers known to a hosting app + remote_controller_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + fingerprint BLOB NOT NULL +); +|] + +down_m20230922_remote_controller :: Query +down_m20230922_remote_controller = + [sql| +DROP TABLE remote_hosts; +DROP TABLE remote_controllers; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 141247e590..292fcf32fc 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -515,6 +515,20 @@ CREATE TABLE IF NOT EXISTS "received_probes"( created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL) ); +CREATE TABLE remote_hosts( + -- hosts known to a controlling app + remote_host_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + store_path TEXT NOT NULL, + ca_cert BLOB NOT NULL, + ca_key BLOB NOT NULL +); +CREATE TABLE remote_controllers( + -- controllers known to a hosting app + remote_controller_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + fingerprint BLOB NOT NULL +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 700548bb12..664412c589 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -3,6 +3,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fobject-code #-} module Simplex.Chat.Mobile where @@ -37,6 +38,7 @@ import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options +import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types @@ -55,6 +57,8 @@ foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString +foreign export ccall "chat_send_remote_cmd" cChatSendRemoteCmd :: StablePtr ChatController -> CInt -> CString -> IO CJSONString + foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatController -> CInt -> IO CJSONString @@ -102,6 +106,14 @@ cChatSendCmd cPtr cCmd = do cmd <- B.packCString cCmd newCStringFromLazyBS =<< chatSendCmd c cmd +-- | send command to chat (same syntax as in terminal for now) +cChatSendRemoteCmd :: StablePtr ChatController -> CInt -> CString -> IO CJSONString +cChatSendRemoteCmd cPtr cRemoteHostId cCmd = do + c <- deRefStablePtr cPtr + cmd <- B.packCString cCmd + let rhId = Just $ fromIntegral cRemoteHostId + newCStringFromLazyBS =<< chatSendRemoteCmd c rhId cmd + -- | receive message from chat (blocking) cChatRecvMsg :: StablePtr ChatController -> IO CJSONString cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCStringFromLazyBS @@ -195,13 +207,16 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do _ -> dbError e dbError e = Left . DBMErrorSQL dbFile $ show e -chatSendCmd :: ChatController -> ByteString -> IO JSONByteString -chatSendCmd cc s = J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc +chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString +chatSendCmd cc = chatSendRemoteCmd cc Nothing + +chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO JSONByteString +chatSendRemoteCmd cc rh s = J.encode . APIResponse Nothing rh <$> runReaderT (execChatCommand rh s) cc chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) where - json (corr, resp) = J.encode APIResponse {corr, resp} + json (corr, remoteHostId, resp) = J.encode APIResponse {corr, remoteHostId, resp} chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) @@ -227,7 +242,7 @@ chatPasswordHash pwd salt = either (const "") passwordHash salt' salt' = U.decode salt passwordHash = U.encode . C.sha512Hash . (pwd <>) -data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} +data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} deriving (Generic) instance ToJSON APIResponse where diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs new file mode 100644 index 0000000000..ba543e33f2 --- /dev/null +++ b/src/Simplex/Chat/Remote.hs @@ -0,0 +1,92 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Simplex.Chat.Remote where + +import Control.Monad.Except +import Control.Monad.IO.Class +import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) +import qualified Data.Map.Strict as M +import qualified Data.Binary.Builder as Binary +import qualified Network.HTTP.Types as HTTP +import qualified Network.HTTP2.Client as HTTP2Client +import Simplex.Chat.Controller +import Simplex.Chat.Remote.Types +import Simplex.Chat.Types +import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) +import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 +import Simplex.Messaging.Util (bshow) +import System.Directory (getFileSize) + +withRemoteHostSession :: ChatMonad m => RemoteHostId -> (RemoteHostSession -> m a) -> m a +withRemoteHostSession remoteHostId action = do + chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteHostId + where + err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing + +processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse +processRemoteCommand rhs = \case + -- XXX: intercept and filter some commands + -- TODO: store missing files on remote host + (s, _cmd) -> relayCommand rhs s + +relayCommand :: ChatMonad m => RemoteHostSession -> ByteString -> m ChatResponse +relayCommand RemoteHostSession {ctrlClient} s = postBytestring Nothing ctrlClient "/relay" mempty s >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + remoteChatResponse <- + if iTax then + case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of + J.Error e -> error "TODO: json2chatError" e + J.Success cr -> pure cr + else + case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right cr -> pure cr + case remoteChatResponse of + -- TODO: intercept file responses and fetch files when needed + -- XXX: is that even possible, to have a file response to a command? + _ -> pure remoteChatResponse + where + iTax = True -- TODO: get from RemoteHost + -- XXX: extract to http2 transport + postBytestring timeout c path hs body = liftIO $ HTTP2.sendRequest c req timeout + where + req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) + +storeRemoteFile :: ChatMonad m => RemoteHostSession -> FilePath -> m ChatResponse +storeRemoteFile RemoteHostSession {ctrlClient} localFile = do + postFile Nothing ctrlClient "/store" mempty localFile >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response { response } -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of + Just 200 -> pure $ CRCmdOk Nothing + unexpected -> error "TODO: http2chatError" + where + postFile timeout c path hs file = liftIO $ do + fileSize <- fromIntegral <$> getFileSize file + HTTP2.sendRequest c (req fileSize) timeout + where + req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) + +fetchRemoteFile :: ChatMonad m => RemoteHostSession -> FileTransferId -> m ChatResponse +fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do + liftIO (HTTP2.sendRequest ctrlClient req Nothing) >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response {respBody} -> do + error "TODO: stream body into a local file" -- XXX: consult headers for a file name? + where + req = HTTP2Client.requestNoBody "GET" path mempty + path = "/fetch/" <> bshow remoteFileId + +-- | Convert swift single-field sum encoding into tagged/discriminator-field +sum2tagged :: J.Value -> J.Value +sum2tagged = \case + J.Object todo'convert -> J.Object todo'convert + skip -> skip diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs new file mode 100644 index 0000000000..8e705d5d28 --- /dev/null +++ b/src/Simplex/Chat/Remote/Types.hs @@ -0,0 +1,46 @@ +{-# LANGUAGE DuplicateRecordFields #-} + +module Simplex.Chat.Remote.Types where + +import Control.Concurrent.Async (Async) +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) + +type RemoteHostId = Int64 + +data RemoteHost = RemoteHost + { remoteHostId :: RemoteHostId, + displayName :: Text, + -- | Path to store replicated files + storePath :: FilePath, + -- | A stable part of X509 credentials used to access the host + caCert :: ByteString, + -- | Credentials signing key for root and session certs + caKey :: C.Key + } + +type RemoteCtrlId = Int + +data RemoteCtrl = RemoteCtrl + { remoteCtrlId :: RemoteCtrlId, + displayName :: Text, + fingerprint :: Text + } + +data RemoteHostSession = RemoteHostSession + { -- | process to communicate with the host + hostAsync :: Async (), + -- | Path for local resources to be synchronized with host + storePath :: FilePath, + ctrlClient :: HTTP2Client + } + +-- | Host-side dual to RemoteHostSession, on-methods represent HTTP API. +data RemoteCtrlSession = RemoteCtrlSession + { -- | process to communicate with the remote controller + ctrlAsync :: Async () + -- server :: HTTP2Server + } diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index d8bab817e3..759244f019 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -81,6 +81,7 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes +import Simplex.Chat.Migrations.M20230922_remote_controller import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -161,7 +162,8 @@ schemaMigrations = ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange), ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), - ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes) + ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), + ("20230922_remote_controller", m20230922_remote_controller, Just down_m20230922_remote_controller) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs new file mode 100644 index 0000000000..12fcb6c081 --- /dev/null +++ b/src/Simplex/Chat/Store/Remote.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Store.Remote where + +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import qualified Database.SQLite.Simple as DB +import Simplex.Chat.Remote.Types (RemoteHostId, RemoteHost (..)) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import qualified Simplex.Messaging.Crypto as C + +getRemoteHosts :: DB.Connection -> IO [RemoteHost] +getRemoteHosts db = + map toRemoteHost <$> DB.query_ db remoteHostQuery + +getRemoteHost :: DB.Connection -> RemoteHostId -> IO (Maybe RemoteHost) +getRemoteHost db remoteHostId = + maybeFirstRow toRemoteHost $ + DB.query db (remoteHostQuery <> "WHERE remote_host_id = ?") (DB.Only remoteHostId) + +remoteHostQuery :: DB.Query +remoteHostQuery = "SELECT remote_host_id, display_name, store_path, ca_cert, ca_key FROM remote_hosts" + +toRemoteHost :: (Int64, Text, FilePath, ByteString, C.Key) -> RemoteHost +toRemoteHost (remoteHostId, displayName, storePath, caCert, caKey) = + RemoteHost {remoteHostId, displayName, storePath, caCert, caKey} diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 8841f15ffd..1097a79543 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -56,7 +56,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do let bs = encodeUtf8 $ T.pack s cmd = parseChatCommand bs unless (isMessage cmd) $ echo s - r <- runReaderT (execChatCommand bs) cc + r <- runReaderT (execChatCommand Nothing bs) cc case r of CRChatCmdError _ _ -> when (isMessage cmd) $ echo s CRChatError _ _ -> when (isMessage cmd) $ echo s diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index db6f16f3ca..74bb9e8c0b 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -112,7 +112,7 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = do forever $ do - (_, r) <- atomically $ readTBQueue outputQ + (_, _, r) <- atomically $ readTBQueue outputQ case r of CRNewChatItem _ ci -> markChatItemRead ci CRChatItemUpdated _ ci -> markChatItemRead ci diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 93964316cc..a228055ad6 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -10,13 +10,13 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE UndecidableInstances #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5db0c317e8..1f110fc951 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -297,6 +297,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRChatError u e -> ttyUser' u $ viewChatError logLevel e CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRTimedAction _ _ -> [] + todo'cr -> ["TODO" <> sShow todo'cr] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser} ss @@ -1677,6 +1678,8 @@ viewChatError logLevel = \case Nothing -> "" cId :: Connection -> StyledString cId conn = sShow conn.connId + ChatErrorRemoteCtrl remoteCtrlId todo'rc -> [sShow remoteCtrlId, sShow todo'rc] + ChatErrorRemoteHost remoteHostId todo'rh -> [sShow remoteHostId, sShow todo'rh] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] sqliteError' = \case From 77410e5d5e434b209297dd4181668b515174723c Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Wed, 27 Sep 2023 13:40:19 +0300 Subject: [PATCH 002/174] Add remote host discovery --- cabal.project | 2 +- package.yaml | 3 + simplex-chat.cabal | 22 +++++ src/Simplex/Chat.hs | 15 +-- src/Simplex/Chat/Remote.hs | 3 + src/Simplex/Chat/Remote/Discovery.hs | 132 +++++++++++++++++++++++++++ src/Simplex/Chat/Remote/Types.hs | 4 +- 7 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 src/Simplex/Chat/Remote/Discovery.hs diff --git a/cabal.project b/cabal.project index b4024f088c..7d7339a7fb 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + tag: 681fa93bf342d7c836fa0ff69b767dcd08526f03 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 5c8c11a698..5337b7eecf 100644 --- a/package.yaml +++ b/package.yaml @@ -25,6 +25,7 @@ dependencies: - constraints >= 0.12 && < 0.14 - containers == 0.6.* - cryptonite == 0.30.* + - data-default >= 0.7 && < 0.8 - directory == 1.3.* - direct-sqlcipher == 2.3.* - email-validate == 2.3.* @@ -35,6 +36,7 @@ dependencies: - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 + - network-udp >= 0.0 && < 0.1 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - random >= 1.1 && < 1.3 @@ -48,6 +50,7 @@ dependencies: - terminal == 0.2.* - text == 2.0.* - time == 1.9.* + - tls - unliftio == 0.2.* - unliftio-core == 0.2.* - zip == 2.0.* diff --git a/simplex-chat.cabal b/simplex-chat.cabal index eda9c371d3..1af77b9112 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -122,6 +122,7 @@ library Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote + Simplex.Chat.Remote.Discovery Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections @@ -161,6 +162,7 @@ library , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -171,6 +173,7 @@ library , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -184,6 +187,7 @@ library , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -211,6 +215,7 @@ executable simplex-bot , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -221,6 +226,7 @@ executable simplex-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -235,6 +241,7 @@ executable simplex-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -262,6 +269,7 @@ executable simplex-bot-advanced , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -272,6 +280,7 @@ executable simplex-bot-advanced , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -286,6 +295,7 @@ executable simplex-bot-advanced , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -315,6 +325,7 @@ executable simplex-broadcast-bot , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -325,6 +336,7 @@ executable simplex-broadcast-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -339,6 +351,7 @@ executable simplex-broadcast-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -367,6 +380,7 @@ executable simplex-chat , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -377,6 +391,7 @@ executable simplex-chat , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -391,6 +406,7 @@ executable simplex-chat , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* @@ -423,6 +439,7 @@ executable simplex-directory-service , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -433,6 +450,7 @@ executable simplex-directory-service , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -447,6 +465,7 @@ executable simplex-directory-service , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -498,6 +517,7 @@ test-suite simplex-chat-test , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , deepseq ==1.4.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -510,6 +530,7 @@ test-suite simplex-chat-test , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -525,6 +546,7 @@ test-suite simplex-chat-test , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d14ab59700..afcd95443e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -63,6 +63,7 @@ import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Remote +import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -1852,12 +1853,14 @@ processChatCommand = \case ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" StartRemoteHost rh -> do RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" - (fingerprint, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) - _announcer <- async $ error "TODO: run announcer" fingerprint - hostAsync <- async $ error "TODO: runServer" storePath sessionCreds - chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {hostAsync, storePath, ctrlClient = undefined} - pure $ chatCmdError Nothing "not supported" - StopRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) + cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete rh) + Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case + Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {storePath, ctrlClient} + pure $ CRRemoteHostStarted rh + StopRemoteHost rh -> closeRemoteHostSession rh $> CRRemoteHostStopped rh DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index ba543e33f2..34c6b31a46 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -29,6 +29,9 @@ withRemoteHostSession remoteHostId action = do where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing +closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) + processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand rhs = \case -- XXX: intercept and filter some commands diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs new file mode 100644 index 0000000000..ace1ced313 --- /dev/null +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -0,0 +1,132 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Remote.Discovery + ( runAnnouncer, + runDiscoverer, + ) +where + +import Control.Monad +import Data.ByteString.Builder (Builder, intDec) +import Data.Default (def) +import Data.String (IsString) +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Debug.Trace +import qualified Network.HTTP.Types as HTTP +import qualified Network.HTTP2.Server as HTTP2 +import qualified Network.Socket as N +import qualified Network.TLS as TLS +import qualified Network.UDP as UDP +import Simplex.Chat.Controller (ChatMonad) +import Simplex.Chat.Types () +import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import Simplex.Messaging.Transport (supportedParameters) +import qualified Simplex.Messaging.Transport as Transport +import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) +import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) +import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) +import UnliftIO +import UnliftIO.Concurrent + +runAnnouncer :: (StrEncoding invite, ChatMonad m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +runAnnouncer finished invite credentials = do + started <- newEmptyTMVarIO + aPid <- async $ announcer started (strEncode invite) + let serverParams = + def + { TLS.serverWantClientCert = False, + TLS.serverShared = def {TLS.sharedCredentials = credentials}, + TLS.serverHooks = def, + TLS.serverSupported = supportedParameters + } + httpClient <- newEmptyMVar + liftIO $ runTransportServer started partyPort serverParams defaultTransportServerConfig (run aPid httpClient) + takeMVar httpClient + where + announcer started inviteBS = do + atomically (takeTMVar started) >>= \case + False -> + error "Server not started?.." + True -> liftIO $ do + traceM $ "TCP server started at " <> partyPort + sock <- UDP.clientSocket broadcastAddrV4 partyPort False + N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 + traceM $ "UDP announce started at " <> broadcastAddrV4 <> ":" <> partyPort + traceM $ "Server invite: " <> show inviteBS + forever $ do + UDP.send sock inviteBS + threadDelay 1000000 + + run :: Async () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () + run aPid clientVar tls = do + cancel aPid + let partyHost = "255.255.255.255" -- XXX: get from tls somehow? not required as host verification is disabled. + attachHTTP2Client defaultHTTP2ClientConfig partyHost partyPort finished defaultHTTP2BufferSize tls >>= putMVar clientVar + +-- | Link-local broadcast address. +broadcastAddrV4 :: (IsString a) => a +broadcastAddrV4 = "255.255.255.255" + +partyPort :: (IsString a) => a +partyPort = "5226" -- XXX: should be `0` or something, to get a random port and announce it + +runDiscoverer :: (ChatMonad m) => Text -> m () +runDiscoverer oobData = + case strDecode (encodeUtf8 oobData) of + Left err -> traceM $ "oobData decode error: " <> err + Right expected -> liftIO $ do + traceM $ "runDiscoverer: locating " <> show oobData + sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) + N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 + traceM $ "runDiscoverer: " <> show sock + go sock expected + where + go sock expected = do + (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock + traceShowM (invite, source) + let expect hash = hash `elem` [expected] -- XXX: can be a callback to fetch actual invite list just in time + case strDecode invite of + Left err -> do + traceM $ "Inivite decode error: " <> err + go sock expected + Right inviteHash | not (expect inviteHash) -> do + traceM $ "Skipping unexpected invite " <> show (strEncode inviteHash) + go sock expected + Right _expected -> do + host <- case source of + N.SockAddrInet _port addr -> do + pure $ THIPv4 (N.hostAddressToTuple addr) + unexpected -> + -- TODO: actually, Apple mandates IPv6 support + fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected + traceM $ "Discoverer: go connect " <> show host + runTransportClient defaultTransportClientConfig Nothing host partyPort (Just expected) $ \tls -> do + traceM "2PTTH server starting" + run tls + traceM "2PTTH server finished" + + run tls = runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r 16384 + processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + + processRequest req = do + traceM $ "Got request: " <> show (request req) + -- TODO: sendResponse req . HTTP2.promiseResponse $ HTTP2.pushPromise path response weight + sendResponse req $ HTTP2.responseStreaming HTTP.ok200 sseHeaders sseExample + + sseHeaders = [(HTTP.hContentType, "text/event-stream")] + + sseExample :: (Builder -> IO ()) -> IO () -> IO () + sseExample write flush = forM_ [1 .. 10] $ \i -> do + let payload = "[" <> intDec i <> ", \"blah\"]" + write "event: message\n" -- XXX: SSE header line + write $ "data: " <> payload <> "\n" -- XXX: SSE payload line + write "\n" -- XXX: SSE delimiter + flush + threadDelay 1000000 diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 8e705d5d28..9f28eab551 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -31,9 +31,7 @@ data RemoteCtrl = RemoteCtrl } data RemoteHostSession = RemoteHostSession - { -- | process to communicate with the host - hostAsync :: Async (), - -- | Path for local resources to be synchronized with host + { -- | Path for local resources to be synchronized with host storePath :: FilePath, ctrlClient :: HTTP2Client } From cccb3e33fb2388cc7d76f92e515f4808e51a3bf7 Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Wed, 27 Sep 2023 18:24:38 +0300 Subject: [PATCH 003/174] Plug discovery into remote controller UI --- src/Simplex/Chat.hs | 56 ++++++++++-- src/Simplex/Chat/Controller.hs | 19 ++-- .../Migrations/M20230922_remote_controller.hs | 3 +- src/Simplex/Chat/Remote.hs | 64 +++++++------ src/Simplex/Chat/Remote/Discovery.hs | 89 +++++++------------ src/Simplex/Chat/Remote/Types.hs | 13 +-- src/Simplex/Chat/Store/Remote.hs | 27 +++++- src/Simplex/Chat/View.hs | 2 +- 8 files changed, 167 insertions(+), 106 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index afcd95443e..849bff97fd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -72,6 +72,7 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -1864,11 +1865,56 @@ processChatCommand = \case DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" - StartRemoteCtrl -> pure $ chatCmdError Nothing "not supported" - ConfirmRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" - RejectRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" - StopRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" - DisposeRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + StartRemoteCtrl -> + chatReadVar remoteCtrlSession >>= \case + Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy + Nothing -> do + uio <- askUnliftIO + accepted <- newEmptyTMVarIO + let getControllers = unliftIO uio $ withStore' $ \db -> + map (\RemoteCtrl{remoteCtrlId, fingerprint} -> (fingerprint, remoteCtrlId)) <$> getRemoteCtrls (DB.conn db) + let started remoteCtrlId = unliftIO uio $ do + withStore' (\db -> getRemoteCtrl (DB.conn db) remoteCtrlId) >>= \case + Nothing -> pure False + Just RemoteCtrl{displayName, accepted=resolution} -> case resolution of + Nothing -> do + -- started/finished wrapper is synchronous, running HTTP server can be delayed here until UI processes the first contact dialogue + toView $ CRRemoteCtrlFirstContact {remoteCtrlId, displayName} + atomically $ takeTMVar accepted + Just known -> atomically $ putTMVar accepted known $> known + let finished remoteCtrlId todo'error = unliftIO uio $ do + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlDisconnected {remoteCtrlId} + let process rc req = unliftIO uio $ processControllerCommand rc req + ctrlAsync <- async . liftIO $ Discovery.runDiscoverer getControllers started finished process + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync, accepted} + pure CRRemoteCtrlStarted + ConfirmRemoteCtrl remoteCtrlId -> do + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {accepted} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True + atomically $ putTMVar accepted True + pure $ CRRemoteCtrlAccepted {remoteCtrlId} + RejectRemoteCtrl remoteCtrlId -> do + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {accepted} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False + atomically $ putTMVar accepted False + pure $ CRRemoteCtrlRejected {remoteCtrlId} + StopRemoteCtrl remoteCtrlId -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {ctrlAsync} -> do + cancel ctrlAsync + pure $ CRRemoteCtrlDisconnected {remoteCtrlId} + DisposeRemoteCtrl remoteCtrlId -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> do + withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId + pure $ CRRemoteCtrlDisposed {remoteCtrlId} + Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c6405990a3..82a1cbcede 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -422,7 +422,7 @@ data ChatCommand | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers | ListRemoteCtrls | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm discovered data and store confirmation - | RejectRemoteCtrl RemoteCtrlId -- ^ Reject discovered data (and blacklist?) + | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session | DisposeRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat @@ -602,9 +602,11 @@ data ChatResponse | CRRemoteHostDisposed {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlStarted + | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlDisconnected {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} @@ -906,7 +908,7 @@ data ChatError | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} - | ChatErrorRemoteCtrl {remoteCtrlId :: RemoteCtrlId, remoteControllerError :: RemoteCtrlError} + | ChatErrorRemoteCtrl {remoteControllerError :: RemoteCtrlError} | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) @@ -1036,13 +1038,14 @@ instance ToJSON RemoteHostError where -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError - = RCEMissing -- ^ No remote session matches this identifier + = RCEMissing {remoteCtrlId :: RemoteCtrlId} -- ^ No remote session matches this identifier + | RCEInactive -- ^ No session is running | RCEBusy -- ^ A session is already running | RCETimeout -- ^ Remote operation timed out - | RCEDisconnected {reason :: Text} -- ^ A session disconnected by a controller - | RCEConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues - | RCECertificateExpired -- ^ A connection or CA certificate in a chain have bad validity period - | RCECertificateUntrusted -- ^ TLS is unable to validate certificate chain presented for a connection + | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller + | RCEConnectionLost {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected due to transport issues + | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period + | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs index 070a4e35cb..4890bfc22a 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -19,7 +19,8 @@ CREATE TABLE remote_hosts ( -- hosts known to a controlling app CREATE TABLE remote_controllers ( -- controllers known to a hosting app remote_controller_id INTEGER PRIMARY KEY, display_name TEXT NOT NULL, - fingerprint BLOB NOT NULL + fingerprint BLOB NOT NULL, + accepted INTEGER ); |] diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 34c6b31a46..8e2bedc979 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -10,9 +10,9 @@ module Simplex.Chat.Remote where import Control.Monad.Except import Control.Monad.IO.Class import qualified Data.Aeson as J +import qualified Data.Binary.Builder as Binary import Data.ByteString.Char8 (ByteString) import qualified Data.Map.Strict as M -import qualified Data.Binary.Builder as Binary import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP2.Client as HTTP2Client import Simplex.Chat.Controller @@ -20,43 +20,44 @@ import Simplex.Chat.Remote.Types import Simplex.Chat.Types import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 -import Simplex.Messaging.Util (bshow) +import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 +import Simplex.Messaging.Util (bshow) import System.Directory (getFileSize) -withRemoteHostSession :: ChatMonad m => RemoteHostId -> (RemoteHostSession -> m a) -> m a +withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteHostId where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing -closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) -processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse +processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand rhs = \case -- XXX: intercept and filter some commands -- TODO: store missing files on remote host (s, _cmd) -> relayCommand rhs s -relayCommand :: ChatMonad m => RemoteHostSession -> ByteString -> m ChatResponse -relayCommand RemoteHostSession {ctrlClient} s = postBytestring Nothing ctrlClient "/relay" mempty s >>= \case - Left e -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - remoteChatResponse <- - if iTax then - case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of - J.Error e -> error "TODO: json2chatError" e - J.Success cr -> pure cr - else - case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right cr -> pure cr - case remoteChatResponse of - -- TODO: intercept file responses and fetch files when needed - -- XXX: is that even possible, to have a file response to a command? - _ -> pure remoteChatResponse +relayCommand :: (ChatMonad m) => RemoteHostSession -> ByteString -> m ChatResponse +relayCommand RemoteHostSession {ctrlClient} s = + postBytestring Nothing ctrlClient "/relay" mempty s >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + remoteChatResponse <- + if iTax + then case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of + J.Error e -> error "TODO: json2chatError" e + J.Success cr -> pure cr + else case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right cr -> pure cr + case remoteChatResponse of + -- TODO: intercept file responses and fetch files when needed + -- XXX: is that even possible, to have a file response to a command? + _ -> pure remoteChatResponse where iTax = True -- TODO: get from RemoteHost -- XXX: extract to http2 transport @@ -64,11 +65,11 @@ relayCommand RemoteHostSession {ctrlClient} s = postBytestring Nothing ctrlClien where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) -storeRemoteFile :: ChatMonad m => RemoteHostSession -> FilePath -> m ChatResponse +storeRemoteFile :: (ChatMonad m) => RemoteHostSession -> FilePath -> m ChatResponse storeRemoteFile RemoteHostSession {ctrlClient} localFile = do postFile Nothing ctrlClient "/store" mempty localFile >>= \case Left e -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response { response } -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of + Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of Just 200 -> pure $ CRCmdOk Nothing unexpected -> error "TODO: http2chatError" where @@ -78,7 +79,7 @@ storeRemoteFile RemoteHostSession {ctrlClient} localFile = do where req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) -fetchRemoteFile :: ChatMonad m => RemoteHostSession -> FileTransferId -> m ChatResponse +fetchRemoteFile :: (ChatMonad m) => RemoteHostSession -> FileTransferId -> m ChatResponse fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do liftIO (HTTP2.sendRequest ctrlClient req Nothing) >>= \case Left e -> error "TODO: http2chatError" @@ -93,3 +94,12 @@ sum2tagged :: J.Value -> J.Value sum2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip + +-- withRemoteCtrlSession :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrlSession -> m a) -> m a +-- withRemoteCtrlSession remoteCtrlId action = do +-- chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteCtrlId +-- where +-- err = throwError $ ChatErrorRemoteCtrl (Just remoteCtrlId) RCMissing + +processControllerCommand :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () +processControllerCommand rc req = error "TODO: processControllerCommand" diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index ace1ced313..cb668677f5 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -10,19 +10,13 @@ module Simplex.Chat.Remote.Discovery where import Control.Monad -import Data.ByteString.Builder (Builder, intDec) import Data.Default (def) import Data.String (IsString) -import Data.Text (Text) -import Data.Text.Encoding (encodeUtf8) import Debug.Trace -import qualified Network.HTTP.Types as HTTP -import qualified Network.HTTP2.Server as HTTP2 import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP -import Simplex.Chat.Controller (ChatMonad) -import Simplex.Chat.Types () +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport @@ -34,7 +28,7 @@ import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTran import UnliftIO import UnliftIO.Concurrent -runAnnouncer :: (StrEncoding invite, ChatMonad m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +runAnnouncer :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) runAnnouncer finished invite credentials = do started <- newEmptyTMVarIO aPid <- async $ announcer started (strEncode invite) @@ -76,57 +70,38 @@ broadcastAddrV4 = "255.255.255.255" partyPort :: (IsString a) => a partyPort = "5226" -- XXX: should be `0` or something, to get a random port and announce it -runDiscoverer :: (ChatMonad m) => Text -> m () -runDiscoverer oobData = - case strDecode (encodeUtf8 oobData) of - Left err -> traceM $ "oobData decode error: " <> err - Right expected -> liftIO $ do - traceM $ "runDiscoverer: locating " <> show oobData - sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) - N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 - traceM $ "runDiscoverer: " <> show sock - go sock expected +runDiscoverer :: IO [(C.KeyHash, ctx)] -> (ctx -> IO Bool) -> (ctx -> Maybe SomeException -> IO ()) -> (ctx -> HTTP2Request -> IO ()) -> IO () +runDiscoverer getFingerprints started finished processRequest = do + sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) + N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 + traceM $ "runDiscoverer: " <> show sock + go sock where - go sock expected = do + go sock = do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock - traceShowM (invite, source) - let expect hash = hash `elem` [expected] -- XXX: can be a callback to fetch actual invite list just in time case strDecode invite of Left err -> do traceM $ "Inivite decode error: " <> err - go sock expected - Right inviteHash | not (expect inviteHash) -> do - traceM $ "Skipping unexpected invite " <> show (strEncode inviteHash) - go sock expected - Right _expected -> do - host <- case source of - N.SockAddrInet _port addr -> do - pure $ THIPv4 (N.hostAddressToTuple addr) - unexpected -> - -- TODO: actually, Apple mandates IPv6 support - fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected - traceM $ "Discoverer: go connect " <> show host - runTransportClient defaultTransportClientConfig Nothing host partyPort (Just expected) $ \tls -> do - traceM "2PTTH server starting" - run tls - traceM "2PTTH server finished" - - run tls = runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r 16384 - processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} - - processRequest req = do - traceM $ "Got request: " <> show (request req) - -- TODO: sendResponse req . HTTP2.promiseResponse $ HTTP2.pushPromise path response weight - sendResponse req $ HTTP2.responseStreaming HTTP.ok200 sseHeaders sseExample - - sseHeaders = [(HTTP.hContentType, "text/event-stream")] - - sseExample :: (Builder -> IO ()) -> IO () -> IO () - sseExample write flush = forM_ [1 .. 10] $ \i -> do - let payload = "[" <> intDec i <> ", \"blah\"]" - write "event: message\n" -- XXX: SSE header line - write $ "data: " <> payload <> "\n" -- XXX: SSE payload line - write "\n" -- XXX: SSE delimiter - flush - threadDelay 1000000 + go sock + Right inviteHash -> do + expected <- getFingerprints + case lookup inviteHash expected of + Nothing -> do + traceM $ "Unexpected invite: " <> show (invite, source) + go sock + Just ctx -> do + host <- case source of + N.SockAddrInet _port addr -> do + pure $ THIPv4 (N.hostAddressToTuple addr) + unexpected -> + -- TODO: actually, Apple mandates IPv6 support + fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected + runTransportClient defaultTransportClientConfig Nothing host partyPort (Just inviteHash) $ \tls -> do + accepted <- started ctx + if not accepted + then go sock -- Ignore rejected invites and wait for another + else do + res <- try $ runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r 16384 + processRequest ctx HTTP2Request {sessionId, request = r, reqBody, sendResponse} + finished ctx $ either Just (\() -> Nothing) res diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 9f28eab551..53f73c3389 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -8,6 +8,7 @@ import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import UnliftIO.STM type RemoteHostId = Int64 @@ -22,12 +23,13 @@ data RemoteHost = RemoteHost caKey :: C.Key } -type RemoteCtrlId = Int +type RemoteCtrlId = Int64 data RemoteCtrl = RemoteCtrl { remoteCtrlId :: RemoteCtrlId, displayName :: Text, - fingerprint :: Text + fingerprint :: C.KeyHash, + accepted :: Maybe Bool } data RemoteHostSession = RemoteHostSession @@ -36,9 +38,8 @@ data RemoteHostSession = RemoteHostSession ctrlClient :: HTTP2Client } --- | Host-side dual to RemoteHostSession, on-methods represent HTTP API. data RemoteCtrlSession = RemoteCtrlSession - { -- | process to communicate with the remote controller - ctrlAsync :: Async () - -- server :: HTTP2Server + { -- | Server side of transport to process remote commands and forward notifications + ctrlAsync :: Async (), + accepted :: TMVar Bool } diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 12fcb6c081..f185000d88 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -7,7 +8,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import qualified Database.SQLite.Simple as DB -import Simplex.Chat.Remote.Types (RemoteHostId, RemoteHost (..)) +import Simplex.Chat.Remote.Types (RemoteCtrl (..), RemoteCtrlId, RemoteHost (..), RemoteHostId) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) import qualified Simplex.Messaging.Crypto as C @@ -26,3 +27,27 @@ remoteHostQuery = "SELECT remote_host_id, display_name, store_path, ca_cert, ca_ toRemoteHost :: (Int64, Text, FilePath, ByteString, C.Key) -> RemoteHost toRemoteHost (remoteHostId, displayName, storePath, caCert, caKey) = RemoteHost {remoteHostId, displayName, storePath, caCert, caKey} + +getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] +getRemoteCtrls db = + map toRemoteCtrl <$> DB.query_ db remoteCtrlQuery + +getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO (Maybe RemoteCtrl) +getRemoteCtrl db remoteCtrlId = + maybeFirstRow toRemoteCtrl $ + DB.query db (remoteCtrlQuery <> "WHERE remote_controller_id = ?") (DB.Only remoteCtrlId) + +remoteCtrlQuery :: DB.Query +remoteCtrlQuery = "SELECT remote_controller_id, display_name, fingerprint, accepted FROM remote_controllers" + +toRemoteCtrl :: (Int64, Text, C.KeyHash, Maybe Bool) -> RemoteCtrl +toRemoteCtrl (remoteCtrlId, displayName, fingerprint, accepted) = + RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} + +markRemoteCtrlResolution :: DB.Connection -> RemoteCtrlId -> Bool -> IO () +markRemoteCtrlResolution db remoteCtrlId accepted = + DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ?" (accepted, remoteCtrlId) + +deleteRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO () +deleteRemoteCtrl db remoteCtrlId = + DB.execute db "DELETE FROM remote_controllers WHERE remote_controller_id = ?" (DB.Only remoteCtrlId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1f110fc951..1761b384ac 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1678,7 +1678,7 @@ viewChatError logLevel = \case Nothing -> "" cId :: Connection -> StyledString cId conn = sShow conn.connId - ChatErrorRemoteCtrl remoteCtrlId todo'rc -> [sShow remoteCtrlId, sShow todo'rc] + ChatErrorRemoteCtrl todo'rc -> [sShow todo'rc] ChatErrorRemoteHost remoteHostId todo'rh -> [sShow remoteHostId, sShow todo'rh] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] From af2df8d4892216719cf9745cfef1e2834b980c89 Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Fri, 29 Sep 2023 14:56:56 +0300 Subject: [PATCH 004/174] Rewrite remote controller --- src/Simplex/Chat.hs | 110 ++--------------- src/Simplex/Chat/Controller.hs | 53 +++++++- .../Migrations/M20230922_remote_controller.hs | 2 +- src/Simplex/Chat/Remote.hs | 113 ++++++++++++++++-- src/Simplex/Chat/Remote/Discovery.hs | 89 +++++++------- src/Simplex/Chat/Remote/Types.hs | 8 +- src/Simplex/Chat/Store/Remote.hs | 7 +- 7 files changed, 216 insertions(+), 166 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 849bff97fd..ef2f95f67d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -19,7 +19,6 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -63,7 +62,6 @@ import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Remote -import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -72,7 +70,6 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles -import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -367,19 +364,6 @@ execRemoteCommand u rh scmd = either (CRChatCmdError u) id <$> runExceptT (withR parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace --- | Emit local events. -toView :: ChatMonad' m => ChatResponse -> m () -toView = toView_ Nothing - --- | Used by transport to mark remote events with source. -toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () -toViewRemote = toView_ . Just - -toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () -toView_ rh event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, rh, event) - -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case @@ -1852,69 +1836,16 @@ processChatCommand = \case p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} CreateRemoteHost _displayName -> pure $ chatCmdError Nothing "not supported" ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" - StartRemoteHost rh -> do - RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" - (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) - cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete rh) - Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case - Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {storePath, ctrlClient} - pure $ CRRemoteHostStarted rh + StartRemoteHost rh -> startRemoteHost rh StopRemoteHost rh -> closeRemoteHostSession rh $> CRRemoteHostStopped rh DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + StartRemoteCtrl -> startRemoteCtrl + ConfirmRemoteCtrl rc -> confirmRemoteCtrl rc + RejectRemoteCtrl rc -> rejectRemoteCtrl rc + StopRemoteCtrl rc -> stopRemoteCtrl rc RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" - StartRemoteCtrl -> - chatReadVar remoteCtrlSession >>= \case - Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy - Nothing -> do - uio <- askUnliftIO - accepted <- newEmptyTMVarIO - let getControllers = unliftIO uio $ withStore' $ \db -> - map (\RemoteCtrl{remoteCtrlId, fingerprint} -> (fingerprint, remoteCtrlId)) <$> getRemoteCtrls (DB.conn db) - let started remoteCtrlId = unliftIO uio $ do - withStore' (\db -> getRemoteCtrl (DB.conn db) remoteCtrlId) >>= \case - Nothing -> pure False - Just RemoteCtrl{displayName, accepted=resolution} -> case resolution of - Nothing -> do - -- started/finished wrapper is synchronous, running HTTP server can be delayed here until UI processes the first contact dialogue - toView $ CRRemoteCtrlFirstContact {remoteCtrlId, displayName} - atomically $ takeTMVar accepted - Just known -> atomically $ putTMVar accepted known $> known - let finished remoteCtrlId todo'error = unliftIO uio $ do - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlDisconnected {remoteCtrlId} - let process rc req = unliftIO uio $ processControllerCommand rc req - ctrlAsync <- async . liftIO $ Discovery.runDiscoverer getControllers started finished process - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync, accepted} - pure CRRemoteCtrlStarted - ConfirmRemoteCtrl remoteCtrlId -> do - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True - atomically $ putTMVar accepted True - pure $ CRRemoteCtrlAccepted {remoteCtrlId} - RejectRemoteCtrl remoteCtrlId -> do - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False - atomically $ putTMVar accepted False - pure $ CRRemoteCtrlRejected {remoteCtrlId} - StopRemoteCtrl remoteCtrlId -> - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {ctrlAsync} -> do - cancel ctrlAsync - pure $ CRRemoteCtrlDisconnected {remoteCtrlId} - DisposeRemoteCtrl remoteCtrlId -> - chatReadVar remoteCtrlSession >>= \case - Nothing -> do - withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId - pure $ CRRemoteCtrlDisposed {remoteCtrlId} - Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy + DisposeRemoteCtrl rc -> disposeRemoteCtrl rc QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -5410,33 +5341,6 @@ withAgent action = >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) -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 - ChatController {chatStore} <- ask - liftEitherError ChatErrorStore $ case ctx_ of - Nothing -> withTransaction chatStore (runExceptT . action) `E.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) `E.catch` handleInternal "" - where - handleInternal :: String -> E.SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr - chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -5689,8 +5593,8 @@ chatCommandP = "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), "/dispose remote host " *> (DisposeRemoteHost <$> A.decimal), - "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), "/start remote ctrl" $> StartRemoteCtrl, + "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 82a1cbcede..0598bba8f1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -47,7 +47,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types -import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) @@ -57,6 +57,8 @@ import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction) +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 @@ -67,7 +69,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>)) +import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) import System.Mem.Weak (Weak) @@ -603,11 +605,14 @@ data ChatResponse | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlStarted - | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} + | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation + | CRRemoteCtrlFound {remoteCtrl::RemoteCtrl} -- registered fingerprint, may connect + -- | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} - | CRRemoteCtrlDisconnected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlStopped {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlDisposed {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -1106,3 +1111,43 @@ data ArchiveError instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" + +-- | Emit local events. +toView :: ChatMonad' m => ChatResponse -> m () +toView = toView_ Nothing + +-- | Used by transport to mark remote events with source. +toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () +toViewRemote = toView_ . Just + +toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () +toView_ rh event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, rh, event) + +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 + 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 diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs index 4890bfc22a..d2ca386b0e 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -20,7 +20,7 @@ CREATE TABLE remote_controllers ( -- controllers known to a hosting app remote_controller_id INTEGER PRIMARY KEY, display_name TEXT NOT NULL, fingerprint BLOB NOT NULL, - accepted INTEGER + accepted INTEGER -- unknown/rejected/confirmed ); |] diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8e2bedc979..4671f43537 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -15,14 +15,23 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.Map.Strict as M import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP2.Client as HTTP2Client +import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller +import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Remote import Simplex.Chat.Types +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow) import System.Directory (getFileSize) +import UnliftIO withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do @@ -30,6 +39,17 @@ withRemoteHostSession remoteHostId action = do where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing +startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +startRemoteHost remoteHostId = do + RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" + (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) + cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete remoteHostId) + Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case + Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSession {storePath, ctrlClient} + pure $ CRRemoteHostStarted remoteHostId + closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) @@ -68,10 +88,10 @@ relayCommand RemoteHostSession {ctrlClient} s = storeRemoteFile :: (ChatMonad m) => RemoteHostSession -> FilePath -> m ChatResponse storeRemoteFile RemoteHostSession {ctrlClient} localFile = do postFile Nothing ctrlClient "/store" mempty localFile >>= \case - Left e -> error "TODO: http2chatError" + Left todo'err -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of Just 200 -> pure $ CRCmdOk Nothing - unexpected -> error "TODO: http2chatError" + todo'notOk -> error "TODO: http2chatError" where postFile timeout c path hs file = liftIO $ do fileSize <- fromIntegral <$> getFileSize file @@ -95,11 +115,88 @@ sum2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip --- withRemoteCtrlSession :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrlSession -> m a) -> m a --- withRemoteCtrlSession remoteCtrlId action = do --- chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteCtrlId --- where --- err = throwError $ ChatErrorRemoteCtrl (Just remoteCtrlId) RCMissing - processControllerCommand :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () processControllerCommand rc req = error "TODO: processControllerCommand" + +-- * ChatRequest handlers + +startRemoteCtrl :: (ChatMonad m) => m ChatResponse +startRemoteCtrl = + chatReadVar remoteCtrlSession >>= \case + Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy + Nothing -> do + accepted <- newEmptyTMVarIO + discovered <- newTVarIO mempty + listener <- async $ discoverRemoteCtrls discovered + _supervisor <- async $ do + uiEvent <- async $ atomically $ readTMVar accepted + waitEitherCatchCancel listener uiEvent >>= \case + Left _ -> pure () -- discover got cancelled or crashed on some UDP error + Right (Left _) -> pure () -- readTMVar blocked indefinitely (should not happen) + Right (Right remoteCtrlId) -> do + -- got connection confirmation + (source, fingerprint) <- + atomically $ + TM.lookup remoteCtrlId discovered >>= \case + Nothing -> error "Session accepted without getting registered" + Just found -> found <$ writeTVar discovered mempty -- flush unused sources + host <- async $ runRemoteHost remoteCtrlId source fingerprint + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} + _ <- waitCatch host + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = listener, accepted} + pure CRRemoteCtrlStarted + +discoverRemoteCtrls :: (ChatMonad m) => TM.TMap RemoteCtrlId (TransportHost, C.KeyHash) -> m () +discoverRemoteCtrls discovered = Discovery.openListener >>= go + where + go sock = + Discovery.recvAnnounce sock >>= \case + (SockAddrInet _port addr, invite) -> case strDecode invite of + Left _ -> go sock -- ignore malformed datagrams + Right fingerprint -> do + withStore' (\db -> getRemoteCtrlByFingerprint (DB.conn db) fingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint + Just found@RemoteCtrl {remoteCtrlId} -> do + atomically $ TM.insert remoteCtrlId (THIPv4 (hostAddressToTuple addr), fingerprint) discovered + toView $ CRRemoteCtrlFound found + _nonV4 -> go sock + +runRemoteHost :: (ChatMonad m) => RemoteCtrlId -> TransportHost -> C.KeyHash -> m () +runRemoteHost remoteCtrlId remoteCtrlHost fingerprint = + Discovery.connectSessionHost remoteCtrlHost fingerprint $ Discovery.attachServer (processControllerCommand remoteCtrlId) + +confirmRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +confirmRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {accepted} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True + atomically $ putTMVar accepted remoteCtrlId -- the remote host can now proceed with connection + pure $ CRRemoteCtrlAccepted {remoteCtrlId} + +rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +rejectRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {ctrlAsync} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False + cancel ctrlAsync + pure $ CRRemoteCtrlRejected {remoteCtrlId} + +stopRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +stopRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {ctrlAsync} -> do + cancel ctrlAsync + pure CRRemoteCtrlStopped {remoteCtrlId} + +disposeRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +disposeRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> do + withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId + pure $ CRRemoteCtrlDisposed {remoteCtrlId} + Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index cb668677f5..f04d0a008a 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -2,14 +2,22 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} module Simplex.Chat.Remote.Discovery - ( runAnnouncer, - runDiscoverer, + ( -- * Announce + runAnnouncer, + + -- * Discovery + openListener, + recvAnnounce, + connectSessionHost, + attachServer, ) where import Control.Monad +import Data.ByteString (ByteString) import Data.Default (def) import Data.String (IsString) import Debug.Trace @@ -28,6 +36,13 @@ import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTran import UnliftIO import UnliftIO.Concurrent +-- | Link-local broadcast address. +pattern BROADCAST_ADDR_V4 :: (IsString a, Eq a) => a +pattern BROADCAST_ADDR_V4 = "255.255.255.255" + +pattern BROADCAST_PORT :: (IsString a, Eq a) => a +pattern BROADCAST_PORT = "5226" + runAnnouncer :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) runAnnouncer finished invite credentials = do started <- newEmptyTMVarIO @@ -40,7 +55,7 @@ runAnnouncer finished invite credentials = do TLS.serverSupported = supportedParameters } httpClient <- newEmptyMVar - liftIO $ runTransportServer started partyPort serverParams defaultTransportServerConfig (run aPid httpClient) + liftIO $ runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig (run aPid httpClient) takeMVar httpClient where announcer started inviteBS = do @@ -48,10 +63,10 @@ runAnnouncer finished invite credentials = do False -> error "Server not started?.." True -> liftIO $ do - traceM $ "TCP server started at " <> partyPort - sock <- UDP.clientSocket broadcastAddrV4 partyPort False + traceM $ "TCP server started at " <> BROADCAST_PORT + sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - traceM $ "UDP announce started at " <> broadcastAddrV4 <> ":" <> partyPort + traceM $ "UDP announce started at " <> BROADCAST_ADDR_V4 <> ":" <> BROADCAST_PORT traceM $ "Server invite: " <> show inviteBS forever $ do UDP.send sock inviteBS @@ -61,47 +76,25 @@ runAnnouncer finished invite credentials = do run aPid clientVar tls = do cancel aPid let partyHost = "255.255.255.255" -- XXX: get from tls somehow? not required as host verification is disabled. - attachHTTP2Client defaultHTTP2ClientConfig partyHost partyPort finished defaultHTTP2BufferSize tls >>= putMVar clientVar + attachHTTP2Client defaultHTTP2ClientConfig partyHost BROADCAST_PORT finished defaultHTTP2BufferSize tls >>= putMVar clientVar --- | Link-local broadcast address. -broadcastAddrV4 :: (IsString a) => a -broadcastAddrV4 = "255.255.255.255" - -partyPort :: (IsString a) => a -partyPort = "5226" -- XXX: should be `0` or something, to get a random port and announce it - -runDiscoverer :: IO [(C.KeyHash, ctx)] -> (ctx -> IO Bool) -> (ctx -> Maybe SomeException -> IO ()) -> (ctx -> HTTP2Request -> IO ()) -> IO () -runDiscoverer getFingerprints started finished processRequest = do - sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) +openListener :: (MonadIO m) => m UDP.ListenSocket +openListener = liftIO $ do + sock <- UDP.serverSocket (BROADCAST_ADDR_V4, read BROADCAST_PORT) N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 - traceM $ "runDiscoverer: " <> show sock - go sock - where - go sock = do - (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock - case strDecode invite of - Left err -> do - traceM $ "Inivite decode error: " <> err - go sock - Right inviteHash -> do - expected <- getFingerprints - case lookup inviteHash expected of - Nothing -> do - traceM $ "Unexpected invite: " <> show (invite, source) - go sock - Just ctx -> do - host <- case source of - N.SockAddrInet _port addr -> do - pure $ THIPv4 (N.hostAddressToTuple addr) - unexpected -> - -- TODO: actually, Apple mandates IPv6 support - fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected - runTransportClient defaultTransportClientConfig Nothing host partyPort (Just inviteHash) $ \tls -> do - accepted <- started ctx - if not accepted - then go sock -- Ignore rejected invites and wait for another - else do - res <- try $ runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r 16384 - processRequest ctx HTTP2Request {sessionId, request = r, reqBody, sendResponse} - finished ctx $ either Just (\() -> Nothing) res + pure sock + +recvAnnounce :: (MonadIO m) => UDP.ListenSocket -> m (N.SockAddr, ByteString) +recvAnnounce sock = liftIO $ do + (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock + pure (source, invite) + +connectSessionHost :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a +connectSessionHost host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) + +attachServer :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () +attachServer processRequest tls = do + withRunInIO $ \unlift -> + runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r defaultHTTP2BufferSize + unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 53f73c3389..fa9b3fb359 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,11 +1,15 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) +import Data.Aeson (ToJSON) import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) +import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import UnliftIO.STM @@ -22,6 +26,7 @@ data RemoteHost = RemoteHost -- | Credentials signing key for root and session certs caKey :: C.Key } + deriving (Show) type RemoteCtrlId = Int64 @@ -31,6 +36,7 @@ data RemoteCtrl = RemoteCtrl fingerprint :: C.KeyHash, accepted :: Maybe Bool } + deriving (Show, Generic, ToJSON) data RemoteHostSession = RemoteHostSession { -- | Path for local resources to be synchronized with host @@ -41,5 +47,5 @@ data RemoteHostSession = RemoteHostSession data RemoteCtrlSession = RemoteCtrlSession { -- | Server side of transport to process remote commands and forward notifications ctrlAsync :: Async (), - accepted :: TMVar Bool + accepted :: TMVar RemoteCtrlId } diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index f185000d88..591f346bee 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -37,6 +37,11 @@ getRemoteCtrl db remoteCtrlId = maybeFirstRow toRemoteCtrl $ DB.query db (remoteCtrlQuery <> "WHERE remote_controller_id = ?") (DB.Only remoteCtrlId) +getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) +getRemoteCtrlByFingerprint db fingerprint = + maybeFirstRow toRemoteCtrl $ + DB.query db (remoteCtrlQuery <> "WHERE fingerprint = ?") (DB.Only fingerprint) + remoteCtrlQuery :: DB.Query remoteCtrlQuery = "SELECT remote_controller_id, display_name, fingerprint, accepted FROM remote_controllers" @@ -46,7 +51,7 @@ toRemoteCtrl (remoteCtrlId, displayName, fingerprint, accepted) = markRemoteCtrlResolution :: DB.Connection -> RemoteCtrlId -> Bool -> IO () markRemoteCtrlResolution db remoteCtrlId accepted = - DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ?" (accepted, remoteCtrlId) + DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ? AND accepted IS NULL" (accepted, remoteCtrlId) deleteRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO () deleteRemoteCtrl db remoteCtrlId = From 6c0d1b5f153266cd62b00e709a8be1b6c3e7f60a Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Fri, 29 Sep 2023 16:53:05 +0300 Subject: [PATCH 005/174] Notify about handover errors --- cabal.project | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Remote.hs | 23 +++++++++++------------ src/Simplex/Chat/Remote/Types.hs | 8 +++++++- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/cabal.project b/cabal.project index 7d7339a7fb..af664652db 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 681fa93bf342d7c836fa0ff69b767dcd08526f03 + tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 source-repository-package type: git diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0598bba8f1..690f78ea4f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -913,7 +913,7 @@ data ChatError | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} - | ChatErrorRemoteCtrl {remoteControllerError :: RemoteCtrlError} + | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4671f43537..82d2e9e630 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -132,19 +132,18 @@ startRemoteCtrl = uiEvent <- async $ atomically $ readTMVar accepted waitEitherCatchCancel listener uiEvent >>= \case Left _ -> pure () -- discover got cancelled or crashed on some UDP error - Right (Left _) -> pure () -- readTMVar blocked indefinitely (should not happen) - Right (Right remoteCtrlId) -> do + Right (Left _) -> toView . CRChatError Nothing . ChatError $ CEException "Crashed while waiting for remote session confirmation" + Right (Right remoteCtrlId) -> -- got connection confirmation - (source, fingerprint) <- - atomically $ - TM.lookup remoteCtrlId discovered >>= \case - Nothing -> error "Session accepted without getting registered" - Just found -> found <$ writeTVar discovered mempty -- flush unused sources - host <- async $ runRemoteHost remoteCtrlId source fingerprint - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} - _ <- waitCatch host - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} + atomically (TM.lookup remoteCtrlId discovered) >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote session accepted without getting discovered first" + Just (source, fingerprint) -> do + atomically $ writeTVar discovered mempty -- flush unused sources + host <- async $ runRemoteHost remoteCtrlId source fingerprint + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} + _ <- waitCatch host + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = listener, accepted} pure CRRemoteCtrlStarted diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index fa9b3fb359..5902476fc1 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -5,7 +5,7 @@ module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) -import Data.Aeson (ToJSON) +import Data.Aeson (ToJSON (..)) import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) @@ -13,6 +13,7 @@ import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import UnliftIO.STM +import Simplex.Messaging.Encoding.String (strToJEncoding, strToJSON) type RemoteHostId = Int64 @@ -38,6 +39,11 @@ data RemoteCtrl = RemoteCtrl } deriving (Show, Generic, ToJSON) +-- XXX: until fixed in master +instance ToJSON C.KeyHash where + toEncoding = strToJEncoding + toJSON = strToJSON + data RemoteHostSession = RemoteHostSession { -- | Path for local resources to be synchronized with host storePath :: FilePath, From 0bcf5c9c66e82b2aee2bac2b39b4648d96cc6e7d Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 4 Oct 2023 18:36:10 +0300 Subject: [PATCH 006/174] Add commands for remote session credentials (#3161) * Add remote host commands * Make startRemoteHost async * Add tests * Trim randomStorePath to 16 chars * Add chat command tests * add view, use view output in test * enable all tests * Fix discovery listener host Must use any, not broadcast on macos. * Fix missing do * address, names * Fix session host flow * fix test --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 29 +-- src/Simplex/Chat/Controller.hs | 29 ++- .../Migrations/M20230922_remote_controller.hs | 19 +- src/Simplex/Chat/Migrations/chat_schema.sql | 18 +- src/Simplex/Chat/Remote.hs | 238 ++++++++++++------ src/Simplex/Chat/Remote/Discovery.hs | 100 ++++---- src/Simplex/Chat/Remote/Types.hs | 38 +-- src/Simplex/Chat/Store/Remote.hs | 42 ++-- src/Simplex/Chat/View.hs | 46 +++- tests/RemoteTests.hs | 148 +++++++++++ tests/Test.hs | 2 + 13 files changed, 515 insertions(+), 197 deletions(-) create mode 100644 tests/RemoteTests.hs diff --git a/cabal.project b/cabal.project index af664652db..f5bb879762 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 + tag: 753a6c7542c3764fda9ce3f4c4cdc9f2329816d3 source-repository-package type: git diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 986c4966b0..75ca58c432 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -490,6 +490,7 @@ test-suite simplex-chat-test MarkdownTests MobileTests ProtocolTests + RemoteTests SchemaDump ViewTests WebRTCTests diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 92a29b7ac9..0112b76373 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1835,18 +1835,18 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - CreateRemoteHost _displayName -> pure $ chatCmdError Nothing "not supported" - ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" + CreateRemoteHost -> createRemoteHost + ListRemoteHosts -> listRemoteHosts StartRemoteHost rh -> startRemoteHost rh - StopRemoteHost rh -> closeRemoteHostSession rh $> CRRemoteHostStopped rh - DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + StopRemoteHost rh -> closeRemoteHostSession rh + DeleteRemoteHost rh -> deleteRemoteHost rh StartRemoteCtrl -> startRemoteCtrl - ConfirmRemoteCtrl rc -> confirmRemoteCtrl rc + AcceptRemoteCtrl rc -> acceptRemoteCtrl rc RejectRemoteCtrl rc -> rejectRemoteCtrl rc StopRemoteCtrl rc -> stopRemoteCtrl rc - RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" - ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" - DisposeRemoteCtrl rc -> disposeRemoteCtrl rc + RegisterRemoteCtrl oob -> registerRemoteCtrl oob + ListRemoteCtrls -> listRemoteCtrls + DeleteRemoteCtrl rc -> deleteRemoteCtrl rc QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -5609,17 +5609,19 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, - "/create remote host" *> (CreateRemoteHost <$> textP), + "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), - "/dispose remote host " *> (DisposeRemoteHost <$> A.decimal), + "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/start remote ctrl" $> StartRemoteCtrl, - "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), - "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), + -- TODO *** you need to pass multiple parameters here + "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP)), + "/list remote ctrls" $> ListRemoteCtrls, + "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), - "/dispose remote ctrl " *> (DisposeRemoteCtrl <$> A.decimal), + "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -5737,7 +5739,6 @@ chatCommandP = srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char - remoteHostOOBP = RemoteHostOOB <$> textP adminContactReq :: ConnReqContact adminContactReq = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a5f3d55b67..9266e2292a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -414,18 +414,18 @@ data ChatCommand | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) - | CreateRemoteHost Text -- ^ Configure a new remote host + | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session - | DisposeRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data - | RegisterRemoteCtrl Text RemoteHostOOB -- ^ Register OOB data for satellite discovery and handshake + | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data + | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers | ListRemoteCtrls - | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm discovered data and store confirmation + | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session - | DisposeRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session + | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat | ShowVersion | DebugLocks @@ -597,22 +597,23 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} - | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteHostOOB} + | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteCtrlOOB} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup | CRRemoteHostStarted {remoteHostId :: RemoteHostId} + | CRRemoteHostConnected {remoteHostId :: RemoteHostId} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} - | CRRemoteHostDisposed {remoteHostId :: RemoteHostId} + | CRRemoteHostDeleted {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlStarted | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation - | CRRemoteCtrlFound {remoteCtrl::RemoteCtrl} -- registered fingerprint, may connect - -- | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} + | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlStopped {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlDisposed {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlDeleted {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -656,13 +657,14 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" -data RemoteHostOOB = RemoteHostOOB - { fingerprint :: Text -- CA key fingerprint +data RemoteCtrlOOB = RemoteCtrlOOB + { caFingerprint :: C.KeyHash } deriving (Show, Generic, ToJSON) data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, + storePath :: FilePath, displayName :: Text, sessionActive :: Bool } @@ -673,7 +675,7 @@ data RemoteCtrlInfo = RemoteCtrlInfo displayName :: Text, sessionActive :: Bool } - deriving (Show, Generic, ToJSON) + deriving (Eq, Show, Generic, ToJSON) newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -1052,6 +1054,7 @@ data RemoteCtrlError | RCEConnectionLost {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected due to transport issues | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection + | RCEBadFingerprint -- ^ Bad fingerprint data provided in OOB deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs index d2ca386b0e..21d653d124 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -9,18 +9,19 @@ m20230922_remote_controller :: Query m20230922_remote_controller = [sql| CREATE TABLE remote_hosts ( -- hosts known to a controlling app - remote_host_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - store_path TEXT NOT NULL, - ca_cert BLOB NOT NULL, - ca_key BLOB NOT NULL + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + store_path TEXT NOT NULL, -- file path relative to app store (must not contain "/") + display_name TEXT NOT NULL, -- user-provided name for a remote host + ca_key BLOB NOT NULL, -- private key for signing session certificates + ca_cert BLOB NOT NULL, -- root certificate, whose fingerprint is pinned on a remote + contacted INTEGER NOT NULL DEFAULT 0 -- 0 (first time), 1 (connected before) ); CREATE TABLE remote_controllers ( -- controllers known to a hosting app - remote_controller_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - fingerprint BLOB NOT NULL, - accepted INTEGER -- unknown/rejected/confirmed + remote_controller_id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, -- user-provided name for a remote controller + fingerprint BLOB NOT NULL, -- remote controller CA fingerprint + accepted INTEGER -- NULL (unknown), 0 (rejected), 1 (confirmed) ); |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index e6be03bcc2..36ebe61209 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -518,17 +518,19 @@ CREATE TABLE IF NOT EXISTS "received_probes"( ); CREATE TABLE remote_hosts( -- hosts known to a controlling app - remote_host_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - store_path TEXT NOT NULL, - ca_cert BLOB NOT NULL, - ca_key BLOB NOT NULL + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + store_path TEXT NOT NULL, -- file path relative to app store(must not contain "/") + display_name TEXT NOT NULL, -- user-provided name for a remote host + ca_key BLOB NOT NULL, -- private key for signing session certificates + ca_cert BLOB NOT NULL, -- root certificate, whose fingerprint is pinned on a remote + contacted INTEGER NOT NULL DEFAULT 0 -- 0(first time), 1(connected before) ); CREATE TABLE remote_controllers( -- controllers known to a hosting app - remote_controller_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - fingerprint BLOB NOT NULL + remote_controller_id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, -- user-provided name for a remote controller + fingerprint BLOB NOT NULL, -- remote controller CA fingerprint + accepted INTEGER -- NULL(unknown), 0(rejected), 1(confirmed) ); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 82d2e9e630..936c750c6c 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -7,11 +7,17 @@ module Simplex.Chat.Remote where +import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Control.Monad.STM (retry) +import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J import qualified Data.Binary.Builder as Binary -import Data.ByteString.Char8 (ByteString) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Char8 as B +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP2.Client as HTTP2Client @@ -21,12 +27,13 @@ import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Remote import Simplex.Chat.Types -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow) @@ -39,29 +46,82 @@ withRemoteHostSession remoteHostId action = do where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing +withRemoteHost :: (ChatMonad m) => RemoteHostId -> (RemoteHost -> m a) -> m a +withRemoteHost remoteHostId action = + withStore' (`getRemoteHost` remoteHostId) >>= \case + Nothing -> throwError $ ChatErrorRemoteHost remoteHostId RHMissing + Just rh -> action rh + startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse startRemoteHost remoteHostId = do - RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" - (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) - cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete remoteHostId) - Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case - Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSession {storePath, ctrlClient} - pure $ CRRemoteHostStarted remoteHostId + M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case + Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy + Nothing -> withRemoteHost remoteHostId run + where + run RemoteHost {storePath, caKey, caCert} = do + announcer <- async $ do + cleanup <- toIO $ closeRemoteHostSession remoteHostId >>= toView + let parent = (C.signatureKeyPair caKey, caCert) + sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" + let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] + Discovery.announceRevHTTP2 cleanup fingerprint credentials >>= \case + Left todo'err -> liftIO cleanup -- TODO: log error + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} + -- TODO: start streaming outputQ + toView CRRemoteHostConnected {remoteHostId} + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} + pure CRRemoteHostStarted {remoteHostId} -closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () -closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) +closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse +closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do + case session of + RemoteHostSessionStarting {announcer} -> cancel announcer + RemoteHostSessionStarted {ctrlClient} -> liftIO (HTTP2.closeHTTP2Client ctrlClient) + chatModifyVar remoteHostSessions $ M.delete remoteHostId + pure CRRemoteHostStopped { remoteHostId } + +createRemoteHost :: (ChatMonad m) => m ChatResponse +createRemoteHost = do + let displayName = "TODO" -- you don't have remote host name here, it will be passed from remote host + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName + storePath <- liftIO randomStorePath + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert + let oobData = + RemoteCtrlOOB + { caFingerprint = C.certificateFingerprint caCert + } + pure CRRemoteHostCreated {remoteHostId, oobData} + +-- | Generate a random 16-char filepath without / in it by using base64url encoding. +randomStorePath :: IO FilePath +randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 + +listRemoteHosts :: (ChatMonad m) => m ChatResponse +listRemoteHosts = do + stored <- withStore' getRemoteHosts + active <- chatReadVar remoteHostSessions + pure $ CRRemoteHostList $ do + RemoteHost {remoteHostId, storePath, displayName} <- stored + let sessionActive = M.member remoteHostId active + pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} + +deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do + -- TODO: delete files + withStore' $ \db -> deleteRemoteHostRecord db remoteHostId + pure CRRemoteHostDeleted {remoteHostId} processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand rhs = \case +processRemoteCommand RemoteHostSessionStarting {} _ = error "TODO: sending remote commands before session started" +processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = -- XXX: intercept and filter some commands -- TODO: store missing files on remote host - (s, _cmd) -> relayCommand rhs s + relayCommand ctrlClient s -relayCommand :: (ChatMonad m) => RemoteHostSession -> ByteString -> m ChatResponse -relayCommand RemoteHostSession {ctrlClient} s = - postBytestring Nothing ctrlClient "/relay" mempty s >>= \case +relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse +relayCommand http s = + postBytestring Nothing http "/relay" mempty s >>= \case Left e -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do remoteChatResponse <- @@ -85,9 +145,15 @@ relayCommand RemoteHostSession {ctrlClient} s = where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) -storeRemoteFile :: (ChatMonad m) => RemoteHostSession -> FilePath -> m ChatResponse -storeRemoteFile RemoteHostSession {ctrlClient} localFile = do - postFile Nothing ctrlClient "/store" mempty localFile >>= \case +-- | Convert swift single-field sum encoding into tagged/discriminator-field +sum2tagged :: J.Value -> J.Value +sum2tagged = \case + J.Object todo'convert -> J.Object todo'convert + skip -> skip + +storeRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> m ChatResponse +storeRemoteFile http localFile = do + postFile Nothing http "/store" mempty localFile >>= \case Left todo'err -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of Just 200 -> pure $ CRCmdOk Nothing @@ -99,9 +165,9 @@ storeRemoteFile RemoteHostSession {ctrlClient} localFile = do where req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) -fetchRemoteFile :: (ChatMonad m) => RemoteHostSession -> FileTransferId -> m ChatResponse -fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do - liftIO (HTTP2.sendRequest ctrlClient req Nothing) >>= \case +fetchRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> FileTransferId -> m ChatResponse +fetchRemoteFile http storePath remoteFileId = do + liftIO (HTTP2.sendRequest http req Nothing) >>= \case Left e -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {respBody} -> do error "TODO: stream body into a local file" -- XXX: consult headers for a file name? @@ -109,14 +175,8 @@ fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do req = HTTP2Client.requestNoBody "GET" path mempty path = "/fetch/" <> bshow remoteFileId --- | Convert swift single-field sum encoding into tagged/discriminator-field -sum2tagged :: J.Value -> J.Value -sum2tagged = \case - J.Object todo'convert -> J.Object todo'convert - skip -> skip - -processControllerCommand :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () -processControllerCommand rc req = error "TODO: processControllerCommand" +processControllerRequest :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () +processControllerRequest rc req = error "TODO: processControllerRequest" -- * ChatRequest handlers @@ -127,27 +187,23 @@ startRemoteCtrl = Nothing -> do accepted <- newEmptyTMVarIO discovered <- newTVarIO mempty - listener <- async $ discoverRemoteCtrls discovered - _supervisor <- async $ do - uiEvent <- async $ atomically $ readTMVar accepted - waitEitherCatchCancel listener uiEvent >>= \case - Left _ -> pure () -- discover got cancelled or crashed on some UDP error - Right (Left _) -> toView . CRChatError Nothing . ChatError $ CEException "Crashed while waiting for remote session confirmation" - Right (Right remoteCtrlId) -> - -- got connection confirmation - atomically (TM.lookup remoteCtrlId discovered) >>= \case - Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote session accepted without getting discovered first" - Just (source, fingerprint) -> do - atomically $ writeTVar discovered mempty -- flush unused sources - host <- async $ runRemoteHost remoteCtrlId source fingerprint - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} - _ <- waitCatch host - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = listener, accepted} + discoverer <- async $ discoverRemoteCtrls discovered + supervisor <- async $ do + remoteCtrlId <- atomically (readTMVar accepted) + withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do + source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure + toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName} + atomically $ writeTVar discovered mempty -- flush unused sources + server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest remoteCtrlId) + chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} + toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName} + _ <- waitCatch server + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted} pure CRRemoteCtrlStarted -discoverRemoteCtrls :: (ChatMonad m) => TM.TMap RemoteCtrlId (TransportHost, C.KeyHash) -> m () +discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.openListener >>= go where go sock = @@ -155,47 +211,77 @@ discoverRemoteCtrls discovered = Discovery.openListener >>= go (SockAddrInet _port addr, invite) -> case strDecode invite of Left _ -> go sock -- ignore malformed datagrams Right fingerprint -> do - withStore' (\db -> getRemoteCtrlByFingerprint (DB.conn db) fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint - Just found@RemoteCtrl {remoteCtrlId} -> do - atomically $ TM.insert remoteCtrlId (THIPv4 (hostAddressToTuple addr), fingerprint) discovered - toView $ CRRemoteCtrlFound found + atomically $ TM.insert fingerprint (THIPv4 $ hostAddressToTuple addr) discovered + withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui action required + Just found@RemoteCtrl {remoteCtrlId, accepted=storedChoice} -> case storedChoice of + Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui action required + Just False -> pure () -- skipping a rejected item + Just True -> chatReadVar remoteCtrlSession >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" + Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock -runRemoteHost :: (ChatMonad m) => RemoteCtrlId -> TransportHost -> C.KeyHash -> m () -runRemoteHost remoteCtrlId remoteCtrlHost fingerprint = - Discovery.connectSessionHost remoteCtrlHost fingerprint $ Discovery.attachServer (processControllerCommand remoteCtrlId) +registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m ChatResponse +registerRemoteCtrl RemoteCtrlOOB {caFingerprint} = do + let displayName = "TODO" -- maybe include into OOB data + remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint + pure $ CRRemoteCtrlRegistered {remoteCtrlId} -confirmRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -confirmRemoteCtrl remoteCtrlId = +listRemoteCtrls :: (ChatMonad m) => m ChatResponse +listRemoteCtrls = do + stored <- withStore' getRemoteCtrls + active <- + chatReadVar remoteCtrlSession >>= \case + Nothing -> pure Nothing + Just RemoteCtrlSession {accepted} -> atomically (tryReadTMVar accepted) + pure $ CRRemoteCtrlList $ do + RemoteCtrl {remoteCtrlId, displayName} <- stored + let sessionActive = active == Just remoteCtrlId + pure RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} + +acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +acceptRemoteCtrl remoteCtrlId = do + withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId True chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True - atomically $ putTMVar accepted remoteCtrlId -- the remote host can now proceed with connection - pure $ CRRemoteCtrlAccepted {remoteCtrlId} + Just RemoteCtrlSession {accepted} -> atomically . void $ tryPutTMVar accepted remoteCtrlId -- the remote host can now proceed with connection + pure $ CRRemoteCtrlAccepted {remoteCtrlId} rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -rejectRemoteCtrl remoteCtrlId = +rejectRemoteCtrl remoteCtrlId = do + withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId False chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {ctrlAsync} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False - cancel ctrlAsync - pure $ CRRemoteCtrlRejected {remoteCtrlId} + Just RemoteCtrlSession {discoverer, supervisor} -> do + cancel discoverer + cancel supervisor + pure $ CRRemoteCtrlRejected {remoteCtrlId} stopRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse stopRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {ctrlAsync} -> do - cancel ctrlAsync - pure CRRemoteCtrlStopped {remoteCtrlId} + Just RemoteCtrlSession {discoverer, supervisor, hostServer} -> do + cancel discoverer -- may be gone by now + case hostServer of + Just host -> cancel host -- supervisor will clean up + Nothing -> do + cancel supervisor -- supervisor is blocked until session progresses + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} + pure $ CRCmdOk Nothing -disposeRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -disposeRemoteCtrl remoteCtrlId = +deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +deleteRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case Nothing -> do - withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId - pure $ CRRemoteCtrlDisposed {remoteCtrlId} + withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId + pure $ CRRemoteCtrlDeleted {remoteCtrlId} Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy + +withRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrl -> m a) -> m a +withRemoteCtrl remoteCtrlId action = + withStore' (`getRemoteCtrl` remoteCtrlId) >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEMissing {remoteCtrlId} + Just rc -> action rc diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index f04d0a008a..2faed66cd8 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -1,18 +1,21 @@ {-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} module Simplex.Chat.Remote.Discovery ( -- * Announce + announceRevHTTP2, runAnnouncer, + startTLSServer, + runHTTP2Client, -- * Discovery + connectRevHTTP2, openListener, recvAnnounce, - connectSessionHost, - attachServer, + connectTLSClient, + attachHTTP2Server, ) where @@ -20,7 +23,6 @@ import Control.Monad import Data.ByteString (ByteString) import Data.Default (def) import Data.String (IsString) -import Debug.Trace import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP @@ -33,54 +35,65 @@ import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) +import Simplex.Messaging.Util (whenM) import UnliftIO import UnliftIO.Concurrent -- | Link-local broadcast address. pattern BROADCAST_ADDR_V4 :: (IsString a, Eq a) => a -pattern BROADCAST_ADDR_V4 = "255.255.255.255" +pattern BROADCAST_ADDR_V4 = "0.0.0.0" + +pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a +pattern ANY_ADDR_V4 = "0.0.0.0" pattern BROADCAST_PORT :: (IsString a, Eq a) => a pattern BROADCAST_PORT = "5226" -runAnnouncer :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) -runAnnouncer finished invite credentials = do - started <- newEmptyTMVarIO - aPid <- async $ announcer started (strEncode invite) - let serverParams = - def - { TLS.serverWantClientCert = False, - TLS.serverShared = def {TLS.sharedCredentials = credentials}, - TLS.serverHooks = def, - TLS.serverSupported = supportedParameters - } +-- | Announce tls server, wait for connection and attach http2 client to it. +-- +-- Announcer is started when TLS server is started and stopped when a connection is made. +announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 finishAction invite credentials = do httpClient <- newEmptyMVar - liftIO $ runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig (run aPid httpClient) - takeMVar httpClient - where - announcer started inviteBS = do - atomically (takeTMVar started) >>= \case - False -> - error "Server not started?.." - True -> liftIO $ do - traceM $ "TCP server started at " <> BROADCAST_PORT - sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False - N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - traceM $ "UDP announce started at " <> BROADCAST_ADDR_V4 <> ":" <> BROADCAST_PORT - traceM $ "Server invite: " <> show inviteBS - forever $ do - UDP.send sock inviteBS - threadDelay 1000000 + started <- newEmptyTMVarIO + finished <- newEmptyMVar + announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ runAnnouncer (strEncode invite) + tlsServer <- startTLSServer started credentials $ \tls -> cancel announcer >> runHTTP2Client finished httpClient tls + _ <- forkIO . liftIO $ do + readMVar finished + cancel tlsServer + finishAction + readMVar httpClient - run :: Async () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () - run aPid clientVar tls = do - cancel aPid - let partyHost = "255.255.255.255" -- XXX: get from tls somehow? not required as host verification is disabled. - attachHTTP2Client defaultHTTP2ClientConfig partyHost BROADCAST_PORT finished defaultHTTP2BufferSize tls >>= putMVar clientVar +-- | Broadcast invite with link-local datagrams +runAnnouncer :: ByteString -> IO () +runAnnouncer inviteBS = do + sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False + N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 + forever $ do + UDP.send sock inviteBS + threadDelay 1000000 + +startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) +startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig + where + serverParams = + def + { TLS.serverWantClientCert = False, + TLS.serverShared = def {TLS.sharedCredentials = credentials}, + TLS.serverHooks = def, + TLS.serverSupported = supportedParameters + } + +-- | Attach HTTP2 client and hold the TLS until the attached client finishes. +runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () +runHTTP2Client finishedVar clientVar tls = do + attachHTTP2Client defaultHTTP2ClientConfig ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar + readMVar finishedVar openListener :: (MonadIO m) => m UDP.ListenSocket openListener = liftIO $ do - sock <- UDP.serverSocket (BROADCAST_ADDR_V4, read BROADCAST_PORT) + sock <- UDP.serverSocket (ANY_ADDR_V4, read BROADCAST_PORT) N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 pure sock @@ -89,11 +102,14 @@ recvAnnounce sock = liftIO $ do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock pure (source, invite) -connectSessionHost :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectSessionHost host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) +connectRevHTTP2 :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (HTTP2Request -> m ()) -> m () +connectRevHTTP2 host fingerprint = connectTLSClient host fingerprint . attachHTTP2Server -attachServer :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () -attachServer processRequest tls = do +connectTLSClient :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a +connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) + +attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () +attachHTTP2Server processRequest tls = do withRunInIO $ \unlift -> runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do reqBody <- getHTTP2Body r defaultHTTP2BufferSize diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 5902476fc1..b66e9a6253 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -6,26 +6,26 @@ module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) import Data.Aeson (ToJSON (..)) -import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.TMap (TMap) +import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import UnliftIO.STM -import Simplex.Messaging.Encoding.String (strToJEncoding, strToJSON) type RemoteHostId = Int64 data RemoteHost = RemoteHost { remoteHostId :: RemoteHostId, - displayName :: Text, - -- | Path to store replicated files storePath :: FilePath, - -- | A stable part of X509 credentials used to access the host - caCert :: ByteString, + displayName :: Text, -- | Credentials signing key for root and session certs - caKey :: C.Key + caKey :: C.APrivateSignKey, + -- | A stable part of TLS credentials used in remote session + caCert :: C.SignedCertificate, + contacted :: Bool } deriving (Show) @@ -39,19 +39,21 @@ data RemoteCtrl = RemoteCtrl } deriving (Show, Generic, ToJSON) --- XXX: until fixed in master -instance ToJSON C.KeyHash where - toEncoding = strToJEncoding - toJSON = strToJSON - -data RemoteHostSession = RemoteHostSession - { -- | Path for local resources to be synchronized with host - storePath :: FilePath, - ctrlClient :: HTTP2Client - } +data RemoteHostSession + = RemoteHostSessionStarting + { announcer :: Async () + } + | RemoteHostSessionStarted + { -- | Path for local resources to be synchronized with host + storePath :: FilePath, + ctrlClient :: HTTP2Client + } data RemoteCtrlSession = RemoteCtrlSession { -- | Server side of transport to process remote commands and forward notifications - ctrlAsync :: Async (), + discoverer :: Async (), + supervisor :: Async (), + hostServer :: Maybe (Async ()), + discovered :: TMap C.KeyHash TransportHost, accepted :: TMVar RemoteCtrlId } diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 591f346bee..c231a535b5 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -4,14 +4,20 @@ module Simplex.Chat.Store.Remote where -import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) -import qualified Database.SQLite.Simple as DB +import Database.SQLite.Simple (Only (..)) +import qualified Database.SQLite.Simple as SQL +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Chat.Remote.Types (RemoteCtrl (..), RemoteCtrlId, RemoteHost (..), RemoteHostId) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) import qualified Simplex.Messaging.Crypto as C +insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId +insertRemoteHost db storePath displayName caKey caCert = do + DB.execute db "INSERT INTO remote_hosts (store_path, display_name, ca_key, ca_cert) VALUES (?,?,?,?)" (storePath, displayName, caKey, C.SignedObject caCert) + fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" + getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = map toRemoteHost <$> DB.query_ db remoteHostQuery @@ -19,14 +25,22 @@ getRemoteHosts db = getRemoteHost :: DB.Connection -> RemoteHostId -> IO (Maybe RemoteHost) getRemoteHost db remoteHostId = maybeFirstRow toRemoteHost $ - DB.query db (remoteHostQuery <> "WHERE remote_host_id = ?") (DB.Only remoteHostId) + DB.query db (remoteHostQuery <> " WHERE remote_host_id = ?") (Only remoteHostId) -remoteHostQuery :: DB.Query -remoteHostQuery = "SELECT remote_host_id, display_name, store_path, ca_cert, ca_key FROM remote_hosts" +remoteHostQuery :: SQL.Query +remoteHostQuery = "SELECT remote_host_id, store_path, display_name, ca_key, ca_cert, contacted FROM remote_hosts" -toRemoteHost :: (Int64, Text, FilePath, ByteString, C.Key) -> RemoteHost -toRemoteHost (remoteHostId, displayName, storePath, caCert, caKey) = - RemoteHost {remoteHostId, displayName, storePath, caCert, caKey} +toRemoteHost :: (Int64, FilePath, Text, C.APrivateSignKey, C.SignedObject C.Certificate, Bool) -> RemoteHost +toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert, contacted) = + RemoteHost {remoteHostId, storePath, displayName, caKey, caCert, contacted} + +deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () +deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) + +insertRemoteCtrl :: DB.Connection -> Text -> C.KeyHash -> IO RemoteCtrlId +insertRemoteCtrl db displayName fingerprint = do + DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint) + fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] getRemoteCtrls db = @@ -35,14 +49,14 @@ getRemoteCtrls db = getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO (Maybe RemoteCtrl) getRemoteCtrl db remoteCtrlId = maybeFirstRow toRemoteCtrl $ - DB.query db (remoteCtrlQuery <> "WHERE remote_controller_id = ?") (DB.Only remoteCtrlId) + DB.query db (remoteCtrlQuery <> " WHERE remote_controller_id = ?") (Only remoteCtrlId) getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) getRemoteCtrlByFingerprint db fingerprint = maybeFirstRow toRemoteCtrl $ - DB.query db (remoteCtrlQuery <> "WHERE fingerprint = ?") (DB.Only fingerprint) + DB.query db (remoteCtrlQuery <> " WHERE fingerprint = ?") (Only fingerprint) -remoteCtrlQuery :: DB.Query +remoteCtrlQuery :: SQL.Query remoteCtrlQuery = "SELECT remote_controller_id, display_name, fingerprint, accepted FROM remote_controllers" toRemoteCtrl :: (Int64, Text, C.KeyHash, Maybe Bool) -> RemoteCtrl @@ -53,6 +67,6 @@ markRemoteCtrlResolution :: DB.Connection -> RemoteCtrlId -> Bool -> IO () markRemoteCtrlResolution db remoteCtrlId accepted = DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ? AND accepted IS NULL" (accepted, remoteCtrlId) -deleteRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO () -deleteRemoteCtrl db remoteCtrlId = - DB.execute db "DELETE FROM remote_controllers WHERE remote_controller_id = ?" (DB.Only remoteCtrlId) +deleteRemoteCtrlRecord :: DB.Connection -> RemoteCtrlId -> IO () +deleteRemoteCtrlRecord db remoteCtrlId = + DB.execute db "DELETE FROM remote_controllers WHERE remote_controller_id = ?" (Only remoteCtrlId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9181351bd9..42f8d70ff0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -4,10 +4,10 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{-# LANGUAGE OverloadedRecordDot #-} module Simplex.Chat.View where @@ -42,6 +42,7 @@ import Simplex.Chat.Markdown import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types @@ -258,6 +259,23 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] + CRRemoteHostCreated rhId oobData -> ("remote host " <> sShow rhId <> " created") : viewRemoteCtrlOOBData oobData + CRRemoteHostList hs -> viewRemoteHosts hs + CRRemoteHostStarted rhId -> ["remote host " <> sShow rhId <> " started"] + CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] + CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] + CRRemoteHostDeleted rhId -> ["remote host " <> sShow rhId <> " deleted"] + CRRemoteCtrlList cs -> viewRemoteCtrls cs + CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] + CRRemoteCtrlStarted -> ["remote controller started"] + CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] + CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] + CRRemoteCtrlAccepted rcId -> ["remote controller " <> sShow rcId <> " accepted"] + CRRemoteCtrlRejected rcId -> ["remote controller " <> sShow rcId <> " rejected"] + CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] + CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] + CRRemoteCtrlStopped rcId -> ["remote controller " <> sShow rcId <> " stopped"] + CRRemoteCtrlDeleted rcId -> ["remote controller " <> sShow rcId <> " deleted"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = @@ -298,7 +316,6 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRChatError u e -> ttyUser' u $ viewChatError logLevel e CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRTimedAction _ _ -> [] - todo'cr -> ["TODO" <> sShow todo'cr] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser} ss @@ -1539,6 +1556,31 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo where parens s = " (" <> s <> ")" +viewRemoteCtrlOOBData :: RemoteCtrlOOB -> [StyledString] +viewRemoteCtrlOOBData RemoteCtrlOOB {caFingerprint} = + ["connection code:", plain $ strEncode caFingerprint] + +viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] +viewRemoteHosts = \case + [] -> ["No remote hosts"] + hs -> "Remote hosts: " : map viewRemoteHostInfo hs + where + viewRemoteHostInfo RemoteHostInfo {remoteHostId, displayName, sessionActive} = + plain $ tshow remoteHostId <> ". " <> displayName <> if sessionActive then " (active)" else "" + +viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] +viewRemoteCtrls = \case + [] -> ["No remote controllers"] + hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs + where + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} = + plain $ tshow remoteCtrlId <> ". " <> displayName <> if sessionActive then " (active)" else "" + +-- TODO fingerprint, accepted? +viewRemoteCtrl :: RemoteCtrl -> StyledString +viewRemoteCtrl RemoteCtrl {remoteCtrlId, displayName} = + plain $ tshow remoteCtrlId <> ". " <> displayName + viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case ChatError err -> case err of diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs new file mode 100644 index 0000000000..d1c162187f --- /dev/null +++ b/tests/RemoteTests.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE OverloadedStrings #-} + +module RemoteTests where + +import ChatClient +import ChatTests.Utils +import Control.Monad +import Data.List.NonEmpty (NonEmpty (..)) +import Debug.Trace +import Network.HTTP.Types (ok200) +import qualified Network.HTTP2.Client as C +import qualified Network.HTTP2.Server as S +import qualified Network.Socket as N +import qualified Network.TLS as TLS +import qualified Simplex.Chat.Remote.Discovery as Discovery +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import qualified Simplex.Messaging.Transport as Transport +import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import Test.Hspec +import UnliftIO + +remoteTests :: SpecWith FilePath +remoteTests = describe "Handshake" $ do + it "generates usable credentials" genCredentialsTest + it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test + it "connects desktop and mobile" remoteHandshakeTest + +-- * Low-level TLS with ephemeral credentials + +genCredentialsTest :: (HasCallStack) => FilePath -> IO () +genCredentialsTest _tmp = do + (fingerprint, credentials) <- genTestCredentials + started <- newEmptyTMVarIO + server <- Discovery.startTLSServer started credentials serverHandler + ok <- atomically (readTMVar started) + unless ok $ cancel server >> error "TLS server failed to start" + Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler + cancel server + where + serverHandler serverTls = do + traceM " - Sending from server" + Transport.putLn serverTls "hi client" + traceM " - Reading from server" + Transport.getLn serverTls `shouldReturn` "hi server" + clientHandler clientTls = do + traceM " - Sending from client" + Transport.putLn clientTls "hi server" + traceM " - Reading from client" + Transport.getLn clientTls `shouldReturn` "hi client" + +-- * UDP discovery and rever HTTP2 + +announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () +announceDiscoverHttp2Test _tmp = do + (fingerprint, credentials) <- genTestCredentials + finished <- newEmptyMVar + announcer <- async $ do + traceM " - Controller: starting" + http <- Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure + traceM " - Controller: got client" + sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case + Left err -> do + traceM " - Controller: got error" + fail $ show err + Right HTTP2Response {} -> + traceM " - Controller: got response" + closeHTTP2Client http + dis <- async $ do + sock <- Discovery.openListener + (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock + strDecode invite `shouldBe` Right fingerprint + traceM " - Host: connecting" + server <- async $ Discovery.connectTLSClient (THIPv4 $ N.hostAddressToTuple addr) fingerprint $ \tls -> do + traceM " - Host: got tls" + flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do + traceM " - Host: got request" + sendResponse $ S.responseNoBody ok200 [] + traceM " - Host: sent response" + takeMVar finished + cancel server + traceM " - Host: finished" + waitBoth dis announcer `shouldReturn` ((), ()) + +-- * Chat commands + +remoteHandshakeTest :: HasCallStack => FilePath -> IO () +remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + desktop ##> "/create remote host" + desktop <## "remote host 1 created" + desktop <## "connection code:" + fingerprint <- getTermLine desktop + + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet + + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + + mobile ##> "/start remote ctrl" + mobile <## "remote controller started" + mobile <## "remote controller announced" + mobile <## "connection code:" + fingerprint' <- getTermLine mobile + fingerprint' `shouldBe` fingerprint + mobile ##> "/list remote ctrls" + mobile <## "No remote controllers" + mobile ##> ("/register remote ctrl " <> fingerprint') + mobile <## "remote controller 1 registered" + mobile ##> "/list remote ctrls" + mobile <## "Remote controllers:" + mobile <## "1. TODO" + mobile ##> "/accept remote ctrl 1" + mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "remote controller 1 connecting to TODO" + mobile <## "remote controller 1 connected, TODO" + mobile ##> "/stop remote ctrl 1" + mobile <## "ok" + mobile <## "remote controller 1 stopped" -- TODO two outputs + mobile ##> "/delete remote ctrl 1" + mobile <## "remote controller 1 deleted" + mobile ##> "/list remote ctrls" + mobile <## "No remote controllers" + + desktop ##> "/stop remote host 1" + desktop <## "remote host 1 stopped" + desktop ##> "/delete remote host 1" + desktop <## "remote host 1 deleted" + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + +-- * Utils + +genTestCredentials :: IO (C.KeyHash, TLS.Credentials) +genTestCredentials = do + caCreds <- liftIO $ genCredentials Nothing (0, 24) "CA" + sessionCreds <- liftIO $ genCredentials (Just caCreds) (0, 24) "Session" + pure . tlsCredentials $ sessionCreds :| [caCreds] diff --git a/tests/Test.hs b/tests/Test.hs index 455d5459c7..d68de34aa5 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -8,6 +8,7 @@ import Data.Time.Clock.System import MarkdownTests import MobileTests import ProtocolTests +import RemoteTests import SchemaDump import Test.Hspec import UnliftIO.Temporary (withTempDirectory) @@ -28,6 +29,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests + describe "Remote session" remoteTests where testBracket test = do t <- getSystemTime From fc9db9c38182e9b9fb2edae472be6d18ddfbc9e2 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 5 Oct 2023 21:49:20 +0300 Subject: [PATCH 007/174] core: add FromJSON instance to ChatResponse (#3129) * Start adding FromJSON instances to ChatResponse * progress * FromJSON instance for ChatResponse compiles * restore removed encodings * remove comment * diff * update simplexmq, use TH for JSON --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 8 +- src/Simplex/Chat/Call.hs | 20 +- src/Simplex/Chat/Controller.hs | 101 ++++++---- src/Simplex/Chat/Markdown.hs | 25 ++- src/Simplex/Chat/Messages.hs | 243 +++++++++++++++++++++---- src/Simplex/Chat/Messages/CIContent.hs | 110 +++++------ src/Simplex/Chat/Protocol.hs | 3 + src/Simplex/Chat/Remote.hs | 2 +- src/Simplex/Chat/Remote/Types.hs | 8 +- src/Simplex/Chat/Store/Profiles.hs | 7 +- src/Simplex/Chat/Store/Shared.hs | 5 +- src/Simplex/Chat/Types.hs | 134 +++++++++----- src/Simplex/Chat/Types/Preferences.hs | 7 +- src/Simplex/Chat/Types/Util.hs | 3 + src/Simplex/Chat/View.hs | 10 +- stack.yaml | 2 +- 18 files changed, 483 insertions(+), 209 deletions(-) diff --git a/cabal.project b/cabal.project index f5bb879762..03b3bc8100 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 753a6c7542c3764fda9ce3f4c4cdc9f2329816d3 + tag: 96a38505d63ec9a12096991e7725b250e397af72 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b6ca36e313..748da0363c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ec1b72cb8013a65a5d9783104a47ae44f5730089" = "1lz5rvgxp242zg95r9zd9j50y45314cf8nfpjg1qsa55nrk2w19b"; + "https://github.com/simplex-chat/simplexmq.git"."96a38505d63ec9a12096991e7725b250e397af72" = "0kllakklvfrbpjlk6zi5mbxqm1prp6xdwyh2y4fw9n6c8b76is98"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 29f69d99da..bcd23f44ef 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -469,11 +469,11 @@ processChatCommand = \case DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ StartChat subConns enableExpireCIs startXFTPWorkers -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case - Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted + Just _ -> pure $ CRChatRunning Nothing + _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted Nothing APIStopChat -> do ask >>= stopChatController - pure CRChatStopped + pure $ CRChatStopped Nothing APIActivateChat -> withUser $ \_ -> do restoreCalls withAgent foregroundAgent @@ -2814,7 +2814,7 @@ processAgentMessageNoConn = \case DISCONNECT p h -> hostEvent $ CRHostDisconnected p h DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" - SUSPENDED -> toView CRChatSuspended + SUSPENDED -> toView $ CRChatSuspended Nothing DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 7a738512bd..7e6e60c8f5 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -49,6 +49,9 @@ data CallStateTag | CSTCallNegotiated deriving (Show, Generic) +instance FromJSON CallStateTag where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CSTCall" + instance ToJSON CallStateTag where toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall" @@ -132,7 +135,7 @@ data RcvCallInvitation = RcvCallInvitation sharedKey :: Maybe C.Key, callTs :: UTCTime } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON RcvCallInvitation where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -157,10 +160,7 @@ data CallInvitation = CallInvitation { callType :: CallType, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic) - -instance FromJSON CallInvitation where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show, Generic, FromJSON) instance ToJSON CallInvitation where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -190,10 +190,7 @@ data CallOffer = CallOffer rtcSession :: WebRTCSession, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic) - -instance FromJSON CallOffer where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show, Generic, FromJSON) instance ToJSON CallOffer where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -203,10 +200,7 @@ data WebRTCCallOffer = WebRTCCallOffer { callType :: CallType, rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic) - -instance FromJSON WebRTCCallOffer where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show, Generic, FromJSON) instance ToJSON WebRTCCallOffer where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c6c813b743..986eaf073e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1,4 +1,5 @@ {-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} @@ -12,6 +13,7 @@ {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Controller where @@ -24,11 +26,13 @@ import Control.Monad.Reader import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?)) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) +import Data.Constraint (Dict (..)) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) @@ -64,7 +68,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -200,6 +204,9 @@ data ChatController = ChatController data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase deriving (Show, Generic) +instance FromJSON HelpSection where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "HS" + instance ToJSON HelpSection where toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" @@ -438,10 +445,10 @@ data ChatCommand data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} - | CRChatStarted - | CRChatRunning - | CRChatStopped - | CRChatSuspended + | CRChatStarted {_nullary :: Maybe Int} + | CRChatRunning {_nullary :: Maybe Int} + | CRChatStopped {_nullary :: Maybe Int} + | CRChatSuspended {_nullary :: Maybe Int} | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat} @@ -605,7 +612,7 @@ data ChatResponse | CRRemoteHostDeleted {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlStarted + | CRRemoteCtrlStarted {_nullary :: Maybe Int} | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} @@ -629,7 +636,7 @@ data ChatResponse | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRTimedAction {action :: String, durationMilliseconds :: Int64} - deriving (Show, Generic) + deriving (Show) logResponseToFile :: ChatResponse -> Bool logResponseToFile = \case @@ -650,17 +657,12 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False -instance FromJSON ChatResponse where - parseJSON todo = pure $ CRCmdOk Nothing -- TODO: actually use the instances - -instance ToJSON ChatResponse where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" - data RemoteCtrlOOB = RemoteCtrlOOB { caFingerprint :: C.KeyHash } - deriving (Show, Generic, ToJSON) + deriving (Show, Generic, FromJSON) + +instance ToJSON RemoteCtrlOOB where toEncoding = J.genericToEncoding J.defaultOptions data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, @@ -668,14 +670,18 @@ data RemoteHostInfo = RemoteHostInfo displayName :: Text, sessionActive :: Bool } - deriving (Show, Generic, ToJSON) + deriving (Show, Generic, FromJSON) + +instance ToJSON RemoteHostInfo where toEncoding = J.genericToEncoding J.defaultOptions data RemoteCtrlInfo = RemoteCtrlInfo { remoteCtrlId :: RemoteCtrlId, displayName :: Text, sessionActive :: Bool } - deriving (Eq, Show, Generic, ToJSON) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON RemoteCtrlInfo where toEncoding = J.genericToEncoding J.defaultOptions newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -695,6 +701,9 @@ instance StrEncoding AgentQueueId where strDecode s = AgentQueueId <$> strDecode s strP = AgentQueueId <$> strP +instance FromJSON AgentQueueId where + parseJSON = strParseJSON "AgentQueueId" + instance ToJSON AgentQueueId where toJSON = strToJSON toEncoding = strToJEncoding @@ -713,12 +722,23 @@ data UserProtoServers p = UserProtoServers } deriving (Show, Generic) +instance ProtocolTypeI p => FromJSON (UserProtoServers p) where + parseJSON = J.genericParseJSON J.defaultOptions + instance ProtocolTypeI p => ToJSON (UserProtoServers p) where - toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) +instance FromJSON AUserProtoServers where + parseJSON v = J.withObject "AUserProtoServers" parse v + where + parse o = do + AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" + case userProtocol p of + Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v + Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p + instance ToJSON AUserProtoServers where toJSON (AUPS s) = J.genericToJSON J.defaultOptions s toEncoding (AUPS s) = J.genericToEncoding J.defaultOptions s @@ -747,7 +767,7 @@ data ContactSubStatus = ContactSubStatus { contact :: Contact, contactError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON ContactSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -757,7 +777,7 @@ data MemberSubStatus = MemberSubStatus { member :: GroupMember, memberError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON MemberSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -767,7 +787,7 @@ data UserContactSubStatus = UserContactSubStatus { userContact :: UserContact, userContactError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON UserContactSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -777,7 +797,7 @@ data PendingSubStatus = PendingSubStatus { connection :: PendingContactConnection, connError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON PendingSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -789,7 +809,7 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary updateFailures :: Int, changedContacts :: [Contact] } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON UserProfileUpdateSummary where toEncoding = J.genericToEncoding J.defaultOptions @@ -825,12 +845,10 @@ data XFTPFileConfig = XFTPFileConfig defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -instance ToJSON XFTPFileConfig where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON XFTPFileConfig where toEncoding = J.genericToEncoding J.defaultOptions data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON NtfMsgInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -842,7 +860,7 @@ data SwitchProgress = SwitchProgress switchPhase :: SwitchPhase, connectionStats :: ConnectionStats } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions @@ -850,7 +868,7 @@ data RatchetSyncProgress = RatchetSyncProgress { ratchetSyncStatus :: RatchetSyncState, connectionStats :: ConnectionStats } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON RatchetSyncProgress where toEncoding = J.genericToEncoding J.defaultOptions @@ -858,7 +876,7 @@ data ParsedServerAddress = ParsedServerAddress { serverAddress :: Maybe ServerAddress, parseError :: String } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions @@ -869,7 +887,7 @@ data ServerAddress = ServerAddress keyHash :: String, basicAuth :: String } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions @@ -893,7 +911,7 @@ data CoreVersionInfo = CoreVersionInfo simplexmqVersion :: String, simplexmqCommit :: String } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON CoreVersionInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -906,7 +924,7 @@ data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON SlowSQLQuery where toEncoding = J.genericToEncoding J.defaultOptions @@ -919,6 +937,9 @@ data ChatError | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) +instance FromJSON ChatError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "Chat" + instance ToJSON ChatError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" @@ -1002,6 +1023,9 @@ data ChatErrorType | CEException {message :: String} deriving (Show, Exception, Generic) +instance FromJSON ChatErrorType where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CE" + instance ToJSON ChatErrorType where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" @@ -1014,6 +1038,9 @@ data DatabaseError | DBErrorOpen {sqliteError :: SQLiteError} deriving (Show, Exception, Generic) +instance FromJSON DatabaseError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "DB" + instance ToJSON DatabaseError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DB" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DB" @@ -1021,6 +1048,9 @@ instance ToJSON DatabaseError where data SQLiteError = SQLiteErrorNotADatabase | SQLiteError String deriving (Show, Exception, Generic) +instance FromJSON SQLiteError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SQLite" + instance ToJSON SQLiteError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SQLite" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SQLite" @@ -1070,6 +1100,9 @@ data ArchiveError | AEImportFile {file :: String, chatError :: ChatError} deriving (Show, Exception, Generic) +instance FromJSON ArchiveError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "AE" + instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" @@ -1156,3 +1189,5 @@ withStoreCtx ctx_ action = do where handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index d18f28db31..64b1145539 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} @@ -10,7 +11,7 @@ module Simplex.Chat.Markdown where import Control.Applicative (optional, (<|>)) -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A @@ -56,6 +57,9 @@ data Format data SimplexLinkType = XLContact | XLInvitation | XLGroup deriving (Eq, Show, Generic) +instance FromJSON SimplexLinkType where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "XL" + instance ToJSON SimplexLinkType where toJSON = J.genericToJSON . enumJSON $ dropPrefix "XL" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "XL" @@ -66,6 +70,9 @@ colored = Colored . FormatColor markdown :: Format -> Text -> Markdown markdown = Markdown . Just +instance FromJSON Format where + parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower + instance ToJSON Format where toJSON = J.genericToJSON $ sumTypeJSON fstToLower toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower @@ -91,6 +98,18 @@ instance IsString Markdown where fromString = unmarked . T.pack newtype FormatColor = FormatColor Color deriving (Eq, Show) +instance FromJSON FormatColor where + parseJSON = J.withText "FormatColor" $ fmap FormatColor . \case + "red" -> pure Red + "green" -> pure Green + "blue" -> pure Blue + "yellow" -> pure Yellow + "cyan" -> pure Cyan + "magenta" -> pure Magenta + "black" -> pure Black + "white" -> pure White + unexpected -> fail $ "unexpected FormatColor: " <> show unexpected + instance ToJSON FormatColor where toJSON (FormatColor c) = case c of Red -> "red" @@ -103,7 +122,7 @@ instance ToJSON FormatColor where White -> "white" data FormattedText = FormattedText {format :: Maybe Format, text :: Text} - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON FormattedText where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -129,7 +148,7 @@ parseMaybeMarkdownList s | otherwise = Just . reverse $ foldl' acc [] ml where ml = intercalate ["\n"] . map (markdownToList . parseMarkdown) $ T.lines s - acc [] m = [m] + acc [] m = [m] acc ms@(FormattedText f t : ms') ft@(FormattedText f' t') | f == f' = FormattedText f (t <> t') : ms' | otherwise = ft : ms diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 79463d2107..b9ce953731 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -17,7 +17,7 @@ module Simplex.Chat.Messages where import Control.Applicative ((<|>)) -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.Attoparsec.ByteString.Char8 as A @@ -66,6 +66,9 @@ chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name data ChatRef = ChatRef ChatType Int64 deriving (Eq, Show, Ord) +instance FromJSON ChatType where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CT" + instance ToJSON ChatType where toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" @@ -110,10 +113,16 @@ data JSONChatInfo | JCInfoContactConnection {contactConnection :: PendingContactConnection} deriving (Generic) +instance FromJSON JSONChatInfo where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCInfo" + instance ToJSON JSONChatInfo where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" +instance ChatTypeI c => FromJSON (ChatInfo c) where + parseJSON v = (\(AChatInfo _ c) -> checkChatType c) <$?> J.parseJSON v + instance ToJSON (ChatInfo c) where toJSON = J.toJSON . jsonChatInfo toEncoding = J.toEncoding . jsonChatInfo @@ -125,10 +134,20 @@ jsonChatInfo = \case ContactRequest g -> JCInfoContactRequest g ContactConnection c -> JCInfoContactConnection c -data AChatInfo = forall c. AChatInfo (SChatType c) (ChatInfo c) +data AChatInfo = forall c. ChatTypeI c => AChatInfo (SChatType c) (ChatInfo c) deriving instance Show AChatInfo +jsonAChatInfo :: JSONChatInfo -> AChatInfo +jsonAChatInfo = \case + JCInfoDirect c -> AChatInfo SCTDirect $ DirectChat c + JCInfoGroup g -> AChatInfo SCTGroup $ GroupChat g + JCInfoContactRequest g -> AChatInfo SCTContactRequest $ ContactRequest g + JCInfoContactConnection c -> AChatInfo SCTContactConnection $ ContactConnection c + +instance FromJSON AChatInfo where + parseJSON v = jsonAChatInfo <$> J.parseJSON v + instance ToJSON AChatInfo where toJSON (AChatInfo _ c) = J.toJSON c toEncoding (AChatInfo _ c) = J.toEncoding c @@ -144,7 +163,10 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show, Generic) -instance MsgDirectionI d => ToJSON (ChatItem c d) where +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where + parseJSON = J.genericParseJSON J.defaultOptions + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -156,6 +178,16 @@ data CIDirection (c :: ChatType) (d :: MsgDirection) where deriving instance Show (CIDirection c d) +data CCIDirection c = forall d. MsgDirectionI d => CCID (SMsgDirection d) (CIDirection c d) + +instance ChatTypeI c => FromJSON (CCIDirection c) where + parseJSON v = (\(ACID _ d x) -> checkChatType (CCID d x)) <$?> J.parseJSON v + +data ACIDirection = forall c d. (ChatTypeI c, MsgDirectionI d) => ACID (SChatType c) (SMsgDirection d) (CIDirection c d) + +instance FromJSON ACIDirection where + parseJSON v = jsonACIDirection <$> J.parseJSON v + data JSONCIDirection = JCIDirectSnd | JCIDirectRcv @@ -163,10 +195,16 @@ data JSONCIDirection | JCIGroupRcv {groupMember :: GroupMember} deriving (Generic, Show) +instance FromJSON JSONCIDirection where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" + instance ToJSON JSONCIDirection where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where + parseJSON v = (\(CCID _ x') -> checkDirection x') <$?> J.parseJSON v + instance ToJSON (CIDirection c d) where toJSON = J.toJSON . jsonCIDirection toEncoding = J.toEncoding . jsonCIDirection @@ -178,8 +216,15 @@ jsonCIDirection = \case CIGroupSnd -> JCIGroupSnd CIGroupRcv m -> JCIGroupRcv m +jsonACIDirection :: JSONCIDirection -> ACIDirection +jsonACIDirection = \case + JCIDirectSnd -> ACID SCTDirect SMDSnd CIDirectSnd + JCIDirectRcv -> ACID SCTDirect SMDRcv CIDirectRcv + JCIGroupSnd -> ACID SCTGroup SMDSnd CIGroupSnd + JCIGroupRcv m -> ACID SCTGroup SMDRcv $ CIGroupRcv m + data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int} - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON CIReactionCount where toEncoding = J.genericToEncoding J.defaultOptions @@ -187,7 +232,15 @@ data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (Cha deriving instance Show (CChatItem c) -instance ToJSON (CChatItem c) where +instance forall c. ChatTypeI c => FromJSON (CChatItem c) where + parseJSON v = J.withObject "CChatItem" parse v + where + parse o = do + CCID d (_ :: CIDirection c d) <- o .: "chatDir" + ci <- J.parseJSON @(ChatItem c d) v + pure $ CChatItem d ci + +instance ChatTypeI c => ToJSON (CChatItem c) where toJSON (CChatItem _ ci) = J.toJSON ci toEncoding (CChatItem _ ci) = J.toEncoding ci @@ -279,14 +332,19 @@ data Chat c = Chat } deriving (Show, Generic) -instance ToJSON (Chat c) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions +instance ChatTypeI c => ToJSON (Chat c) where toEncoding = J.genericToEncoding J.defaultOptions -data AChat = forall c. AChat (SChatType c) (Chat c) +data AChat = forall c. ChatTypeI c => AChat (SChatType c) (Chat c) deriving instance Show AChat +instance FromJSON AChat where + parseJSON = J.withObject "AChat" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + chatItems <- o .: "chatItems" + chatStats <- o .: "chatStats" + pure $ AChat c Chat {chatInfo, chatItems, chatStats} + instance ToJSON AChat where toJSON (AChat _ c) = J.toJSON c toEncoding (AChat _ c) = J.toEncoding c @@ -296,17 +354,21 @@ data ChatStats = ChatStats minUnreadItemId :: ChatItemId, unreadChat :: Bool } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) -instance ToJSON ChatStats where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON ChatStats where toEncoding = J.genericToEncoding J.defaultOptions -- | type to show a mix of messages from multiple chats -data AChatItem = forall c d. MsgDirectionI d => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) +data AChatItem = forall c d. (ChatTypeI c, MsgDirectionI d) => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem +instance FromJSON AChatItem where + parseJSON = J.withObject "AChatItem" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + CChatItem d chatItem <- o .: "chatItem" + pure $ AChatItem c d chatInfo chatItem + instance ToJSON AChatItem where toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item @@ -330,7 +392,7 @@ updateFileStatus ci@ChatItem {file} status = case file of Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} Nothing -> ci -instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions @@ -349,7 +411,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs createdAt updatedAt = @@ -358,13 +420,13 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item _ -> False in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt} -instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions +instance ChatTypeI c => ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions data CITimed = CITimed { ttl :: Int, -- seconds deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON CITimed where toEncoding = J.genericToEncoding J.defaultOptions @@ -402,6 +464,9 @@ data CIQuote (c :: ChatType) = CIQuote } deriving (Show, Generic) +instance ChatTypeI c => FromJSON (CIQuote c) where + parseJSON = J.genericParseJSON J.defaultOptions + instance ToJSON (CIQuote c) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -414,24 +479,39 @@ data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction } deriving (Show, Generic) -instance ToJSON (CIReaction c d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIReaction c d) where + parseJSON = J.genericParseJSON J.defaultOptions -data ACIReaction = forall c d. ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) +instance ChatTypeI c => ToJSON (CIReaction c d) where + toEncoding = J.genericToEncoding J.defaultOptions + +data AnyCIReaction = forall c d. ChatTypeI c => ACIR (SChatType c) (SMsgDirection d) (CIReaction c d) + +instance FromJSON AnyCIReaction where + parseJSON v = J.withObject "AnyCIReaction" parse v + where + parse o = do + ACID c d (_ :: CIDirection c d) <- o .: "chatDir" + ACIR c d <$> J.parseJSON @(CIReaction c d) v + +data ACIReaction = forall c d. ChatTypeI c => ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) deriving instance Show ACIReaction +instance FromJSON ACIReaction where + parseJSON = J.withObject "ACIReaction" $ \o -> do + ACIR c d reaction <- o .: "chatReaction" + cInfo <- o .: "chatInfo" + pure $ ACIReaction c d cInfo reaction + instance ToJSON ACIReaction where - toJSON (ACIReaction _ _ chat reaction) = J.toJSON $ JSONCIReaction chat reaction - toEncoding (ACIReaction _ _ chat reaction) = J.toEncoding $ JSONCIReaction chat reaction + toJSON (ACIReaction _ _ cInfo reaction) = J.toJSON $ JSONCIReaction cInfo reaction + toEncoding (ACIReaction _ _ cInfo reaction) = J.toEncoding $ JSONCIReaction cInfo reaction data JSONCIReaction c d = JSONCIReaction {chatInfo :: ChatInfo c, chatReaction :: CIReaction c d} deriving (Generic) -instance ToJSON (JSONCIReaction c d) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions +instance ChatTypeI c => ToJSON (JSONCIReaction c d) where toEncoding = J.genericToEncoding J.defaultOptions data CIQDirection (c :: ChatType) where CIQDirectSnd :: CIQDirection 'CTDirect @@ -441,6 +521,11 @@ data CIQDirection (c :: ChatType) where deriving instance Show (CIQDirection c) +data ACIQDirection = forall c. ChatTypeI c => ACIQDirection (SChatType c) (CIQDirection c) + +instance ChatTypeI c => FromJSON (CIQDirection c) where + parseJSON v = (\(ACIQDirection _ x) -> checkChatType x) . jsonACIQDirection <$?> J.parseJSON v + instance ToJSON (CIQDirection c) where toJSON = J.toJSON . jsonCIQDirection toEncoding = J.toEncoding . jsonCIQDirection @@ -453,6 +538,14 @@ jsonCIQDirection = \case CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m CIQGroupRcv Nothing -> Nothing +jsonACIQDirection :: Maybe JSONCIDirection -> ACIQDirection +jsonACIQDirection = \case + Just JCIDirectSnd -> ACIQDirection SCTDirect CIQDirectSnd + Just JCIDirectRcv -> ACIQDirection SCTDirect CIQDirectRcv + Just JCIGroupSnd -> ACIQDirection SCTGroup CIQGroupSnd + Just (JCIGroupRcv m) -> ACIQDirection SCTGroup $ CIQGroupRcv (Just m) + Nothing -> ACIQDirection SCTGroup $ CIQGroupRcv Nothing + quoteMsgDirection :: CIQDirection c -> MsgDirection quoteMsgDirection = \case CIQDirectSnd -> MDSnd @@ -470,6 +563,9 @@ data CIFile (d :: MsgDirection) = CIFile } deriving (Show, Generic) +instance MsgDirectionI d => FromJSON (CIFile d) where + parseJSON = J.genericParseJSON J.defaultOptions + instance MsgDirectionI d => ToJSON (CIFile d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -481,6 +577,9 @@ instance FromField FileProtocol where fromField = fromTextField_ textDecode instance ToField FileProtocol where toField = toField . textEncode +instance FromJSON FileProtocol where + parseJSON = textParseJSON "FileProtocol" + instance ToJSON FileProtocol where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -527,6 +626,9 @@ ciFileEnded = \case CIFSRcvError -> True CIFSInvalid {} -> True +instance MsgDirectionI d => FromJSON (CIFileStatus d) where + parseJSON v = (\(AFS _ s) -> checkDirection s) . aciFileStatusJSON <$?> J.parseJSON v + instance ToJSON (CIFileStatus d) where toJSON = J.toJSON . jsonCIFileStatus toEncoding = J.toEncoding . jsonCIFileStatus @@ -594,6 +696,9 @@ data JSONCIFileStatus | JCIFSInvalid {text :: Text} deriving (Generic) +instance FromJSON JSONCIFileStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIFS" + instance ToJSON JSONCIFileStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIFS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIFS" @@ -658,6 +763,9 @@ deriving instance Eq (CIStatus d) deriving instance Show (CIStatus d) +instance MsgDirectionI d => FromJSON (CIStatus d) where + parseJSON v = (\(ACIStatus _ s) -> checkDirection s) . jsonACIStatus <$?> J.parseJSON v + instance ToJSON (CIStatus d) where toJSON = J.toJSON . jsonCIStatus toEncoding = J.toEncoding . jsonCIStatus @@ -712,6 +820,9 @@ data JSONCIStatus | JCISInvalid {text :: Text} deriving (Show, Generic) +instance FromJSON JSONCIStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIS" + instance ToJSON JSONCIStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIS" @@ -727,6 +838,17 @@ jsonCIStatus = \case CISRcvRead -> JCISRcvRead CISInvalid text -> JCISInvalid text +jsonACIStatus :: JSONCIStatus -> ACIStatus +jsonACIStatus = \case + JCISSndNew -> ACIStatus SMDSnd CISSndNew + JCISSndSent sndProgress -> ACIStatus SMDSnd $ CISSndSent sndProgress + JCISSndRcvd msgRcptStatus sndProgress -> ACIStatus SMDSnd $ CISSndRcvd msgRcptStatus sndProgress + JCISSndErrorAuth -> ACIStatus SMDSnd CISSndErrorAuth + JCISSndError e -> ACIStatus SMDSnd $ CISSndError e + JCISRcvNew -> ACIStatus SMDRcv CISRcvNew + JCISRcvRead -> ACIStatus SMDRcv CISRcvRead + JCISInvalid text -> ACIStatus SMDSnd $ CISInvalid text + ciStatusNew :: forall d. MsgDirectionI d => CIStatus d ciStatusNew = case msgDirection @d of SMDSnd -> CISSndNew @@ -757,6 +879,9 @@ data SndCIStatusProgress | SSPComplete deriving (Eq, Show, Generic) +instance FromJSON SndCIStatusProgress where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SSP" + instance ToJSON SndCIStatusProgress where toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "SSP" @@ -796,6 +921,8 @@ instance TestEquality SChatType where testEquality SCTContactConnection SCTContactConnection = Just Refl testEquality _ _ = Nothing +data AChatType = forall c. ChatTypeI c => ACT (SChatType c) + class ChatTypeI (c :: ChatType) where chatTypeI :: SChatType c @@ -803,6 +930,36 @@ instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup +instance ChatTypeI 'CTContactRequest where chatTypeI = SCTContactRequest + +instance ChatTypeI 'CTContactConnection where chatTypeI = SCTContactConnection + +instance ChatTypeI c => FromJSON (SChatType c) where + parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v + +instance ToJSON (SChatType c) where + toJSON = J.toJSON . toChatType + toEncoding = J.toEncoding . toChatType + +toChatType :: SChatType c -> ChatType +toChatType = \case + SCTDirect -> CTDirect + SCTGroup -> CTGroup + SCTContactRequest -> CTContactRequest + SCTContactConnection -> CTContactConnection + +aChatType :: ChatType -> AChatType +aChatType = \case + CTDirect -> ACT SCTDirect + CTGroup -> ACT SCTGroup + CTContactRequest -> ACT SCTContactRequest + CTContactConnection -> ACT SCTContactConnection + +checkChatType :: forall t c c'. (ChatTypeI c, ChatTypeI c') => t c' -> Either String (t c) +checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of + Just Refl -> Right x + Nothing -> Left "bad chat type" + data NewMessage e = NewMessage { chatMsgEvent :: ChatMsgEvent e, msgBody :: MsgBody @@ -920,35 +1077,43 @@ msgDeliveryStatusT' s = Just Refl -> Just st _ -> Nothing -checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d) -checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of - Just Refl -> Right x - Nothing -> Left "bad direction" - data CIDeleted (c :: ChatType) where CIDeleted :: Maybe UTCTime -> CIDeleted c CIModerated :: Maybe UTCTime -> GroupMember -> CIDeleted 'CTGroup deriving instance Show (CIDeleted c) -instance ToJSON (CIDeleted d) where +data ACIDeleted = forall c. ChatTypeI c => ACIDeleted (SChatType c) (CIDeleted c) + +instance ChatTypeI c => FromJSON (CIDeleted c) where + parseJSON v = (\(ACIDeleted _ x) -> checkChatType x) . jsonACIDeleted <$?> J.parseJSON v + +instance ChatTypeI c => ToJSON (CIDeleted c) where toJSON = J.toJSON . jsonCIDeleted toEncoding = J.toEncoding . jsonCIDeleted data JSONCIDeleted - = JCIDDeleted {deletedTs :: Maybe UTCTime} + = JCIDDeleted {deletedTs :: Maybe UTCTime, chatType :: ChatType} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} deriving (Show, Generic) +instance FromJSON JSONCIDeleted where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCID" + instance ToJSON JSONCIDeleted where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCID" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCID" -jsonCIDeleted :: CIDeleted d -> JSONCIDeleted +jsonCIDeleted :: forall d. ChatTypeI d => CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case - CIDeleted ts -> JCIDDeleted ts + CIDeleted ts -> JCIDDeleted ts (toChatType $ chatTypeI @d) CIModerated ts m -> JCIDModerated ts m +jsonACIDeleted :: JSONCIDeleted -> ACIDeleted +jsonACIDeleted = \case + JCIDDeleted ts cType -> case aChatType cType of ACT c -> ACIDeleted c $ CIDeleted ts + JCIDModerated ts m -> ACIDeleted SCTGroup (CIModerated ts m) + itemDeletedTs :: CIDeleted d -> Maybe UTCTime itemDeletedTs = \case CIDeleted ts -> ts @@ -958,7 +1123,7 @@ data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe [MemberDeliveryStatus] } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON ChatItemInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -969,7 +1134,7 @@ data ChatItemVersion = ChatItemVersion itemVersionTs :: UTCTime, createdAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON ChatItemVersion where toEncoding = J.genericToEncoding J.defaultOptions @@ -990,7 +1155,7 @@ data MemberDeliveryStatus = MemberDeliveryStatus { groupMemberId :: GroupMemberId, memberDeliveryStatus :: CIStatus 'MDSnd } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON MemberDeliveryStatus where toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 9abc8e4644..8f9a453bde 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -9,12 +9,14 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -34,7 +36,7 @@ import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) -import Simplex.Messaging.Util (safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>)) data MsgDirection = MDRcv | MDSnd deriving (Eq, Show, Generic) @@ -69,6 +71,13 @@ instance TestEquality SMsgDirection where testEquality SMDSnd SMDSnd = Just Refl testEquality _ _ = Nothing +instance MsgDirectionI d => FromJSON (SMsgDirection d) where + parseJSON v = (\(AMsgDirection d) -> checkDirection d) . fromMsgDirection <$?> J.parseJSON v + +instance ToJSON (SMsgDirection d) where + toJSON = J.toJSON . toMsgDirection + toEncoding = J.toEncoding . toMsgDirection + instance ToField (SMsgDirection d) where toField = toField . msgDirectionInt . toMsgDirection data AMsgDirection = forall d. MsgDirectionI d => AMsgDirection (SMsgDirection d) @@ -92,6 +101,11 @@ instance MsgDirectionI 'MDRcv where msgDirection = SMDRcv instance MsgDirectionI 'MDSnd where msgDirection = SMDSnd +checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d) +checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of + Just Refl -> Right x + Nothing -> Left "bad direction" + msgDirectionInt :: MsgDirection -> Int msgDirectionInt = \case MDRcv -> 0 @@ -481,27 +495,10 @@ msgDirToModeratedContent_ = \case ciModeratedText :: Text ciModeratedText = "moderated" --- platform independent -instance MsgDirectionI d => ToField (CIContent d) where - toField = toField . encodeJSON . dbJsonCIContent - --- platform specific -instance MsgDirectionI d => ToJSON (CIContent d) where - toJSON = J.toJSON . jsonCIContent - toEncoding = J.toEncoding . jsonCIContent - data ACIContent = forall d. MsgDirectionI d => ACIContent (SMsgDirection d) (CIContent d) deriving instance Show ACIContent --- platform independent -dbParseACIContent :: Text -> Either String ACIContent -dbParseACIContent = fmap aciContentDBJSON . J.eitherDecodeStrict' . encodeUtf8 - --- platform specific -instance FromJSON ACIContent where - parseJSON = fmap aciContentJSON . J.parseJSON - -- platform specific data JSONCIContent = JCISndMsgContent {msgContent :: MsgContent} @@ -527,17 +524,9 @@ data JSONCIContent | JCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} | JCIRcvChatFeatureRejected {feature :: ChatFeature} | JCIRcvGroupFeatureRejected {groupFeature :: GroupFeature} - | JCISndModerated - | JCIRcvModerated + | JCISndModerated {_nullary :: Maybe Int} + | JCIRcvModerated {_nullary :: Maybe Int} | JCIInvalidJSON {direction :: MsgDirection, json :: Text} - deriving (Generic) - -instance FromJSON JSONCIContent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" - -instance ToJSON JSONCIContent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent jsonCIContent = \case @@ -564,8 +553,8 @@ jsonCIContent = \case CISndGroupFeature groupFeature preference param -> JCISndGroupFeature {groupFeature, preference, param} CIRcvChatFeatureRejected feature -> JCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> JCIRcvGroupFeatureRejected {groupFeature} - CISndModerated -> JCISndModerated - CIRcvModerated -> JCISndModerated + CISndModerated -> JCISndModerated Nothing + CIRcvModerated -> JCISndModerated Nothing CIInvalidJSON json -> JCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentJSON :: JSONCIContent -> ACIContent @@ -593,8 +582,8 @@ aciContentJSON = \case JCISndGroupFeature {groupFeature, preference, param} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param JCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature JCIRcvGroupFeatureRejected {groupFeature} -> ACIContent SMDRcv $ CIRcvGroupFeatureRejected groupFeature - JCISndModerated -> ACIContent SMDSnd CISndModerated - JCIRcvModerated -> ACIContent SMDRcv CIRcvModerated + JCISndModerated _ -> ACIContent SMDSnd CISndModerated + JCIRcvModerated _ -> ACIContent SMDRcv CIRcvModerated JCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -623,17 +612,9 @@ data DBJSONCIContent | DBJCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} | DBJCIRcvChatFeatureRejected {feature :: ChatFeature} | DBJCIRcvGroupFeatureRejected {groupFeature :: GroupFeature} - | DBJCISndModerated - | DBJCIRcvModerated + | DBJCISndModerated {_nullary :: Maybe Int} + | DBJCIRcvModerated {_nullary :: Maybe Int} | DBJCIInvalidJSON {direction :: MsgDirection, json :: Text} - deriving (Generic) - -instance FromJSON DBJSONCIContent where - parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "DBJCI" - -instance ToJSON DBJSONCIContent where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "DBJCI" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "DBJCI" dbJsonCIContent :: forall d. MsgDirectionI d => CIContent d -> DBJSONCIContent dbJsonCIContent = \case @@ -660,8 +641,8 @@ dbJsonCIContent = \case CISndGroupFeature groupFeature preference param -> DBJCISndGroupFeature {groupFeature, preference, param} CIRcvChatFeatureRejected feature -> DBJCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> DBJCIRcvGroupFeatureRejected {groupFeature} - CISndModerated -> DBJCISndModerated - CIRcvModerated -> DBJCIRcvModerated + CISndModerated -> DBJCISndModerated Nothing + CIRcvModerated -> DBJCIRcvModerated Nothing CIInvalidJSON json -> DBJCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentDBJSON :: DBJSONCIContent -> ACIContent @@ -689,8 +670,8 @@ aciContentDBJSON = \case DBJCISndGroupFeature {groupFeature, preference, param} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param DBJCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature DBJCIRcvGroupFeatureRejected {groupFeature} -> ACIContent SMDRcv $ CIRcvGroupFeatureRejected groupFeature - DBJCISndModerated -> ACIContent SMDSnd CISndModerated - DBJCIRcvModerated -> ACIContent SMDRcv CIRcvModerated + DBJCISndModerated _ -> ACIContent SMDSnd CISndModerated + DBJCIRcvModerated _ -> ACIContent SMDRcv CIRcvModerated DBJCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -703,14 +684,7 @@ data CICallStatus | CISCallProgress | CISCallEnded | CISCallError - deriving (Show, Generic) - -instance FromJSON CICallStatus where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CISCall" - -instance ToJSON CICallStatus where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CISCall" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CISCall" + deriving (Show) ciCallInfoText :: CICallStatus -> Int -> Text ciCallInfoText status duration = case status of @@ -722,3 +696,31 @@ ciCallInfoText status duration = case status of CISCallProgress -> "in progress " <> durationText duration CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CISCall") ''CICallStatus) + +-- platform specific +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIContent) + +-- platform independent +$(JQ.deriveJSON (singleFieldJSON $ dropPrefix "DBJCI") ''DBJSONCIContent) + +-- platform independent +instance MsgDirectionI d => ToField (CIContent d) where + toField = toField . encodeJSON . dbJsonCIContent + +-- platform specific +instance MsgDirectionI d => ToJSON (CIContent d) where + toJSON = J.toJSON . jsonCIContent + toEncoding = J.toEncoding . jsonCIContent + +instance MsgDirectionI d => FromJSON (CIContent d) where + parseJSON v = (\(ACIContent _ c) -> checkDirection c) <$?> J.parseJSON v + +-- platform independent +dbParseACIContent :: Text -> Either String ACIContent +dbParseACIContent = fmap aciContentDBJSON . J.eitherDecodeStrict' . encodeUtf8 + +-- platform specific +instance FromJSON ACIContent where + parseJSON = fmap aciContentJSON . J.parseJSON diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index bbdddf8ce0..cb937441f9 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -72,6 +72,9 @@ data ConnectionEntity | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} deriving (Eq, Show, Generic) +instance FromJSON ConnectionEntity where + parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower + instance ToJSON ConnectionEntity where toJSON = J.genericToJSON $ sumTypeJSON fstToLower toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 936c750c6c..8f7a3b4f4c 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -201,7 +201,7 @@ startRemoteCtrl = chatWriteVar remoteCtrlSession Nothing toView $ CRRemoteCtrlStopped {remoteCtrlId} chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted} - pure CRRemoteCtrlStarted + pure $ CRRemoteCtrlStarted Nothing discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.openListener >>= go diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index b66e9a6253..f13c3c84ea 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,14 +1,14 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) -import Data.Aeson (ToJSON (..)) +import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) -import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport.Client (TransportHost) @@ -37,7 +37,9 @@ data RemoteCtrl = RemoteCtrl fingerprint :: C.KeyHash, accepted :: Maybe Bool } - deriving (Show, Generic, ToJSON) + deriving (Show) + +$(J.deriveJSON J.defaultOptions ''RemoteCtrl) data RemoteHostSession = RemoteHostSessionStarting diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e521cb43cf..d005c3893e 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} @@ -59,7 +60,7 @@ where import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.Functor (($>)) import Data.Int (Int64) @@ -398,7 +399,7 @@ data UserContactLink = UserContactLink { connReqContact :: ConnReqContact, autoAccept :: Maybe AutoAccept } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions @@ -406,7 +407,7 @@ data AutoAccept = AutoAccept { acceptIncognito :: IncognitoEnabled, autoReply :: Maybe MsgContent } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 4dc4f6e82d..64634dd29d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -16,7 +16,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG, randomBytesGenerate) -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) @@ -102,6 +102,9 @@ data StoreError | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} deriving (Show, Exception, Generic) +instance FromJSON StoreError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SE" + instance ToJSON StoreError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 0b143a7574..088f23e056 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -15,6 +15,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE UndecidableInstances #-} @@ -25,9 +26,10 @@ 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 import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString, pack, unpack) @@ -112,18 +114,14 @@ data User = User sendRcptsContacts :: Bool, sendRcptsSmallGroups :: Bool } - deriving (Show, Generic, FromJSON) - -instance ToJSON User where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data NewUser = NewUser { profile :: Maybe Profile, sameServers :: Bool, pastTimestamp :: Bool } - deriving (Show, Generic, FromJSON) + deriving (Show) newtype B64UrlByteString = B64UrlByteString ByteString deriving (Eq, Show) @@ -144,19 +142,13 @@ instance ToJSON B64UrlByteString where toEncoding = strToJEncoding data UserPwdHash = UserPwdHash {hash :: B64UrlByteString, salt :: B64UrlByteString} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON UserPwdHash where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data UserInfo = UserInfo { user :: User, unreadCount :: Int } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) type ContactId = Int64 @@ -179,11 +171,7 @@ data Contact = Contact contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool } - deriving (Eq, Show, Generic) - -instance ToJSON Contact where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) contactConn :: Contact -> Connection contactConn Contact {activeConn} = activeConn @@ -221,6 +209,9 @@ instance FromField ContactStatus where fromField = fromTextField_ textDecode instance ToField ContactStatus where toField = toField . textEncode +instance FromJSON ContactStatus where + parseJSON = textParseJSON "ContactStatus" + instance ToJSON ContactStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -240,9 +231,7 @@ data ContactRef = ContactRef agentConnId :: AgentConnId, localDisplayName :: ContactName } - deriving (Eq, Show, Generic) - -instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ContactOrGroupMember = CGMContact Contact | CGMGroupMember GroupInfo GroupMember deriving (Show) @@ -262,15 +251,13 @@ data UserContact = UserContact connReqContact :: ConnReqContact, groupId :: Maybe GroupId } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON UserContact where toEncoding = J.genericToEncoding J.defaultOptions userContactGroupId :: UserContact -> Maybe GroupId userContactGroupId UserContact {groupId} = groupId -instance ToJSON UserContact where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - data UserContactRequest = UserContactRequest { contactRequestId :: Int64, agentInvitationId :: AgentInvId, @@ -284,7 +271,7 @@ data UserContactRequest = UserContactRequest updatedAt :: UTCTime, xContactId :: Maybe XContactId } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON UserContactRequest where toEncoding = J.genericToEncoding J.defaultOptions @@ -341,7 +328,7 @@ optionalFullName displayName fullName | otherwise = " (" <> fullName <> ")" data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON Group where toEncoding = J.genericToEncoding J.defaultOptions @@ -359,7 +346,7 @@ data GroupInfo = GroupInfo updatedAt :: UTCTime, chatTs :: Maybe UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -369,7 +356,7 @@ groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary { currentMembers :: Int } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions @@ -639,7 +626,7 @@ data GroupMember = GroupMember memberContactProfileId :: ProfileId, activeConn :: Maybe Connection } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON GroupMember where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -710,6 +697,9 @@ instance ToJSON MemberId where data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show, Generic) +instance FromJSON InvitedBy where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" + instance ToJSON InvitedBy where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" @@ -803,6 +793,9 @@ instance FromField GroupMemberCategory where fromField = fromTextField_ textDeco instance ToField GroupMemberCategory where toField = toField . textEncode +instance FromJSON GroupMemberCategory where + parseJSON = textParseJSON "GroupMemberCategory" + instance ToJSON GroupMemberCategory where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -840,6 +833,9 @@ instance FromField GroupMemberStatus where fromField = fromTextField_ textDecode instance ToField GroupMemberStatus where toField = toField . textEncode +instance FromJSON GroupMemberStatus where + parseJSON = textParseJSON "GroupMemberStatus" + instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -931,7 +927,7 @@ data SndFileTransfer = SndFileTransfer fileDescrId :: Maybe Int64, fileInline :: Maybe InlineFileMode } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON SndFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions @@ -997,7 +993,7 @@ instance FromField InlineFileMode where fromField = fromTextField_ textDecode instance ToField InlineFileMode where toField = toField . textEncode instance FromJSON InlineFileMode where - parseJSON = J.withText "InlineFileMode" $ maybe (fail "bad InlineFileMode") pure . textDecode + parseJSON = textParseJSON "InlineFileMode" instance ToJSON InlineFileMode where toJSON = J.String . textEncode @@ -1017,7 +1013,7 @@ data RcvFileTransfer = RcvFileTransfer -- SMP files are encrypted after all chunks are received cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions @@ -1026,7 +1022,7 @@ data XFTPRcvFile = XFTPRcvFile agentRcvFileId :: Maybe AgentRcvFileId, agentRcvFileDeleted :: Bool } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON XFTPRcvFile where toEncoding = J.genericToEncoding J.defaultOptions @@ -1036,7 +1032,7 @@ data RcvFileDescr = RcvFileDescr fileDescrPartNo :: Int, fileDescrComplete :: Bool } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON RcvFileDescr where toEncoding = J.genericToEncoding J.defaultOptions @@ -1048,6 +1044,9 @@ data RcvFileStatus | RFSCancelled (Maybe RcvFileInfo) deriving (Eq, Show, Generic) +instance FromJSON RcvFileStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" + instance ToJSON RcvFileStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" @@ -1065,7 +1064,7 @@ data RcvFileInfo = RcvFileInfo connId :: Maybe Int64, agentConnId :: Maybe AgentConnId } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -1094,6 +1093,9 @@ instance StrEncoding AgentConnId where strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP +instance FromJSON AgentConnId where + parseJSON = strParseJSON "AgentConnId" + instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1110,6 +1112,9 @@ instance StrEncoding AgentSndFileId where strDecode s = AgentSndFileId <$> strDecode s strP = AgentSndFileId <$> strP +instance FromJSON AgentSndFileId where + parseJSON = strParseJSON "AgentSndFileId" + instance ToJSON AgentSndFileId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1126,6 +1131,9 @@ instance StrEncoding AgentRcvFileId where strDecode s = AgentRcvFileId <$> strDecode s strP = AgentRcvFileId <$> strP +instance FromJSON AgentRcvFileId where + parseJSON = strParseJSON "AgentRcvFileId" + instance ToJSON AgentRcvFileId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1142,6 +1150,9 @@ instance StrEncoding AgentInvId where strDecode s = AgentInvId <$> strDecode s strP = AgentInvId <$> strP +instance FromJSON AgentInvId where + parseJSON = strParseJSON "AgentInvId" + instance ToJSON AgentInvId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1158,6 +1169,9 @@ data FileTransfer | FTRcv {rcvFileTransfer :: RcvFileTransfer} deriving (Show, Generic) +instance FromJSON FileTransfer where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "FT" + instance ToJSON FileTransfer where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" @@ -1172,7 +1186,7 @@ data FileTransferMeta = FileTransferMeta chunkSize :: Integer, cancelled :: Bool } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaultOptions @@ -1182,7 +1196,7 @@ data XFTPSndFile = XFTPSndFile agentSndFileDeleted :: Bool, cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON XFTPSndFile where toEncoding = J.genericToEncoding J.defaultOptions @@ -1197,6 +1211,9 @@ instance FromField FileStatus where fromField = fromTextField_ textDecode instance ToField FileStatus where toField = toField . textEncode +instance FromJSON FileStatus where + parseJSON = textParseJSON "FileStatus" + instance ToJSON FileStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1250,11 +1267,9 @@ connDisabled :: Connection -> Bool connDisabled Connection {authErrCounter} = authErrCounter >= authErrDisableCount data SecurityCode = SecurityCode {securityCode :: Text, verifiedAt :: UTCTime} - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) -instance ToJSON SecurityCode where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON SecurityCode where toEncoding = J.genericToEncoding J.defaultOptions verificationCode :: ByteString -> Text verificationCode = T.pack . unwords . chunks 5 . show . os2ip @@ -1273,6 +1288,9 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId +instance FromJSON Connection where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + instance ToJSON Connection where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -1290,7 +1308,7 @@ data PendingContactConnection = PendingContactConnection createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) aConnId' :: PendingContactConnection -> ConnId aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId @@ -1318,6 +1336,9 @@ instance FromField ConnStatus where fromField = fromTextField_ textDecode instance ToField ConnStatus where toField = toField . textEncode +instance FromJSON ConnStatus where + parseJSON = textParseJSON "ConnStatus" + instance ToJSON ConnStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1348,6 +1369,9 @@ instance FromField ConnType where fromField = fromTextField_ textDecode instance ToField ConnType where toField = toField . textEncode +instance FromJSON ConnType where + parseJSON = textParseJSON "ConnType" + instance ToJSON ConnType where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1550,6 +1574,24 @@ instance ToJSON ChatVersionRange where newtype JVersionRange = JVersionRange {fromJVersionRange :: VersionRange} 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 defOpts ''UserPwdHash) + +$(JQ.deriveJSON defOpts ''User) + +$(JQ.deriveJSON defOpts ''NewUser) + +$(JQ.deriveJSON defOpts ''UserInfo) + +$(JQ.deriveJSON defOpts ''Contact) + +$(JQ.deriveJSON defOpts ''ContactRef) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index c53e4476f4..c7555e18a8 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -338,7 +338,7 @@ data ContactUserPreferences = ContactUserPreferences voice :: ContactUserPreference VoicePreference, calls :: ContactUserPreference CallsPreference } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) data ContactUserPreference p = ContactUserPreference { enabled :: PrefEnabled, @@ -352,8 +352,13 @@ data ContactUserPref p = CUPContact {preference :: p} | CUPUser {preference :: p instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions +instance FromJSON p => FromJSON (ContactUserPreference p) where parseJSON = J.genericParseJSON J.defaultOptions + instance ToJSON p => ToJSON (ContactUserPreference p) where toEncoding = J.genericToEncoding J.defaultOptions +instance FromJSON p => FromJSON (ContactUserPref p) where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CUP" + instance ToJSON p => ToJSON (ContactUserPref p) where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs index fffdd24b9e..8681e99086 100644 --- a/src/Simplex/Chat/Types/Util.hs +++ b/src/Simplex/Chat/Types/Util.hs @@ -28,3 +28,6 @@ fromBlobField_ p = \case Right k -> Ok k Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) f -> returnError ConversionFailed f "expecting SQLBlob column type" + +defOpts :: J.Options +defOpts = J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 842a84cc65..f32b1835ed 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -73,10 +73,10 @@ responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users - CRChatStarted -> ["chat started"] - CRChatRunning -> ["chat is running"] - CRChatStopped -> ["chat stopped"] - CRChatSuspended -> ["chat suspended"] + CRChatStarted _ -> ["chat started"] + CRChatRunning _ -> ["chat is running"] + CRChatStopped _ -> ["chat stopped"] + CRChatSuspended _ -> ["chat suspended"] CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] @@ -267,7 +267,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRemoteHostDeleted rhId -> ["remote host " <> sShow rhId <> " deleted"] CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlStarted -> ["remote controller started"] + CRRemoteCtrlStarted _ -> ["remote controller started"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] CRRemoteCtrlAccepted rcId -> ["remote controller " <> sShow rcId <> " accepted"] diff --git a/stack.yaml b/stack.yaml index bce5dd3a68..9a343bcad2 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: ec1b72cb8013a65a5d9783104a47ae44f5730089 + commit: 96a38505d63ec9a12096991e7725b250e397af72 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher From a273c6859697f70c798881db825e766282c83a85 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 Oct 2023 22:33:48 +0100 Subject: [PATCH 008/174] core: rename migration, pin dependencies --- package.yaml | 4 +-- simplex-chat.cabal | 30 +++++++++---------- ...ller.hs => M20231005_remote_controller.hs} | 10 +++---- src/Simplex/Chat/Store/Migrations.hs | 6 ++-- 4 files changed, 25 insertions(+), 25 deletions(-) rename src/Simplex/Chat/Migrations/{M20230922_remote_controller.hs => M20231005_remote_controller.hs} (84%) diff --git a/package.yaml b/package.yaml index 663ae5bc26..f7fc614789 100644 --- a/package.yaml +++ b/package.yaml @@ -32,7 +32,7 @@ dependencies: - exceptions == 0.10.* - filepath == 1.4.* - http-types == 0.12.* - - http2 + - http2 == 4.1.* - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 @@ -50,7 +50,7 @@ dependencies: - terminal == 0.2.* - text == 2.0.* - time == 1.9.* - - tls + - tls >= 1.6.0 && < 1.7 - unliftio == 0.2.* - unliftio-core == 0.2.* - zip == 2.0.* diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8963203c11..8fa33f07f6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -113,8 +113,8 @@ library Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes - Simplex.Chat.Migrations.M20230922_remote_controller Simplex.Chat.Migrations.M20230926_contact_status + Simplex.Chat.Migrations.M20231005_remote_controller Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -170,7 +170,7 @@ library , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -188,7 +188,7 @@ library , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -223,7 +223,7 @@ executable simplex-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -242,7 +242,7 @@ executable simplex-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -277,7 +277,7 @@ executable simplex-bot-advanced , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -296,7 +296,7 @@ executable simplex-bot-advanced , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -333,7 +333,7 @@ executable simplex-broadcast-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -352,7 +352,7 @@ executable simplex-broadcast-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -388,7 +388,7 @@ executable simplex-chat , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* @@ -407,7 +407,7 @@ executable simplex-chat , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* @@ -447,7 +447,7 @@ executable simplex-directory-service , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -466,7 +466,7 @@ executable simplex-directory-service , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -529,7 +529,7 @@ test-suite simplex-chat-test , filepath ==1.4.* , hspec ==2.11.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* @@ -549,7 +549,7 @@ test-suite simplex-chat-test , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20231005_remote_controller.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20230922_remote_controller.hs rename to src/Simplex/Chat/Migrations/M20231005_remote_controller.hs index 21d653d124..0cb8634999 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20231005_remote_controller.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230922_remote_controller where +module Simplex.Chat.Migrations.M20231005_remote_controller where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20230922_remote_controller :: Query -m20230922_remote_controller = +m20231005_remote_controller :: Query +m20231005_remote_controller = [sql| CREATE TABLE remote_hosts ( -- hosts known to a controlling app remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -25,8 +25,8 @@ CREATE TABLE remote_controllers ( -- controllers known to a hosting app ); |] -down_m20230922_remote_controller :: Query -down_m20230922_remote_controller = +down_m20231005_remote_controller :: Query +down_m20231005_remote_controller = [sql| DROP TABLE remote_hosts; DROP TABLE remote_controllers; diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index c694a53714..5d789003a5 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -81,8 +81,8 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes -import Simplex.Chat.Migrations.M20230922_remote_controller import Simplex.Chat.Migrations.M20230926_contact_status +import Simplex.Chat.Migrations.M20231005_remote_controller import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -164,8 +164,8 @@ schemaMigrations = ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), - ("20230922_remote_controller", m20230922_remote_controller, Just down_m20230922_remote_controller), - ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), + ("20231005_remote_controller", m20231005_remote_controller, Just down_m20231005_remote_controller) ] -- | The list of migrations in ascending order by date From 91561da351c19ce621f0723620519999f162f6b3 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sat, 7 Oct 2023 16:23:24 +0300 Subject: [PATCH 009/174] core: http transport for remote session (#3178) * Wire some of the session endpoints * Start sending remote commands * Expand remote controller - Fix queues for pumping to remote - Add 3-way test - WIP: Add TTY wrapper for remote hosts - Stop remote controller w/o ids to match starting * Fix view events * Drop notifications, add message test * refactor, receive test * hunt down stray asyncs * Take discovery sockets in brackets --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/simplex-chat/Main.hs | 7 +- .../src/Directory/Service.hs | 2 +- src/Simplex/Chat.hs | 17 +- src/Simplex/Chat/Controller.hs | 95 +++++++- src/Simplex/Chat/Messages.hs | 4 +- src/Simplex/Chat/Remote.hs | 219 ++++++++++++------ src/Simplex/Chat/Remote/Discovery.hs | 26 ++- src/Simplex/Chat/Remote/Types.hs | 24 -- src/Simplex/Chat/Terminal/Input.hs | 10 +- src/Simplex/Chat/Terminal/Output.hs | 18 +- src/Simplex/Chat/Types.hs | 1 + src/Simplex/Chat/View.hs | 22 +- tests/ChatTests/Utils.hs | 3 + tests/RemoteTests.hs | 100 ++++++-- 14 files changed, 376 insertions(+), 172 deletions(-) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 8dd02623e2..c2ad7e7eb6 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -3,10 +3,11 @@ module Main where import Control.Concurrent (threadDelay) +import Control.Concurrent.STM.TVar (readTVarIO) import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Server -import Simplex.Chat.Controller (versionNumber, versionString) +import Simplex.Chat.Controller (currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Terminal @@ -28,10 +29,12 @@ main = do t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do + rh <- readTVarIO $ currentRemoteHost cc + let cmdRH = rh -- response RemoteHost is the same as for the command itself r <- sendChatCmdStr cc chatCmd ts <- getCurrentTime tz <- getCurrentTimeZone - putStrLn $ serializeChatResponse (Just user) ts tz r + putStrLn $ serializeChatResponse (rh, Just user) ts tz cmdRH r threadDelay $ chatCmdDelay opts * 1000000 welcome :: ChatOpts -> IO () diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 7ed39847a0..a30638249f 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -494,7 +494,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { sendChatCmdStr cc cmdStr >>= \r -> do ts <- getCurrentTime tz <- getCurrentTimeZone - sendReply $ serializeChatResponse (Just user) ts tz r + sendReply $ serializeChatResponse (Nothing, Just user) ts tz Nothing r DCCommandError tag -> sendReply $ "Command error: " <> show tag | otherwise = sendReply "You are not allowed to use this command" where diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bcd23f44ef..dc06d082b8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -193,6 +193,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen firstTime = dbNew chatStore activeTo <- newTVarIO ActiveNone currentUser <- newTVarIO user + currentRemoteHost <- newTVarIO Nothing servers <- agentServers config smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore agentAsync <- newTVarIO Nothing @@ -216,7 +217,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, remoteHostSessions, remoteCtrlSession, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + pure ChatController {activeTo, firstTime, currentUser, currentRemoteHost, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, remoteHostSessions, remoteCtrlSession, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -327,7 +328,9 @@ restoreCalls = do atomically $ writeTVar calls callsMap stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do + readTVarIO remoteHostSessions >>= mapM_ cancelRemoteHostSession + readTVarIO remoteCtrlSession >>= mapM_ cancelRemoteCtrlSession_ disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles @@ -349,8 +352,8 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Nothing -> execChatCommand_ u cmd - Just remoteHostId -> execRemoteCommand u remoteHostId (s, cmd) + Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId (s, cmd) + _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) @@ -1843,10 +1846,10 @@ processChatCommand = \case StartRemoteHost rh -> startRemoteHost rh StopRemoteHost rh -> closeRemoteHostSession rh DeleteRemoteHost rh -> deleteRemoteHost rh - StartRemoteCtrl -> startRemoteCtrl + StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) AcceptRemoteCtrl rc -> acceptRemoteCtrl rc RejectRemoteCtrl rc -> rejectRemoteCtrl rc - StopRemoteCtrl rc -> stopRemoteCtrl rc + StopRemoteCtrl -> stopRemoteCtrl RegisterRemoteCtrl oob -> registerRemoteCtrl oob ListRemoteCtrls -> listRemoteCtrls DeleteRemoteCtrl rc -> deleteRemoteCtrl rc @@ -5631,7 +5634,7 @@ chatCommandP = "/list remote ctrls" $> ListRemoteCtrls, "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), - "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), + "/stop remote ctrl" $> StopRemoteCtrl, "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 986eaf073e..dad5138bc1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -72,6 +72,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Cor import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) @@ -171,6 +172,7 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite data ChatController = ChatController { currentUser :: TVar (Maybe User), + currentRemoteHost :: TVar (Maybe RemoteHostId), activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, @@ -424,6 +426,7 @@ data ChatCommand | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host + -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake @@ -431,7 +434,7 @@ data ChatCommand | ListRemoteCtrls | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data - | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session + | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat | ShowVersion @@ -442,6 +445,29 @@ data ChatCommand | GetAgentSubsDetails deriving (Show) +allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal +allowRemoteCommand = \case + StartChat {} -> False + APIStopChat -> False + APIActivateChat -> False + APISuspendChat {} -> False + SetTempFolder {} -> False + QuitChat -> False + CreateRemoteHost -> False + ListRemoteHosts -> False + StartRemoteHost {} -> False + -- SwitchRemoteHost {} -> False + StopRemoteHost {} -> False + DeleteRemoteHost {} -> False + RegisterRemoteCtrl {} -> False + StartRemoteCtrl -> False + ListRemoteCtrls -> False + AcceptRemoteCtrl {} -> False + RejectRemoteCtrl {} -> False + StopRemoteCtrl -> False + DeleteRemoteCtrl {} -> False + _ -> True + data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} @@ -619,7 +645,7 @@ data ChatResponse | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} - | CRRemoteCtrlStopped {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlStopped {_nullary :: Maybe Int} | CRRemoteCtrlDeleted {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} @@ -638,6 +664,27 @@ data ChatResponse | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) +allowRemoteEvent :: ChatResponse -> Bool +allowRemoteEvent = \case + CRRemoteHostCreated {} -> False + CRRemoteHostList {} -> False + CRRemoteHostStarted {} -> False + CRRemoteHostConnected {} -> False + CRRemoteHostStopped {} -> False + CRRemoteHostDeleted {} -> False + CRRemoteCtrlList {} -> False + CRRemoteCtrlRegistered {} -> False + CRRemoteCtrlStarted {} -> False + CRRemoteCtrlAnnounce {} -> False + CRRemoteCtrlFound {} -> False + CRRemoteCtrlAccepted {} -> False + CRRemoteCtrlRejected {} -> False + CRRemoteCtrlConnecting {} -> False + CRRemoteCtrlConnected {} -> False + CRRemoteCtrlStopped {} -> False + CRRemoteCtrlDeleted {} -> False + _ -> True + logResponseToFile :: ChatResponse -> Bool logResponseToFile = \case CRContactsDisconnected {} -> True @@ -1107,6 +1154,27 @@ instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" +data RemoteHostSession + = RemoteHostSessionStarting + { announcer :: Async () + } + | RemoteHostSessionStarted + { -- | Path for local resources to be synchronized with host + storePath :: FilePath, + ctrlClient :: HTTP2Client + } + +data RemoteCtrlSession = RemoteCtrlSession + { -- | Server side of transport to process remote commands and forward notifications + discoverer :: Async (), + supervisor :: Async (), + hostServer :: Maybe (Async ()), + discovered :: TMap C.KeyHash TransportHost, + accepted :: TMVar RemoteCtrlId, + remoteOutputQ :: TBQueue ChatResponse, + remoteNotifyQ :: TBQueue Notification + } + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -1152,16 +1220,19 @@ unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) -- | Emit local events. toView :: ChatMonad' m => ChatResponse -> m () -toView = toView_ Nothing - --- | Used by transport to mark remote events with source. -toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () -toViewRemote = toView_ . Just - -toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () -toView_ rh event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, rh, event) +toView event = do + localQ <- asks outputQ + chatReadVar remoteCtrlSession >>= \case + Nothing -> atomically $ writeTBQueue localQ (Nothing, Nothing, event) + Just RemoteCtrlSession {remoteOutputQ} -> + if allowRemoteEvent event + then do + -- TODO: filter events or let the UI ignore trigger events by itself? + -- traceM $ "Sending event to remote Q: " <> show event + atomically $ writeTBQueue remoteOutputQ event -- TODO: check full? + else do + -- traceM $ "Sending event to local Q: " <> show event + atomically $ writeTBQueue localQ (Nothing, Nothing, event) withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a withStore' action = withStore $ liftIO . action diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b9ce953731..87bd8f4ef0 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -43,7 +43,7 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptSta import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) @@ -880,7 +880,7 @@ data SndCIStatusProgress deriving (Eq, Show, Generic) instance FromJSON SndCIStatusProgress where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SSP" + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "SSP" instance ToJSON SndCIStatusProgress where toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8f7a3b4f4c..7222743350 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -4,12 +4,15 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} module Simplex.Chat.Remote where +import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J @@ -18,9 +21,13 @@ import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U import qualified Data.ByteString.Char8 as B import Data.List.NonEmpty (NonEmpty (..)) +import Data.Maybe (fromMaybe) import qualified Data.Map.Strict as M +import qualified Data.Text as T import qualified Network.HTTP.Types as HTTP +import qualified Network.HTTP.Types.Status as Status import qualified Network.HTTP2.Client as HTTP2Client +import qualified Network.HTTP2.Server as HTTP2Server import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller import qualified Simplex.Chat.Remote.Discovery as Discovery @@ -36,7 +43,7 @@ import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow) +import Simplex.Messaging.Util (bshow, ifM, tshow) import System.Directory (getFileSize) import UnliftIO @@ -54,32 +61,67 @@ withRemoteHost remoteHostId action = startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse startRemoteHost remoteHostId = do - M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case + asks remoteHostSessions >>= atomically . TM.lookup remoteHostId >>= \case Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy - Nothing -> withRemoteHost remoteHostId run - where - run RemoteHost {storePath, caKey, caCert} = do - announcer <- async $ do - cleanup <- toIO $ closeRemoteHostSession remoteHostId >>= toView - let parent = (C.signatureKeyPair caKey, caCert) - sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" - let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] - Discovery.announceRevHTTP2 cleanup fingerprint credentials >>= \case - Left todo'err -> liftIO cleanup -- TODO: log error - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} - -- TODO: start streaming outputQ - toView CRRemoteHostConnected {remoteHostId} + Nothing -> withRemoteHost remoteHostId $ \rh -> do + announcer <- async $ run rh chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} pure CRRemoteHostStarted {remoteHostId} + where + cleanup finished = do + logInfo "Remote host http2 client fininshed" + atomically $ writeTVar finished True + closeRemoteHostSession remoteHostId >>= toView + run RemoteHost {storePath, caKey, caCert} = do + finished <- newTVarIO False + let parent = (C.signatureKeyPair caKey, caCert) + sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" + let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] + Discovery.announceRevHTTP2 (cleanup finished) fingerprint credentials >>= \case + Left h2ce -> do + logError $ "Failed to set up remote host connection: " <> tshow h2ce + cleanup finished + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} + chatWriteVar currentRemoteHost $ Just remoteHostId + sendHello ctrlClient >>= \case + Left h2ce -> do + logError $ "Failed to send initial remote host request: " <> tshow h2ce + cleanup finished + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + logDebug $ "Got initial from remote host: " <> tshow bodyHead + _ <- asks outputQ >>= async . pollRemote finished ctrlClient "/recv" (Nothing, Just remoteHostId,) + toView CRRemoteHostConnected {remoteHostId} + +sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) +sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) + where + req = HTTP2Client.requestNoBody "GET" "/" mempty + +pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> b) -> TBQueue b -> m () +pollRemote finished http path f queue = loop + where + loop = do + liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case + Left e -> logError $ "pollRemote: " <> tshow (path, e) + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> + case J.eitherDecodeStrict' bodyHead of + Left e -> logError $ "pollRemote/decode: " <> tshow (path, e) + Right o -> atomically $ writeTBQueue queue (f o) + readTVarIO finished >>= (`unless` loop) + req = HTTP2Client.requestNoBody "GET" path mempty closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do - case session of - RemoteHostSessionStarting {announcer} -> cancel announcer - RemoteHostSessionStarted {ctrlClient} -> liftIO (HTTP2.closeHTTP2Client ctrlClient) + liftIO $ cancelRemoteHostSession session + chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId - pure CRRemoteHostStopped { remoteHostId } + pure CRRemoteHostStopped {remoteHostId} + +cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () +cancelRemoteHostSession = \case + RemoteHostSessionStarting {announcer} -> cancel announcer + RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient createRemoteHost :: (ChatMonad m) => m ChatResponse createRemoteHost = do @@ -87,10 +129,7 @@ createRemoteHost = do ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName storePath <- liftIO randomStorePath remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert - let oobData = - RemoteCtrlOOB - { caFingerprint = C.certificateFingerprint caCert - } + let oobData = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert} pure CRRemoteHostCreated {remoteHostId, oobData} -- | Generate a random 16-char filepath without / in it by using base64url encoding. @@ -113,41 +152,40 @@ deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do pure CRRemoteHostDeleted {remoteHostId} processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand RemoteHostSessionStarting {} _ = error "TODO: sending remote commands before session started" -processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = +processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" +processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = do + logDebug $ "processRemoteCommand: " <> T.pack (show s) -- XXX: intercept and filter some commands -- TODO: store missing files on remote host relayCommand ctrlClient s relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse relayCommand http s = - postBytestring Nothing http "/relay" mempty s >>= \case - Left e -> error "TODO: http2chatError" + postBytestring Nothing http "/send" mempty s >>= \case + Left e -> err $ "relayCommand/post: " <> show e Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - remoteChatResponse <- - if iTax - then case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of - J.Error e -> error "TODO: json2chatError" e - J.Success cr -> pure cr - else case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right cr -> pure cr + logDebug $ "Got /send response: " <> T.pack (show bodyHead) + remoteChatResponse <- case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> err $ "relayCommand/decodeValue: " <> show e + Right json -> case J.fromJSON $ toTaggedJSON json of + J.Error e -> err $ "relayCommand/fromJSON: " <> show e + J.Success cr -> pure cr case remoteChatResponse of -- TODO: intercept file responses and fetch files when needed -- XXX: is that even possible, to have a file response to a command? _ -> pure remoteChatResponse where - iTax = True -- TODO: get from RemoteHost + err = pure . CRChatError Nothing . ChatError . CEInternalError + toTaggedJSON :: J.Value -> J.Value + toTaggedJSON = id -- owsf2tagged TODO: get from RemoteHost -- XXX: extract to http2 transport - postBytestring timeout c path hs body = liftIO $ HTTP2.sendRequest c req timeout + postBytestring timeout' c path hs body = liftIO $ HTTP2.sendRequestDirect c req timeout' where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) -- | Convert swift single-field sum encoding into tagged/discriminator-field -sum2tagged :: J.Value -> J.Value -sum2tagged = \case +owsf2tagged :: J.Value -> J.Value +owsf2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip @@ -161,13 +199,13 @@ storeRemoteFile http localFile = do where postFile timeout c path hs file = liftIO $ do fileSize <- fromIntegral <$> getFileSize file - HTTP2.sendRequest c (req fileSize) timeout + HTTP2.sendRequestDirect c (req fileSize) timeout where - req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) + req size = HTTP2Client.requestFile "PUT" path hs (HTTP2Client.FileSpec file 0 size) fetchRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> FileTransferId -> m ChatResponse fetchRemoteFile http storePath remoteFileId = do - liftIO (HTTP2.sendRequest http req Nothing) >>= \case + liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case Left e -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {respBody} -> do error "TODO: stream body into a local file" -- XXX: consult headers for a file name? @@ -175,47 +213,84 @@ fetchRemoteFile http storePath remoteFileId = do req = HTTP2Client.requestNoBody "GET" path mempty path = "/fetch/" <> bshow remoteFileId -processControllerRequest :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () -processControllerRequest rc req = error "TODO: processControllerRequest" +processControllerRequest :: forall m . (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () +processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody = HTTP2Body {bodyHead}, sendResponse} = do + logDebug $ "Remote controller request: " <> T.pack (show $ method <> " " <> path) + res <- tryChatError $ case (method, path) of + ("GET", "/") -> getHello + ("POST", "/send") -> sendCommand + ("GET", "/recv") -> recvMessage + ("PUT", "/store") -> storeFile + ("GET", "/fetch") -> fetchFile + unexpected -> respondWith Status.badRequest400 $ "unexpected method/path: " <> Binary.putStringUtf8 (show unexpected) + case res of + Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e + Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" + where + method = fromMaybe "" $ HTTP2Server.requestMethod request + path = fromMaybe "" $ HTTP2Server.requestPath request + getHello = respond "OK" + sendCommand = execChatCommand bodyHead >>= respondJSON + recvMessage = chatReadVar remoteCtrlSession >>= \case + Nothing -> respondWith Status.internalServerError500 "session not active" + Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON + storeFile = respondWith Status.notImplemented501 "TODO: storeFile" + fetchFile = respondWith Status.notImplemented501 "TODO: fetchFile" + + respondJSON :: J.ToJSON a => a -> m () + respondJSON = respond . Binary.fromLazyByteString . J.encode + + respond = respondWith Status.ok200 + respondWith status = liftIO . sendResponse . HTTP2Server.responseBuilder status [] -- * ChatRequest handlers -startRemoteCtrl :: (ChatMonad m) => m ChatResponse -startRemoteCtrl = +startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m ChatResponse +startRemoteCtrl execChatCommand = chatReadVar remoteCtrlSession >>= \case Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy Nothing -> do - accepted <- newEmptyTMVarIO + size <- asks $ tbqSize . config + remoteOutputQ <- newTBQueueIO size + remoteNotifyQ <- newTBQueueIO size discovered <- newTVarIO mempty discoverer <- async $ discoverRemoteCtrls discovered + accepted <- newEmptyTMVarIO supervisor <- async $ do remoteCtrlId <- atomically (readTMVar accepted) withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName} atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest remoteCtrlId) + server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName} _ <- waitCatch server chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted} + toView $ CRRemoteCtrlStopped Nothing + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ, remoteNotifyQ} pure $ CRRemoteCtrlStarted Nothing discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () -discoverRemoteCtrls discovered = Discovery.openListener >>= go +discoverRemoteCtrls discovered = Discovery.withListener go where go sock = Discovery.recvAnnounce sock >>= \case - (SockAddrInet _port addr, invite) -> case strDecode invite of + (SockAddrInet _sockPort sockAddr, invite) -> case strDecode invite of Left _ -> go sock -- ignore malformed datagrams Right fingerprint -> do - atomically $ TM.insert fingerprint (THIPv4 $ hostAddressToTuple addr) discovered + let addr = THIPv4 (hostAddressToTuple sockAddr) + ifM + (atomically $ TM.member fingerprint discovered) + (logDebug $ "Fingerprint announce already knwon: " <> T.pack (show (addr, invite))) + (do + logInfo $ "New fingerprint announce: " <> T.pack (show (addr, invite)) + atomically $ TM.insert fingerprint addr discovered + ) withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui action required + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required Just found@RemoteCtrl {remoteCtrlId, accepted=storedChoice} -> case storedChoice of - Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui action required + Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required Just False -> pure () -- skipping a rejected item Just True -> chatReadVar remoteCtrlSession >>= \case Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" @@ -258,20 +333,28 @@ rejectRemoteCtrl remoteCtrlId = do cancel supervisor pure $ CRRemoteCtrlRejected {remoteCtrlId} -stopRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -stopRemoteCtrl remoteCtrlId = +stopRemoteCtrl :: (ChatMonad m) => m ChatResponse +stopRemoteCtrl = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {discoverer, supervisor, hostServer} -> do - cancel discoverer -- may be gone by now - case hostServer of - Just host -> cancel host -- supervisor will clean up - Nothing -> do - cancel supervisor -- supervisor is blocked until session progresses - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} + Just rcs -> do + cancelRemoteCtrlSession rcs $ do + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped Nothing pure $ CRCmdOk Nothing +cancelRemoteCtrlSession_ :: MonadUnliftIO m => RemoteCtrlSession -> m () +cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () + +cancelRemoteCtrlSession :: MonadUnliftIO m => RemoteCtrlSession -> m () -> m () +cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do + cancel discoverer -- may be gone by now + case hostServer of + Just host -> cancel host -- supervisor will clean up + Nothing -> do + cancel supervisor -- supervisor is blocked until session progresses + cleanup + deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse deleteRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 2faed66cd8..40314b4cb5 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Remote.Discovery -- * Discovery connectRevHTTP2, + withListener, openListener, recvAnnounce, connectTLSClient, @@ -32,7 +33,7 @@ import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) import Simplex.Messaging.Util (whenM) @@ -52,15 +53,16 @@ pattern BROADCAST_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => m () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) announceRevHTTP2 finishAction invite credentials = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ runAnnouncer (strEncode invite) tlsServer <- startTLSServer started credentials $ \tls -> cancel announcer >> runHTTP2Client finished httpClient tls - _ <- forkIO . liftIO $ do + _ <- forkIO $ do readMVar finished + cancel announcer cancel tlsServer finishAction readMVar httpClient @@ -68,11 +70,12 @@ announceRevHTTP2 finishAction invite credentials = do -- | Broadcast invite with link-local datagrams runAnnouncer :: ByteString -> IO () runAnnouncer inviteBS = do - sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False - N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - forever $ do - UDP.send sock inviteBS - threadDelay 1000000 + bracket (UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False) UDP.close $ \sock -> do + N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 + N.setSocketOption (UDP.udpSocket sock) N.ReuseAddr 1 + forever $ do + UDP.send sock inviteBS + threadDelay 1000000 startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig @@ -88,8 +91,13 @@ startTLSServer started credentials = async . liftIO . runTransportServer started -- | Attach HTTP2 client and hold the TLS until the attached client finishes. runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () runHTTP2Client finishedVar clientVar tls = do - attachHTTP2Client defaultHTTP2ClientConfig ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar + attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar readMVar finishedVar + where + config = defaultHTTP2ClientConfig { connTimeout = 86400000000 } + +withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a +withListener = bracket openListener (liftIO . UDP.stop) openListener :: (MonadIO m) => m UDP.ListenSocket openListener = liftIO $ do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index f13c3c84ea..cdff2b7acc 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -5,15 +5,10 @@ module Simplex.Chat.Remote.Types where -import Control.Concurrent.Async (Async) import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) -import UnliftIO.STM type RemoteHostId = Int64 @@ -40,22 +35,3 @@ data RemoteCtrl = RemoteCtrl deriving (Show) $(J.deriveJSON J.defaultOptions ''RemoteCtrl) - -data RemoteHostSession - = RemoteHostSessionStarting - { announcer :: Async () - } - | RemoteHostSessionStarted - { -- | Path for local resources to be synchronized with host - storePath :: FilePath, - ctrlClient :: HTTP2Client - } - -data RemoteCtrlSession = RemoteCtrlSession - { -- | Server side of transport to process remote commands and forward notifications - discoverer :: Async (), - supervisor :: Async (), - hostServer :: Maybe (Async ()), - discovered :: TMap C.KeyHash TransportHost, - accepted :: TMVar RemoteCtrlId - } diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 1097a79543..4a73a0fd72 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -53,15 +53,16 @@ getKey = runInputLoop :: ChatTerminal -> ChatController -> IO () runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do s <- atomically . readTBQueue $ inputQ cc + rh <- readTVarIO $ currentRemoteHost cc let bs = encodeUtf8 $ T.pack s cmd = parseChatCommand bs unless (isMessage cmd) $ echo s - r <- runReaderT (execChatCommand Nothing bs) cc + r <- runReaderT (execChatCommand rh bs) cc case r of CRChatCmdError _ _ -> when (isMessage cmd) $ echo s CRChatError _ _ -> when (isMessage cmd) $ echo s _ -> pure () - printRespToTerminal ct cc False r + printRespToTerminal ct cc False rh r startLiveMessage cmd r where echo s = printToTerminal ct [plain s] @@ -134,7 +135,7 @@ runTerminalInput ct cc = withChatTerm ct $ do receiveFromTTY cc ct receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () -receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} = +receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} = forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) where processKey :: (Key, Modifiers) -> IO () @@ -166,7 +167,8 @@ receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@C kill promptThreadId atomically $ writeTVar liveMessageState Nothing r <- sendUpdatedLiveMessage cc sentMsg lm False - printRespToTerminal ct cc False r + rh <- readTVarIO currentRemoteHost -- XXX: should be inherited from live message state + printRespToTerminal ct cc False rh r where kill sel = deRefWeak (sel lm) >>= mapM_ killThread diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 74bb9e8c0b..e6792129cb 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -21,6 +21,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Styled import Simplex.Chat.View +import Simplex.Chat.Remote.Types (RemoteHostId) import System.Console.ANSI.Types import System.IO (IOMode (..), hPutStrLn, withFile) import System.Mem.Weak (Weak) @@ -112,7 +113,7 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = do forever $ do - (_, _, r) <- atomically $ readTBQueue outputQ + (_, outputRH, r) <- atomically $ readTBQueue outputQ case r of CRNewChatItem _ ci -> markChatItemRead ci CRChatItemUpdated _ ci -> markChatItemRead ci @@ -121,7 +122,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d Just path -> if logResponseToFile r then logResponse path else printToTerminal ct _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems - responseString cc liveItems r >>= printResp + responseString cc liveItems outputRH r >>= printResp where markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = case (muted chat chatDir, itemStatus) of @@ -132,15 +133,16 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s -printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> ChatResponse -> IO () -printRespToTerminal ct cc liveItems r = responseString cc liveItems r >>= printToTerminal ct +printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO () +printRespToTerminal ct cc liveItems outputRH r = responseString cc liveItems outputRH r >>= printToTerminal ct -responseString :: ChatController -> Bool -> ChatResponse -> IO [StyledString] -responseString cc liveItems r = do - user <- readTVarIO $ currentUser cc +responseString :: ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO [StyledString] +responseString cc liveItems outputRH r = do + currentRH <- readTVarIO $ currentRemoteHost cc + user <- readTVarIO $ currentUser cc -- XXX: local user, should be subsumed by remote when connected ts <- getCurrentTime tz <- getCurrentTimeZone - pure $ responseToView user (config cc) liveItems ts tz r + pure $ responseToView (currentRH, user) (config cc) liveItems ts tz outputRH r printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal ct s = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 088f23e056..03e0135e78 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1442,6 +1442,7 @@ serializeIntroStatus = \case GMIntroConnected -> "con" data Notification = Notification {title :: Text, text :: Text} + deriving (Show, Generic, FromJSON, ToJSON) textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f32b1835ed..f98406d1ba 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -66,11 +66,11 @@ import System.Console.ANSI.Types type CurrentTime = UTCTime -serializeChatResponse :: Maybe User -> CurrentTime -> TimeZone -> ChatResponse -> String -serializeChatResponse user_ ts tz = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz +serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> String +serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ -responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> ChatResponse -> [StyledString] -responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz = \case +responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] +responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users CRChatStarted _ -> ["chat started"] @@ -274,7 +274,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRemoteCtrlRejected rcId -> ["remote controller " <> sShow rcId <> " rejected"] CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] - CRRemoteCtrlStopped rcId -> ["remote controller " <> sShow rcId <> " stopped"] + CRRemoteCtrlStopped _ -> ["remote controller stopped"] CRRemoteCtrlDeleted rcId -> ["remote controller " <> sShow rcId <> " deleted"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> @@ -323,12 +323,14 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView | otherwise = [] ttyUserPrefix :: User -> [StyledString] -> [StyledString] ttyUserPrefix _ [] = [] - ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst userPrefix ss + ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst prefix ss where - userPrefix = case user_ of - Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else "" - _ -> prefix - prefix = "[user: " <> highlight u <> "] " + prefix = if outputRH /= currentRH then r else userPrefix + r = case outputRH of + Nothing -> "[local] " <> userPrefix + Just rh -> "[remote: ]" <> highlight (show rh) <> "] " + userPrefix = if Just userId /= currentUserId then "[user: " <> highlight u <> "] " else "" + currentUserId = fmap (\User {userId} -> userId) user_ ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 6831cf3190..107faef729 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -34,6 +34,9 @@ import Test.Hspec defaultPrefs :: Maybe Preferences defaultPrefs = Just $ toChatPrefs defaultChatPrefs +aliceDesktopProfile :: Profile +aliceDesktopProfile = Profile {displayName = "alice_desktop", fullName = "Alice Desktop", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} + aliceProfile :: Profile aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index d1c162187f..f9137cdbaf 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -31,7 +31,8 @@ remoteTests :: SpecWith FilePath remoteTests = describe "Handshake" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - it "connects desktop and mobile" remoteHandshakeTest + xit "connects desktop and mobile" remoteHandshakeTest + it "send messages via remote desktop" remoteCommandTest -- * Low-level TLS with ephemeral credentials @@ -39,11 +40,10 @@ genCredentialsTest :: (HasCallStack) => FilePath -> IO () genCredentialsTest _tmp = do (fingerprint, credentials) <- genTestCredentials started <- newEmptyTMVarIO - server <- Discovery.startTLSServer started credentials serverHandler - ok <- atomically (readTMVar started) - unless ok $ cancel server >> error "TLS server failed to start" - Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler - cancel server + bracket (Discovery.startTLSServer started credentials serverHandler) cancel $ \_server -> do + ok <- atomically (readTMVar started) + unless ok $ error "TLS server failed to start" + Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler where serverHandler serverTls = do traceM " - Sending from server" @@ -62,19 +62,21 @@ announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do (fingerprint, credentials) <- genTestCredentials finished <- newEmptyMVar - announcer <- async $ do + controller <- async $ do traceM " - Controller: starting" - http <- Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure - traceM " - Controller: got client" - sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case - Left err -> do - traceM " - Controller: got error" - fail $ show err - Right HTTP2Response {} -> - traceM " - Controller: got response" - closeHTTP2Client http - dis <- async $ do - sock <- Discovery.openListener + bracket + (Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure) + closeHTTP2Client + ( \http -> do + traceM " - Controller: got client" + sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case + Left err -> do + traceM " - Controller: got error" + fail $ show err + Right HTTP2Response {} -> + traceM " - Controller: got response" + ) + host <- async $ Discovery.withListener $ \sock -> do (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock strDecode invite `shouldBe` Right fingerprint traceM " - Host: connecting" @@ -84,14 +86,13 @@ announceDiscoverHttp2Test _tmp = do traceM " - Host: got request" sendResponse $ S.responseNoBody ok200 [] traceM " - Host: sent response" - takeMVar finished - cancel server + takeMVar finished `finally` cancel server traceM " - Host: finished" - waitBoth dis announcer `shouldReturn` ((), ()) + (waitBoth host controller `shouldReturn` ((), ())) `onException` (cancel host >> cancel controller) -- * Chat commands -remoteHandshakeTest :: HasCallStack => FilePath -> IO () +remoteHandshakeTest :: (HasCallStack) => FilePath -> IO () remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" @@ -103,7 +104,6 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "Remote hosts:" desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet - desktop ##> "/start remote host 1" desktop <## "remote host 1 started" @@ -124,9 +124,9 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" - mobile ##> "/stop remote ctrl 1" + mobile ##> "/stop remote ctrl" mobile <## "ok" - mobile <## "remote controller 1 stopped" -- TODO two outputs + mobile <## "remote controller stopped" mobile ##> "/delete remote ctrl 1" mobile <## "remote controller 1 deleted" mobile ##> "/list remote ctrls" @@ -139,6 +139,56 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" +remoteCommandTest :: (HasCallStack) => FilePath -> IO () +remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + desktop ##> "/create remote host" + desktop <## "remote host 1 created" + desktop <## "connection code:" + fingerprint <- getTermLine desktop + + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + + mobile ##> "/start remote ctrl" + mobile <## "remote controller started" + mobile <## "remote controller announced" + mobile <## "connection code:" + fingerprint' <- getTermLine mobile + fingerprint' `shouldBe` fingerprint + mobile ##> ("/register remote ctrl " <> fingerprint') + mobile <## "remote controller 1 registered" + mobile ##> "/accept remote ctrl 1" + mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "remote controller 1 connecting to TODO" + mobile <## "remote controller 1 connected, TODO" + desktop <## "remote host 1 connected" + + traceM " - exchanging contacts" + bob ##> "/c" + inv' <- getInvitation bob + desktop ##> ("/c " <> inv') + desktop <## "confirmation sent!" + concurrently_ + (desktop <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + + traceM " - sending messages" + desktop #> "@bob hello there 🙂" + bob <# "alice> hello there 🙂" + bob #> "@alice hi" + desktop <# "bob> hi" + + traceM " - post-remote checks" + mobile ##> "/stop remote ctrl" + mobile <## "ok" + concurrently_ + (mobile <## "remote controller stopped") + (desktop <## "remote host 1 stopped") + mobile ##> "/contacts" + mobile <## "bob (Bob)" + + traceM " - done" + -- * Utils genTestCredentials :: IO (C.KeyHash, TLS.Credentials) From 6f5ba54f7b96e85a6b59d0522e4d6b71754e2f48 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 11 Oct 2023 11:45:05 +0300 Subject: [PATCH 010/174] core: remote session files (#3189) * Receiving files on CRRcvFileComplete * Add remote /fr test * Add broken startFileTransfer notice * Sending files with SendFile/SendImage With tests for SendFile. * Add APISendMessage handling * Test file preconditions No files should be in stores before actual sending. * Fix mobile paths in storeFile --- src/Simplex/Chat/Remote.hs | 218 +++++++++++++++++++++++++++---------- tests/RemoteTests.hs | 92 ++++++++++++++++ 2 files changed, 255 insertions(+), 55 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 7222743350..0f03c1fdb5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} @@ -16,36 +17,47 @@ import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J +import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Binary.Builder as Binary -import Data.ByteString (ByteString) +import Data.ByteString (ByteString, hPut) import qualified Data.ByteString.Base64.URL as B64U import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty (..)) -import Data.Maybe (fromMaybe) import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) import qualified Data.Text as T +import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP.Types.Status as Status import qualified Network.HTTP2.Client as HTTP2Client import qualified Network.HTTP2.Server as HTTP2Server import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller +import Simplex.Chat.Messages (AChatItem (..), CIFile (..), CIFileStatus (..), ChatItem (..), chatNameStr) +import Simplex.Chat.Messages.CIContent (MsgDirection (..), SMsgDirection (..)) import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Files (getRcvFileTransfer) +import Simplex.Chat.Store.Profiles (getUser) import Simplex.Chat.Store.Remote +import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types +import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) -import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) +import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow, ifM, tshow) -import System.Directory (getFileSize) +import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO +import UnliftIO.Directory (createDirectoryIfMissing, getFileSize, makeAbsolute) withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do @@ -90,7 +102,15 @@ startRemoteHost remoteHostId = do cleanup finished Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do logDebug $ "Got initial from remote host: " <> tshow bodyHead - _ <- asks outputQ >>= async . pollRemote finished ctrlClient "/recv" (Nothing, Just remoteHostId,) + oq <- asks outputQ + let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just remoteHostId,) + void . async $ pollRemote finished ctrlClient "/recv" $ \chatResponse -> do + case chatResponse of + CRRcvFileComplete {user = ru, chatItem = AChatItem c d@SMDRcv i ci@ChatItem {file = Just ciFile}} -> do + handleRcvFileComplete ctrlClient storePath ru ciFile >>= \case + Nothing -> toViewRemote chatResponse + Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}} + _ -> toViewRemote chatResponse toView CRRemoteHostConnected {remoteHostId} sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) @@ -98,16 +118,17 @@ sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) where req = HTTP2Client.requestNoBody "GET" "/" mempty -pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> b) -> TBQueue b -> m () -pollRemote finished http path f queue = loop +pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> m ()) -> m () +pollRemote finished http path action = loop where loop = do liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case Left e -> logError $ "pollRemote: " <> tshow (path, e) - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + logDebug $ "Got /recv response: " <> decodeUtf8 bodyHead case J.eitherDecodeStrict' bodyHead of Left e -> logError $ "pollRemote/decode: " <> tshow (path, e) - Right o -> atomically $ writeTBQueue queue (f o) + Right o -> action o readTVarIO finished >>= (`unless` loop) req = HTTP2Client.requestNoBody "GET" path mempty @@ -118,7 +139,7 @@ closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \sess chatModifyVar remoteHostSessions $ M.delete remoteHostId pure CRRemoteHostStopped {remoteHostId} -cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () +cancelRemoteHostSession :: (MonadUnliftIO m) => RemoteHostSession -> m () cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient @@ -154,17 +175,31 @@ deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = do - logDebug $ "processRemoteCommand: " <> T.pack (show s) - -- XXX: intercept and filter some commands - -- TODO: store missing files on remote host - relayCommand ctrlClient s + logDebug $ "processRemoteCommand: " <> tshow (s, cmd) + case cmd of + SendFile cn ctrlPath -> do + storeRemoteFile ctrlClient ctrlPath >>= \case + -- TODO: use Left + Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" + Just hostPath -> relayCommand ctrlClient $ "/file " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath + SendImage cn ctrlPath -> do + storeRemoteFile ctrlClient ctrlPath >>= \case + Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store image on remote host" + Just hostPath -> relayCommand ctrlClient $ "/image " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath + APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do + storeRemoteFile ctrlClient ctrlPath >>= \case + Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" + Just hostPath -> do + let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage + relayCommand ctrlClient $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') + _ -> relayCommand ctrlClient s relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse relayCommand http s = postBytestring Nothing http "/send" mempty s >>= \case Left e -> err $ "relayCommand/post: " <> show e Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got /send response: " <> T.pack (show bodyHead) + logDebug $ "Got /send response: " <> decodeUtf8 bodyHead remoteChatResponse <- case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks Left e -> err $ "relayCommand/decodeValue: " <> show e Right json -> case J.fromJSON $ toTaggedJSON json of @@ -183,61 +218,129 @@ relayCommand http s = where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) +handleRcvFileComplete :: (ChatMonad m) => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) +handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fileStatus} = case fileStatus of + CIFSRcvComplete -> + chatReadVar filesFolder >>= \case + Just baseDir -> do + let hostStore = baseDir storePath + createDirectoryIfMissing True hostStore + localPath <- uniqueCombine hostStore fileName + ok <- fetchRemoteFile http remoteUser fileId localPath + pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) + Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" + _ -> Nothing <$ logDebug ("Ingoring invalid file notification for file (" <> tshow fileId <> ") " <> tshow fileName) + -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value owsf2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip -storeRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> m ChatResponse +storeRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> FilePath -> m (Maybe FilePath) storeRemoteFile http localFile = do - postFile Nothing http "/store" mempty localFile >>= \case - Left todo'err -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of - Just 200 -> pure $ CRCmdOk Nothing - todo'notOk -> error "TODO: http2chatError" + putFile Nothing http uri mempty localFile >>= \case + Left h2ce -> Nothing <$ logError (tshow h2ce) + Right HTTP2.HTTP2Response {response, respBody = HTTP2Body {bodyHead}} -> + case HTTP.statusCode <$> HTTP2Client.responseStatus response of + Just 200 -> pure . Just $ B.unpack bodyHead + notOk -> Nothing <$ logError ("Bad response status: " <> tshow notOk) where - postFile timeout c path hs file = liftIO $ do + uri = "/store?" <> HTTP.renderSimpleQuery False [("file_name", utf8String $ takeFileName localFile)] + putFile timeout c path hs file = liftIO $ do fileSize <- fromIntegral <$> getFileSize file HTTP2.sendRequestDirect c (req fileSize) timeout where req size = HTTP2Client.requestFile "PUT" path hs (HTTP2Client.FileSpec file 0 size) -fetchRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> FileTransferId -> m ChatResponse -fetchRemoteFile http storePath remoteFileId = do +fetchRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> User -> Int64 -> FilePath -> m Bool +fetchRemoteFile http User {userId = remoteUserId} remoteFileId localPath = do liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case - Left e -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response {respBody} -> do - error "TODO: stream body into a local file" -- XXX: consult headers for a file name? + Left h2ce -> False <$ logError (tshow h2ce) + Right HTTP2.HTTP2Response {response, respBody} -> + if HTTP2Client.responseStatus response == Just Status.ok200 + then True <$ writeBodyToFile localPath respBody + else False <$ (logError $ "Request failed: " <> maybe "(??)" tshow (HTTP2Client.responseStatus response) <> " " <> decodeUtf8 (bodyHead respBody)) where req = HTTP2Client.requestNoBody "GET" path mempty - path = "/fetch/" <> bshow remoteFileId + path = "/fetch?" <> HTTP.renderSimpleQuery False [("user_id", bshow remoteUserId), ("file_id", bshow remoteFileId)] -processControllerRequest :: forall m . (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () -processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody = HTTP2Body {bodyHead}, sendResponse} = do - logDebug $ "Remote controller request: " <> T.pack (show $ method <> " " <> path) - res <- tryChatError $ case (method, path) of - ("GET", "/") -> getHello - ("POST", "/send") -> sendCommand - ("GET", "/recv") -> recvMessage - ("PUT", "/store") -> storeFile - ("GET", "/fetch") -> fetchFile +-- XXX: extract to Transport.HTTP2 ? +writeBodyToFile :: (MonadUnliftIO m) => FilePath -> HTTP2Body -> m () +writeBodyToFile path HTTP2Body {bodyHead, bodySize, bodyPart} = do + logInfo $ "Receiving " <> tshow bodySize <> " bytes to " <> tshow path + liftIO . withFile path WriteMode $ \h -> do + hPut h bodyHead + mapM_ (hPutBodyChunks h) bodyPart + +hPutBodyChunks :: Handle -> (Int -> IO ByteString) -> IO () +hPutBodyChunks h getChunk = do + chunk <- getChunk defaultHTTP2BufferSize + unless (B.null chunk) $ do + hPut h chunk + hPutBodyChunks h getChunk + +processControllerRequest :: forall m. (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () +processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, sendResponse} = do + logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) + res <- tryChatError $ case (method, ps) of + ("GET", []) -> getHello + ("POST", ["send"]) -> sendCommand + ("GET", ["recv"]) -> recvMessage + ("PUT", ["store"]) -> storeFile + ("GET", ["fetch"]) -> fetchFile unexpected -> respondWith Status.badRequest400 $ "unexpected method/path: " <> Binary.putStringUtf8 (show unexpected) case res of Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" where method = fromMaybe "" $ HTTP2Server.requestMethod request - path = fromMaybe "" $ HTTP2Server.requestPath request + path = fromMaybe "/" $ HTTP2Server.requestPath request + (ps, query) = HTTP.decodePath path getHello = respond "OK" - sendCommand = execChatCommand bodyHead >>= respondJSON - recvMessage = chatReadVar remoteCtrlSession >>= \case - Nothing -> respondWith Status.internalServerError500 "session not active" - Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON - storeFile = respondWith Status.notImplemented501 "TODO: storeFile" - fetchFile = respondWith Status.notImplemented501 "TODO: fetchFile" + sendCommand = execChatCommand (bodyHead reqBody) >>= respondJSON + recvMessage = + chatReadVar remoteCtrlSession >>= \case + Nothing -> respondWith Status.internalServerError500 "session not active" + Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON + storeFile = case storeFileQuery of + Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) + Right fileName -> do + baseDir <- fromMaybe "." <$> chatReadVar filesFolder + localPath <- uniqueCombine baseDir fileName + logDebug $ "Storing controller file to " <> tshow (baseDir, localPath) + writeBodyToFile localPath reqBody + let storeRelative = takeFileName localPath + respond $ Binary.putStringUtf8 storeRelative + where + storeFileQuery = parseField "file_name" $ A.many1 (A.satisfy $ not . isPathSeparator) + fetchFile = case fetchFileQuery of + Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) + Right (userId, fileId) -> do + logInfo $ "Fetching file " <> tshow fileId <> " from user " <> tshow userId + x <- withStore' $ \db -> runExceptT $ do + user <- getUser db userId + getRcvFileTransfer db user fileId + case x of + Right RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> do + baseDir <- fromMaybe "." <$> chatReadVar filesFolder + let fullPath = baseDir filePath + size <- fromInteger <$> getFileSize fullPath + liftIO . sendResponse . HTTP2Server.responseFile Status.ok200 mempty $ HTTP2Server.FileSpec fullPath 0 size + Right _ -> respondWith Status.internalServerError500 "The requested file is not complete" + Left SEUserNotFound {} -> respondWith Status.notFound404 "User not found" + Left SERcvFileNotFound {} -> respondWith Status.notFound404 "File not found" + _ -> respondWith Status.internalServerError500 "Store error" + where + fetchFileQuery = + (,) + <$> parseField "user_id" A.decimal + <*> parseField "file_id" A.decimal - respondJSON :: J.ToJSON a => a -> m () + parseField :: ByteString -> A.Parser a -> Either String a + parseField field p = maybe (Left $ "missing " <> B.unpack field) (A.parseOnly $ p <* A.endOfInput) (join $ lookup field query) + + respondJSON :: (J.ToJSON a) => a -> m () respondJSON = respond . Binary.fromLazyByteString . J.encode respond = respondWith Status.ok200 @@ -282,19 +385,20 @@ discoverRemoteCtrls discovered = Discovery.withListener go let addr = THIPv4 (hostAddressToTuple sockAddr) ifM (atomically $ TM.member fingerprint discovered) - (logDebug $ "Fingerprint announce already knwon: " <> T.pack (show (addr, invite))) - (do - logInfo $ "New fingerprint announce: " <> T.pack (show (addr, invite)) - atomically $ TM.insert fingerprint addr discovered + (logDebug $ "Fingerprint announce already knwon: " <> tshow (addr, invite)) + ( do + logInfo $ "New fingerprint announce: " <> tshow (addr, invite) + atomically $ TM.insert fingerprint addr discovered ) withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required - Just found@RemoteCtrl {remoteCtrlId, accepted=storedChoice} -> case storedChoice of + Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required Just False -> pure () -- skipping a rejected item - Just True -> chatReadVar remoteCtrlSession >>= \case - Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" - Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically + Just True -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" + Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m ChatResponse @@ -343,10 +447,10 @@ stopRemoteCtrl = toView $ CRRemoteCtrlStopped Nothing pure $ CRCmdOk Nothing -cancelRemoteCtrlSession_ :: MonadUnliftIO m => RemoteCtrlSession -> m () +cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () -cancelRemoteCtrlSession :: MonadUnliftIO m => RemoteCtrlSession -> m () -> m () +cancelRemoteCtrlSession :: (MonadUnliftIO m) => RemoteCtrlSession -> m () -> m () cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do cancel discoverer -- may be gone by now case hostServer of @@ -368,3 +472,7 @@ withRemoteCtrl remoteCtrlId action = withStore' (`getRemoteCtrl` remoteCtrlId) >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEMissing {remoteCtrlId} Just rc -> action rc + +utf8String :: [Char] -> ByteString +utf8String = encodeUtf8 . T.pack +{-# INLINE utf8String #-} diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index f9137cdbaf..34e2b04a6f 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -9,13 +10,16 @@ module RemoteTests where import ChatClient import ChatTests.Utils import Control.Monad +import qualified Data.ByteString as B import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.Map.Strict as M import Debug.Trace import Network.HTTP.Types (ok200) import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S import qualified Network.Socket as N import qualified Network.TLS as TLS +import qualified Simplex.Chat.Controller as Controller import qualified Simplex.Chat.Remote.Discovery as Discovery import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -24,8 +28,11 @@ import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import System.FilePath (makeRelative, ()) import Test.Hspec import UnliftIO +import UnliftIO.Concurrent (threadDelay) +import UnliftIO.Directory remoteTests :: SpecWith FilePath remoteTests = describe "Handshake" $ do @@ -141,6 +148,16 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do remoteCommandTest :: (HasCallStack) => FilePath -> IO () remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + let mobileFiles = "./tests/tmp/mobile_files" + mobile ##> ("/_files_folder " <> mobileFiles) + mobile <## "ok" + let desktopFiles = "./tests/tmp/desktop_files" + desktop ##> ("/_files_folder " <> desktopFiles) + desktop <## "ok" + let bobFiles = "./tests/tmp/bob_files" + bob ##> ("/_files_folder " <> bobFiles) + bob <## "ok" + desktop ##> "/create remote host" desktop <## "remote host 1 created" desktop <## "connection code:" @@ -178,12 +195,87 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob bob #> "@alice hi" desktop <# "bob> hi" + withXFTPServer $ do + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopStore <- case M.lookup 1 rhs of + Just Controller.RemoteHostSessionStarted {storePath} -> pure storePath + _ -> fail "Host session 1 should be started" + + doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False + doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False + mobileName <- userName mobile + + bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" + bob #> ("/f @" <> mobileName <> " " <> bobsFile) + bob <## "use /fc 1 to cancel sending" + + desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" + desktop <## "use /fr 1 [/ | ] to receive it" + desktop ##> "/fr 1" + concurrently_ + do + bob <## "started sending file 1 (test.pdf) to alice" + bob <## "completed sending file 1 (test.pdf) to alice" + + do + desktop <## "saving file 1 from bob to test.pdf" + desktop <## "started receiving file 1 (test.pdf) from bob" + + let desktopReceived = desktopFiles desktopStore "test.pdf" + desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") + bobsFileSize <- getFileSize bobsFile + getFileSize desktopReceived `shouldReturn` bobsFileSize + bobsFileBytes <- B.readFile bobsFile + B.readFile desktopReceived `shouldReturn` bobsFileBytes + + -- test file transit on mobile + mobile ##> "/fs 1" + mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" + getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize + B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes + + traceM " - file received" + + desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f + traceM $ " - sending " <> show desktopFile + doesFileExist (bobFiles "logo.jpg") `shouldReturn` False + doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False + desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" + desktop <# "@bob hi, sending a file" + desktop <# "/f @bob logo.jpg" + desktop <## "use /fc 2 to cancel sending" + + bob <# "alice> hi, sending a file" + bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> "/fr 2" + concurrently_ + do + bob <## "saving file 2 from alice to logo.jpg" + bob <## "started receiving file 2 (logo.jpg) from alice" + bob <## "completed receiving file 2 (logo.jpg) from alice" + bob ##> "/fs 2" + bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg" + do + desktop <## "started sending file 2 (logo.jpg) to bob" + desktop <## "completed sending file 2 (logo.jpg) to bob" + desktopFileSize <- getFileSize desktopFile + getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize + getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize + + desktopFileBytes <- B.readFile desktopFile + B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes + B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes + + traceM " - file sent" + traceM " - post-remote checks" mobile ##> "/stop remote ctrl" mobile <## "ok" concurrently_ (mobile <## "remote controller stopped") (desktop <## "remote host 1 stopped") + mobile ##> "/contacts" mobile <## "bob (Bob)" From c2a858b06eef39a8d235e072929535f4e8462055 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:11:01 +0100 Subject: [PATCH 011/174] core: convert single-field to tagged JSON encoding (#3183) * core: convert single-field to tagged JSON encoding * rename * rename * fixes, test * refactor --- cabal.project | 2 +- package.yaml | 2 ++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 5 ++- src/Simplex/Chat/Remote.hs | 39 +++++++++++++++++++++-- stack.yaml | 2 +- tests/JSONTests.hs | 65 ++++++++++++++++++++++++++++++++++++++ tests/RemoteTests.hs | 1 - tests/Test.hs | 2 ++ 9 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 tests/JSONTests.hs diff --git a/cabal.project b/cabal.project index 3da442ac49..1364d77bf2 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 919550948501d315aa8845cbed1781d4298d4ced + tag: 6b0da8ac50b1582c9f5187c316b93fc8f12c9365 source-repository-package type: git diff --git a/package.yaml b/package.yaml index f7fc614789..861d0c494a 100644 --- a/package.yaml +++ b/package.yaml @@ -120,9 +120,11 @@ tests: - apps/simplex-directory-service/src main: Test.hs dependencies: + - QuickCheck == 2.14.* - simplex-chat - async == 2.2.* - deepseq == 1.4.* + - generic-random == 1.5.* - hspec == 2.11.* - network == 3.1.* - silently == 1.2.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0a199779d0..e1880738d7 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."919550948501d315aa8845cbed1781d4298d4ced" = "05d0cadhlazqi2lxcb7nvyjrf8q49c6ax7b8rahawbh1zmwg38nm"; + "https://github.com/simplex-chat/simplexmq.git"."6b0da8ac50b1582c9f5187c316b93fc8f12c9365" = "18n0b1l1adraw5rq118a6iz9pqg43yf41vrzm193q1si06iwk24b"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 81a71a9109..4fc023bc3b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -488,6 +488,7 @@ test-suite simplex-chat-test ChatTests.Groups ChatTests.Profiles ChatTests.Utils + JSONTests MarkdownTests MobileTests ProtocolTests @@ -509,7 +510,8 @@ test-suite simplex-chat-test apps/simplex-directory-service/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* + QuickCheck ==2.14.* + , aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -528,6 +530,7 @@ test-suite simplex-chat-test , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* + , generic-random ==1.5.* , hspec ==2.11.* , http-types ==0.12.* , http2 ==4.1.* diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 0f03c1fdb5..b81ba33cda 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -16,7 +17,10 @@ import Control.Monad.IO.Class import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) +import Data.Aeson ((.=)) import qualified Data.Aeson as J +import qualified Data.Aeson.Key as JK +import qualified Data.Aeson.KeyMap as JM import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Binary.Builder as Binary import Data.ByteString (ByteString, hPut) @@ -47,6 +51,7 @@ import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONTag, pattern TaggedObjectJSONData) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) @@ -233,9 +238,37 @@ handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fi -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value -owsf2tagged = \case - J.Object todo'convert -> J.Object todo'convert - skip -> skip +owsf2tagged = fst . convert + where + convert val = case val of + J.Object o + | JM.size o == 2 -> + case JM.toList o of + [OwsfTag, o'] -> tagged o' + [o', OwsfTag] -> tagged o' + _ -> props + | otherwise -> props + where + props = (J.Object $ fmap owsf2tagged o, False) + J.Array a -> (J.Array $ fmap owsf2tagged a, False) + _ -> (val, False) + -- `tagged` converts the pair of single-field object encoding to tagged encoding. + -- It sets innerTag returned by `convert` to True to prevent the tag being overwritten. + tagged (k, v) = (J.Object pairs, True) + where + (v', innerTag) = convert v + pairs = case v' of + -- `innerTag` indicates that internal object already has tag, + -- so the current tag cannot be inserted into it. + J.Object o + | innerTag -> pair + | otherwise -> JM.insert TaggedObjectJSONTag tag o + _ -> pair + tag = J.String $ JK.toText k + pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v'] + +pattern OwsfTag :: (JK.Key, J.Value) +pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) storeRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> FilePath -> m (Maybe FilePath) storeRemoteFile http localFile = do diff --git a/stack.yaml b/stack.yaml index 5d9fc214fb..e467b040e9 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 919550948501d315aa8845cbed1781d4298d4ced + commit: 6b0da8ac50b1582c9f5187c316b93fc8f12c9365 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs new file mode 100644 index 0000000000..11567d94af --- /dev/null +++ b/tests/JSONTests.hs @@ -0,0 +1,65 @@ +{-# LANGUAGE DeriveGeneric #-} + +module JSONTests where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import qualified Data.Aeson.Types as JT +import qualified Data.ByteString.Lazy.Char8 as LB +import GHC.Generics (Generic) +import Generic.Random (genericArbitraryU) +import MobileTests +import Simplex.Chat.Remote (owsf2tagged) +import Simplex.Messaging.Parsers +import Test.Hspec +import Test.Hspec.QuickCheck (modifyMaxSuccess) +import Test.QuickCheck (Arbitrary (..), property) + +jsonTests :: Spec +jsonTests = describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests + +owsf2TaggedJSONTest :: IO () +owsf2TaggedJSONTest = do + noActiveUserSwift `to` noActiveUserTagged + activeUserExistsSwift `to` activeUserExistsTagged + activeUserSwift `to` activeUserTagged + chatStartedSwift `to` chatStartedTagged + contactSubSummarySwift `to` contactSubSummaryTagged + memberSubSummarySwift `to` memberSubSummaryTagged + userContactSubSummarySwift `to` userContactSubSummaryTagged + pendingSubSummarySwift `to` pendingSubSummaryTagged + parsedMarkdownSwift `to` parsedMarkdownTagged + where + to :: LB.ByteString -> LB.ByteString -> IO () + owsf `to` tagged = + case J.eitherDecode owsf of + Right json -> Right (owsf2tagged json) `shouldBe` J.eitherDecode tagged + Left e -> expectationFailure e + +data SomeType + = Nullary + | Unary (Maybe SomeType) + | Product String (Maybe SomeType) + | Record + { testOne :: Int, + testTwo :: Maybe Bool, + testThree :: Maybe SomeType + } + | List [Int] + deriving (Eq, Show, Generic) + +instance Arbitrary SomeType where arbitrary = genericArbitraryU + +instance ToJSON SomeType where + toJSON = J.genericToJSON $ singleFieldJSON_ (Just SingleFieldJSONTag) id + toEncoding = J.genericToEncoding $ singleFieldJSON_ (Just SingleFieldJSONTag) id + +instance FromJSON SomeType where + parseJSON = J.genericParseJSON $ taggedObjectJSON id + +owsf2TaggedSomeTypeTests :: Spec +owsf2TaggedSomeTypeTests = + modifyMaxSuccess (const 10000) $ it "should convert to tagged" $ property $ \x -> + (JT.parseMaybe J.parseJSON . owsf2tagged . J.toJSON) x == Just (x :: SomeType) diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 34e2b04a6f..84f361a4a4 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -2,7 +2,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} module RemoteTests where diff --git a/tests/Test.hs b/tests/Test.hs index 1e2cad0376..071ff3791e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -5,6 +5,7 @@ import ChatTests import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System +import JSONTests import MarkdownTests import MobileTests import ProtocolTests @@ -22,6 +23,7 @@ main = do withGlobalLogging logCfg . hspec $ do describe "Schema dump" schemaDumpTest describe "SimpleX chat markdown" markdownTests + describe "JSON Tests" jsonTests describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "WebRTC encryption" webRTCTests From adc1f8c983878bbabb0bc912404884464f2ddeab Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:58:59 +0300 Subject: [PATCH 012/174] android, desktop: remote kotlin types (#3200) * Add remote types to Kotlin * update response info for chat console --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/SimpleXAPI.kt | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) 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 3644268ba3..50c52b6317 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 @@ -1915,6 +1915,18 @@ sealed class CC { class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() + class CreateRemoteHost(): CC() + class ListRemoteHosts(): CC() + class StartRemoteHost(val remoteHostId: Long): CC() + class StopRemoteHost(val remoteHostId: Long): CC() + class DeleteRemoteHost(val remoteHostId: Long): CC() + class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() + class StartRemoteCtrl(): CC() + class ListRemoteCtrls(): CC() + class AcceptRemoteCtrl(val remoteCtrlId: Long): CC() + class RejectRemoteCtrl(val remoteCtrlId: Long): CC() + class StopRemoteCtrl(): CC() + class DeleteRemoteCtrl(val remoteCtrlId: Long): CC() class ShowVersion(): CC() val cmdString: String get() = when (this) { @@ -2022,6 +2034,18 @@ sealed class CC { is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" + is CreateRemoteHost -> "/create remote host" + is ListRemoteHosts -> "/list remote hosts" + is StartRemoteHost -> "/start remote host $remoteHostId" + is StopRemoteHost -> "/stop remote host $remoteHostId" + is DeleteRemoteHost -> "/delete remote host $remoteHostId" + is StartRemoteCtrl -> "/start remote ctrl" + is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.caFingerprint}" + is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId" + is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId" + is ListRemoteCtrls -> "/list remote ctrls" + is StopRemoteCtrl -> "/stop remote ctrl" + is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId" is ShowVersion -> "/version" } @@ -2118,6 +2142,18 @@ sealed class CC { is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" + is CreateRemoteHost -> "createRemoteHost" + is ListRemoteHosts -> "listRemoteHosts" + is StartRemoteHost -> "startRemoteHost" + is StopRemoteHost -> "stopRemoteHost" + is DeleteRemoteHost -> "deleteRemoteHost" + is RegisterRemoteCtrl -> "registerRemoteCtrl" + is StartRemoteCtrl -> "startRemoteCtrl" + is ListRemoteCtrls -> "listRemoteCtrls" + is AcceptRemoteCtrl -> "acceptRemoteCtrl" + is RejectRemoteCtrl -> "rejectRemoteCtrl" + is StopRemoteCtrl -> "stopRemoteCtrl" + is DeleteRemoteCtrl -> "deleteRemoteCtrl" is ShowVersion -> "showVersion" } @@ -3180,6 +3216,34 @@ enum class GroupFeatureEnabled { } +@Serializable +data class RemoteCtrl ( + val remoteCtrlId: Long, + val displayName: String, + val fingerprint: String, + val accepted: Boolean? +) + +@Serializable +data class RemoteCtrlOOB ( + val caFingerprint: String +) + +@Serializable +data class RemoteCtrlInfo ( + val remoteCtrlId: Long, + val displayName: String, + val sessionActive: Boolean +) + +@Serializable +data class RemoteHostInfo ( + val remoteHostId: Long, + val storePath: String, + val displayName: String, + val sessionActive: Boolean +) + val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -3401,6 +3465,26 @@ 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() + // remote events (desktop) + @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() + @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() + @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostDeleted") class RemoteHostDeleted(val remoteHostId: Long): CR() + // remote events (mobile) + @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() + @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlStarted") class RemoteCtrlStarted(): CR() + @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() + @Serializable @SerialName("remoteCtrlAccepted") class RemoteCtrlAccepted(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlRejected") class RemoteCtrlRejected(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() + @Serializable @SerialName("remoteCtrlDeleted") class RemoteCtrlDeleted(val remoteCtrlId: Long): CR() + // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -3529,6 +3613,23 @@ sealed class CR { is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" is ArchiveImported -> "archiveImported" + is RemoteHostCreated -> "remoteHostCreated" + is RemoteHostList -> "remoteHostList" + is RemoteHostStarted -> "remoteHostStarted" + is RemoteHostConnected -> "remoteHostConnected" + is RemoteHostStopped -> "remoteHostStopped" + is RemoteHostDeleted -> "remoteHostDeleted" + is RemoteCtrlList -> "remoteCtrlList" + is RemoteCtrlRegistered -> "remoteCtrlRegistered" + is RemoteCtrlStarted -> "remoteCtrlStarted" + is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" + is RemoteCtrlFound -> "remoteCtrlFound" + is RemoteCtrlAccepted -> "remoteCtrlAccepted" + is RemoteCtrlRejected -> "remoteCtrlRejected" + is RemoteCtrlConnecting -> "remoteCtrlConnecting" + is RemoteCtrlConnected -> "remoteCtrlConnected" + is RemoteCtrlStopped -> "remoteCtrlStopped" + is RemoteCtrlDeleted -> "remoteCtrlDeleted" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -3660,6 +3761,23 @@ sealed class CR { is ChatCmdError -> withUser(user_, chatError.string) is ChatRespError -> withUser(user_, chatError.string) is ArchiveImported -> "${archiveErrors.map { it.string } }" + is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" + is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" + is RemoteHostStarted -> "remote host $remoteHostId" + is RemoteHostConnected -> "remote host ID: $remoteHostId" + is RemoteHostStopped -> "remote host ID: $remoteHostId" + is RemoteHostDeleted -> "remote host ID: $remoteHostId" + is RemoteCtrlList -> json.encodeToString(remoteCtrls) + is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlStarted -> "" + is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" + is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" + is RemoteCtrlAccepted -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlRejected -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlStopped -> "" + is RemoteCtrlDeleted -> "remote ctrl ID: $remoteCtrlId" is Response -> json is Invalid -> str } @@ -3805,12 +3923,16 @@ sealed class ChatError { is ChatErrorAgent -> "agent ${agentError.string}" is ChatErrorStore -> "store ${storeError.string}" is ChatErrorDatabase -> "database ${databaseError.string}" + is ChatErrorRemoteCtrl -> "remoteCtrl ${remoteCtrlError.string}" + is ChatErrorRemoteHost -> "remoteHost ${remoteHostError.string}" is ChatErrorInvalidJSON -> "invalid json ${json}" } @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError() @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError() @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError() @Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError() + @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() + @Serializable @SerialName("errorRemoteHost") class ChatErrorRemoteHost(val remoteHostError: RemoteHostError): ChatError() @Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError() } @@ -4310,6 +4432,47 @@ sealed class ArchiveError { @Serializable @SerialName("importFile") class ArchiveErrorImportFile(val file: String, val chatError: ChatError): ArchiveError() } +@Serializable +sealed class RemoteHostError { + val string: String get() = when (this) { + is Missing -> "missing" + is Busy -> "busy" + is Rejected -> "rejected" + is Timeout -> "timeout" + is Disconnected -> "disconnected" + is ConnectionLost -> "connectionLost" + } + @Serializable @SerialName("missing") object Missing: RemoteHostError() + @Serializable @SerialName("busy") object Busy: RemoteHostError() + @Serializable @SerialName("rejected") object Rejected: RemoteHostError() + @Serializable @SerialName("timeout") object Timeout: RemoteHostError() + @Serializable @SerialName("disconnected") class Disconnected(val reason: String): RemoteHostError() + @Serializable @SerialName("connectionLost") class ConnectionLost(val reason: String): RemoteHostError() +} + +@Serializable +sealed class RemoteCtrlError { + val string: String get() = when (this) { + is Missing -> "missing" + is Inactive -> "inactive" + is Busy -> "busy" + is Timeout -> "timeout" + is Disconnected -> "disconnected" + is ConnectionLost -> "connectionLost" + is CertificateExpired -> "certificateExpired" + is CertificateUntrusted -> "certificateUntrusted" + is BadFingerprint -> "badFingerprint" + } + @Serializable @SerialName("missing") class Missing(val remoteCtrlId: Long): RemoteCtrlError() + @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() + @Serializable @SerialName("busy") object Busy: RemoteCtrlError() + @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() + @Serializable @SerialName("disconnected") class Disconnected(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() + @Serializable @SerialName("connectionLost") class ConnectionLost(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() + @Serializable @SerialName("certificateExpired") class CertificateExpired(val remoteCtrlId: Long): RemoteCtrlError() + @Serializable @SerialName("certificateUntrusted") class CertificateUntrusted(val remoteCtrlId: Long): RemoteCtrlError() + @Serializable @SerialName("badFingerprint") object BadFingerprint: RemoteCtrlError() +} enum class NotificationsMode() { OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; From fe6c65f75cdb8c9b5dd4e8c29983fc28431fd62b Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 12 Oct 2023 17:19:19 +0300 Subject: [PATCH 013/174] rfc: remote profile (#3051) * Add session UX for mobile and desktop * Resolve some feedback * Resolve more feedback Add QR note for desktops. Add TLS handshake notice. * Add details --- docs/rfcs/2023-09-12-remote-profile.md | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/rfcs/2023-09-12-remote-profile.md diff --git a/docs/rfcs/2023-09-12-remote-profile.md b/docs/rfcs/2023-09-12-remote-profile.md new file mode 100644 index 0000000000..9a41d55d5a --- /dev/null +++ b/docs/rfcs/2023-09-12-remote-profile.md @@ -0,0 +1,200 @@ +# Remote profile + +## Problem + +Users want their desktop client to be in sync with profiles at their main device (presumably a mobile phone). +Due to distributed nature of SimpleX chat and comprehensive encryption it is difficult to maintain up to date multi-way synchronized presentation between devices. + +## Solution + +A typical (and expected) solution for this is running a server on a master device which will handle all the communication. +Then, additional "thin" client(s) would be able to present an interface, delegating everything else to the main. + +Fortunately, we already have such a protocol in our clients. +CLI and GUI run a text+json RPC protocol to their chat core. +CLI has a WebSocket server for it that facilitates making custom clients and bots – it won't be usable here though. + +We can run this protocol over a secure channel designed specifically for this problem. + +Then we can tweak clients to use this protocol instead of regular "local" profiles. + +## Session lifecycle + +For the sake of grounding and familiarity the roles are: +* "Mobile": a master device which stores data and does the communication. +* "Desktop": UI client attached to the master. + +1. Discovery: a user wants to attach a desktop client to their mobile. +2. Handshake: desktop and mobile establish a secure duplex session. +3. Activity: desktop sends requests and receives events from mobile and updates its presentation. +4. Restart: desktop should be able to re-eastablish channel unattended in the case of network winking out for a while. +5. Disposal: mobile can terminate the link and permanently dispose the established session. + +[![](https://mermaid.ink/img/pako:eNq1Vs2O2jAQfpVRTq3EvgCqVtomrZZDKhWKtAcuxh7AxbFT27BCq5X2QdqX2yfpODHGQOitJ4jn88w3P98kLwU3Aotx4fDXDjXHSrK1Zc1CAzDujYVqDsxBhW7rTRuOW2a95LJl2kM1yYwwn1zZy9xeGouXiLpD1GYpFQ4DJhmgj9ATq-cnw0KHc208gtljID0i-xgq6Xg4OARzNb-7v68mY5iGXJ0HxzwqJelSa82qc0OoSUCVY5gI1F76A9gefhGBEJ-tYYIz8iQjNrKTe_JMkM5fGaMmf4J5dorUs2wVOyQv8H0KoS0BVAfSNV2fcabPDOF2XYZs1tJ5onRKZ5BOXQ7X6JFp4TZsi4ltnWe_PCbZBS0vi8M3TCnU6xu3LbrWaNdZTevhq7RULi8bhE0eN7qus-5wo1fSNsxLoyOkS5kov7_9LnsrkAcr9RqIBfgNgvO71Qqepd-AwL3kCE9PT4vi_e1P74Mp3__JSkvuHjjH1idUVt6a2e2pM5EUioQ7VmSGWgxwRuVwKOAUfyIfDFihQuqRjW1FcdbOgZiMZKtUHlN39OKPwIuJvB6BhwAI6X0wWh26OjZMalDGtB_PrwT68wksleFbFJ-W9t4bkg_uiWJ4cj4ECnVQknt3PoGdeoPTnifJ_JhSUl25YT5XXGc8jtUsTfgFIuNHsJ1-tqwNo-mBtXLUjZrCEdAPBNN1ITPHp4FNsYN8I7HcyrT4hyfiJFeSZ3259HaNuNm5vnTXnZtSEWhZ3to7_0nJSUTJS6-f_jAflhH0C9ftSPEsjlmXYtJF8tFL4paPsCSNI4Gjc7FeXZluLn5CMxXZntpyJ7X0kp7E2SuBFFmhZ3xz2qJRm2kyf6BtpA4FziikOlV4FOyZXEOa8GicP4-bb4IbcbN9MOg5le02r-6to_dMSZGZoZNoNmZZ9SajvhENPSZ8_746bvBv5mhwNBNhTdD6vWpaUEJ45atAXGPUeT7QZWJQjIqGcmBS0HfISzheFLSCGlwUY_oraAEvioV-JRzbeTM7aF6MV4wqOyp2bUgtfrRcnH4Rkr4T4uHrX2MK93E?type=png)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNq1Vs2O2jAQfpVRTq3EvgCqVtomrZZDKhWKtAcuxh7AxbFT27BCq5X2QdqX2yfpODHGQOitJ4jn88w3P98kLwU3Aotx4fDXDjXHSrK1Zc1CAzDujYVqDsxBhW7rTRuOW2a95LJl2kM1yYwwn1zZy9xeGouXiLpD1GYpFQ4DJhmgj9ATq-cnw0KHc208gtljID0i-xgq6Xg4OARzNb-7v68mY5iGXJ0HxzwqJelSa82qc0OoSUCVY5gI1F76A9gefhGBEJ-tYYIz8iQjNrKTe_JMkM5fGaMmf4J5dorUs2wVOyQv8H0KoS0BVAfSNV2fcabPDOF2XYZs1tJ5onRKZ5BOXQ7X6JFp4TZsi4ltnWe_PCbZBS0vi8M3TCnU6xu3LbrWaNdZTevhq7RULi8bhE0eN7qus-5wo1fSNsxLoyOkS5kov7_9LnsrkAcr9RqIBfgNgvO71Qqepd-AwL3kCE9PT4vi_e1P74Mp3__JSkvuHjjH1idUVt6a2e2pM5EUioQ7VmSGWgxwRuVwKOAUfyIfDFihQuqRjW1FcdbOgZiMZKtUHlN39OKPwIuJvB6BhwAI6X0wWh26OjZMalDGtB_PrwT68wksleFbFJ-W9t4bkg_uiWJ4cj4ECnVQknt3PoGdeoPTnifJ_JhSUl25YT5XXGc8jtUsTfgFIuNHsJ1-tqwNo-mBtXLUjZrCEdAPBNN1ITPHp4FNsYN8I7HcyrT4hyfiJFeSZ3259HaNuNm5vnTXnZtSEWhZ3to7_0nJSUTJS6-f_jAflhH0C9ftSPEsjlmXYtJF8tFL4paPsCSNI4Gjc7FeXZluLn5CMxXZntpyJ7X0kp7E2SuBFFmhZ3xz2qJRm2kyf6BtpA4FziikOlV4FOyZXEOa8GicP4-bb4IbcbN9MOg5le02r-6to_dMSZGZoZNoNmZZ9SajvhENPSZ8_746bvBv5mhwNBNhTdD6vWpaUEJ45atAXGPUeT7QZWJQjIqGcmBS0HfISzheFLSCGlwUY_oraAEvioV-JRzbeTM7aF6MV4wqOyp2bUgtfrRcnH4Rkr4T4uHrX2MK93E) + +### Discovery + +The expected flow is desktop initiates the discovery by generating OOB key data and shows a QR code for mobile to scan. +The mobile then scans that QR code, decodes the "attachment request" and spins up a network server. + +There is a problem here, that the desktop doesn't know where its mobile actually located. + +This can be solved in a few different ways: + +1. The desktop starts a server and encodes its local IP in the QR. Mobile then connects to it. +2. The desktop encodes its local IP, but mobile only does a minimal client legwork, only to signal its actual location. Then the sides flip. + * The legwork may entail sending UDP datagram to desktop IP with an IP of its own. + * Another option is to use a TCP "nanoprotocol" of sending a `host:port` line. +3. The mobile may start announcing itself with UDP broadcasts for the duration of the phase (bluetooth-style) using information in the QR code. +4. A desktop may create a temporary SMP queue and show its address. The mobile then submits its server data to it. + +Another option is to run the server on desktop and have mobile discover it with the help of QR code to get server identity and keys and then on the network via some protocol. Using a fixed address is suboptimal as most networks have dynamic IPs. + +### Handshake + +The aim of this phase is to establish a TLS+cryptobox session. + +TLS could be complex as we need to generate self-signed certificates on desktop (if it acts like a server). A plaintext ws connection with cryptobox encryption could be sufficient initially? + +TBD + +### Activity + +The desktop starts its chat core with a special parameter to signal that it should be using the session instead of its regular "local" database. This can be determined per user profile. + +Other than that, the client behaves like it would do with a local chat state. +Its chat core being handed a socket uses it to relay the chat protocol data. + +The mobile, starts replaying the commands it had received on its state, maintaining a single point of truth. +When a mobile receives events or replies, it mirrors them to the attached session. + +Only a subset of the chat API should be available this way. +Requests like `/_stop` or `/_db delete` should be filtered out and ignored. + +Some of the relayed commands (e.g. `/_read chat` or `/_reaction`) the mobile should apply to its own state too. + +A simpler solution could be that while desktop client is connected mobile UI is locked. When the session terminates, mobile UI gets unlocked and refreshed. + +> A tweak in protocol that would reply with an event like "accepted read of X up to Y" may remove the need for such matching and interpretation. + +### Restart + +It would be annoying to users if walking to another room and loosing WiFi connection for a few seconds would result in another QR dance. + +Therefore, the non-ephemeral part of handshake material should be reused for reconnects. + +TBD + +### Disposal + +The session may have a lifetime that a desktop or a mobile may stipulate while preparing a session. +Alternatively a mobile (or a desktop, why not) may signal that they're done here and no further activity should be going with the session parameters. + +## Proposed UX flow + +> For now, desktop and mobile roles are mutually exclusive. +> Mobile device can only host remote session, while desktop devices can only remote-control. + +### On a mobile device + +1. A user opens sidebar and clicks "use from desktop" in the "You" section, starting remote controller discovery. + * When this happens for the first time, the user must set the mobile device name, pre-filled from system device name if possible. + * UI enters "Waiting for desktop" window, which collects all the broadcasts received so far. + - + * Discovery process starts UDP broadcast listener on application port (5226). + * A datagram containing remote controller fingerprint is checked against a list of pre-registered controller devices. + - If the datagram contains no valid fingerprint, it is ignored. + - For unknown/new broadcasts a fingerprint is displayed instead. + - If the device is already known, the host establishes connection and UI transitions into "connection status" window. +2. Clicking on unknown device fingerprint in the list starts OOB handshake. + * UI enters "New device" window, displaying a fingerprint and asking to scan a QR code (or paste a link, like in the contact screen). + * A OOB data from the QR/link contains remote controller fingerprint and remote display name, which is stored in device DB. + - The OOB fingerprint must match the announce. + * Accepting the OOB automatically triggers remote controller connection and transitions UI to "connection status" window. +3. A remote session initiated with a known device, or as a result of OOB exchange. + * A "connection status" window shows registered display name and current session status. + * Chat controller attempts to establish a remote session. + - The source adddress of the datagram is used to initiate TCP connection. + - A TCP connection is made to the address discovered. + - A TLS connection is instantiated, checking for remote CA fingerprint matches the previously established. + - A HTTP2 server is started on the mobile side of the TLS connection. + - The remote controller connects and subscribes for its output queue, marking the session established. + * For the duration of the remote session, the UI remains in the status window, preventing user interaction. + - This restriction may be lifted later. + +At any time a user may click on a "cancel" button and return to the main UI. +That should fully re-initialise UI state. + +In the "Network & servers" section of "Settings", there is an item to list all the registered remote controllers with buttons attached to *dispose* them one by one. +*Disposing* a remote controller means its entry will be removed from database. +Future connection attempts from a disposed device would be treated exactly as from a previously-unknown device. + +### On a desktop device + +1. A user opens sidebar and clicks "connect to mobile" in the "You" section. + * UI enters a "Select remote host" window asking user to pick an existing connection profile or generate a new one. + * When a new connection profile is requested by a user, a private key is generated and a new X509 root CA certificate is produced and stored in device DB. Then the desktop proceeds to the connection screen. +2. Clicking on an existing connection profile transitions UI to "connecting to remote host" window. + * For a first-time connection a QR code / link is presented, containing the fingerprint of the CA stored for the selected profile. + - After first time the QR code is hidden until a subdued "show QR code" button is clicked. This is to prevent user confusion that they have to scan the code every time. + * A new session certificate is derived from the CA. + * A TLS server is started using ephemeral session certificate. + - TLS handshake is used to authenticate desktop to a connecting mobile, proving that the announcer is indeed owns the key with the fingerprint received OOB by mobile. See below for a case for mutual authentication. + * A periodic UDP broadcast on port 5226 is started, sending the fingerprint. +3. When an incoming connection is established the UI transitions to "connected to remote host" window. + * The announcer is terminated and TCP server stops accepting new connections. + * Desktop chat controller establishes a remote session over the tls session. + * UI transitions to the "remote host" mode, shunting local profiles into background while keeping notifications coming. +4. A user may open sidebar and click "disconnect from mobile" to close the session and return to local mode. + * That should fully re-initialise UI state. + +In the "Network & servers" section of "Settings", there is an item to list all the registered remote hosts with buttons attached to *dispose* them one by one. +*Disposing* a remote host means its entry will be removed from database and any associated files deleted (photos, voice messages, transferred files etc). + +## Caveats + +A public WiFi spot (or a specially configured home AP) may prohibit clients to connect with each other, denying them link-local connection. +n such an event, an alternative transports may be considered: +- Bluetooth link. +- USB tethering that presents an ethernet device. +- The usual NAT traversal techniques. +- Running localnet-providing VPNs. +- Routing chat traffic via SMP queues. + +Application chat traffic may end up too chatty for the link. +This may result in large power drain for both sides or unpleasant latency. +Compression protocols may be used to mitigate this. +Since we know that chat API is text+JSON Zstd compression with pre-shared dictionary may provide huge traffic savings with minimal CPU effort. + +There is a threat, that a device in the broadcast range may intercept discovery datagrams and eagerly connect to their sources. +In the case of such a "honeypot", a desktop may be tricked into receiving arbitrary contacts, messages and files from the remote host. +Some mitigations are possible for authenticating a remote host (like using OOB token as a cookie or exchanging it for a TLS client certificate). +This is intentionally left out of scope for now, until the "remote profiles" system is audited, to be resolved in a wider context. + +Requesting a list of IP addresses is problematic. +A shady app permission is required from the OS (ACCESS_NETWORK_STATE on Android). +And then the app needs to sort through all the found interfaces and guess which one would be accessible. + +## "Should-works" + +File transfer appears to be running within the chat protocol. + +UI assumes that files are available in a local storage, the access to files is not part of chat RPC. This complicates things a lot. + +Attaching multiple sessions appears to be realistic without extensive modifications. + +A headless client with a global address (e.g. VPN or TOR) may be used in a manner of IRC bouncers. + +This may also allow "thin" mobile clients (cf. traffic concerns) and browser apps. + +A backup system may be implemented by attaching a headless app to a bouncer as one of the sessions. + +The unauthenticated remote host can be considered a feature. +A use case for that may be something like a "dead drop" host that wakes up in response to any discovery broadcast. + +## Unresolved questions + +- What to do with WebRTC/calls? +- Do we want attaching only to a subset of profiles? +- Do we want a client to mix remote and local profiles? +- Do we want M-to-N sessions? (follows naturally from the previous two) From 392447ea331988f40725580c44b646384d506486 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 17:52:27 +0100 Subject: [PATCH 014/174] core: fix test --- src/Simplex/Chat/Remote.hs | 2 ++ tests/JSONTests.hs | 2 +- tests/RemoteTests.hs | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 96d15ee8ae..49357763d2 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -8,6 +8,8 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + module Simplex.Chat.Remote where import Control.Logger.Simple diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 11567d94af..a250cdfcf6 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -26,7 +26,7 @@ owsf2TaggedJSONTest = do activeUserExistsSwift `to` activeUserExistsTagged activeUserSwift `to` activeUserTagged chatStartedSwift `to` chatStartedTagged - contactSubSummarySwift `to` contactSubSummaryTagged + networkStatusesSwift `to` networkStatusesTagged memberSubSummarySwift `to` memberSubSummaryTagged userContactSubSummarySwift `to` userContactSubSummaryTagged pendingSubSummarySwift `to` pendingSubSummaryTagged diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 84f361a4a4..d2392adbbd 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -30,7 +30,6 @@ import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import System.FilePath (makeRelative, ()) import Test.Hspec import UnliftIO -import UnliftIO.Concurrent (threadDelay) import UnliftIO.Directory remoteTests :: SpecWith FilePath From 193361c09a236bc4a36ed275010f140d930d2bf5 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 13 Oct 2023 20:53:04 +0300 Subject: [PATCH 015/174] core: fix remote handshake test (#3209) * Fix remoteHandshakeTest Sidesteps some yet to be uncovered bug when mobile stops its side before the desktop. * remove ambiguous update warning --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat/Remote.hs | 22 ++++++++++++++++------ tests/RemoteTests.hs | 27 +++++++++++++++++++-------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 49357763d2..26d4f4bfd2 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -53,7 +54,7 @@ import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONTag, pattern TaggedObjectJSONData) +import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) @@ -64,7 +65,7 @@ import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow, ifM, tshow) import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO -import UnliftIO.Directory (createDirectoryIfMissing, getFileSize, makeAbsolute) +import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do @@ -90,7 +91,9 @@ startRemoteHost remoteHostId = do cleanup finished = do logInfo "Remote host http2 client fininshed" atomically $ writeTVar finished True - closeRemoteHostSession remoteHostId >>= toView + M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case + Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId + Just _ -> closeRemoteHostSession remoteHostId >>= toView run RemoteHost {storePath, caKey, caCert} = do finished <- newTVarIO False let parent = (C.signatureKeyPair caKey, caCert) @@ -141,6 +144,7 @@ pollRemote finished http path action = loop closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do + logInfo $ "Closing remote host session for " <> tshow remoteHostId liftIO $ cancelRemoteHostSession session chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId @@ -174,8 +178,12 @@ listRemoteHosts = do pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse -deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do - -- TODO: delete files +deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do + chatReadVar filesFolder >>= \case + Just baseDir -> do + let hostStore = baseDir storePath + logError $ "TODO: remove " <> tshow hostStore + Nothing -> logWarn "Local file store not available while deleting remote host" withStore' $ \db -> deleteRemoteHostRecord db remoteHostId pure CRRemoteHostDeleted {remoteHostId} @@ -234,7 +242,9 @@ handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fi createDirectoryIfMissing True hostStore localPath <- uniqueCombine hostStore fileName ok <- fetchRemoteFile http remoteUser fileId localPath - pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) + if ok + then pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) + else Nothing <$ logError "fetchRemoteFile failed" Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" _ -> Nothing <$ logDebug ("Ingoring invalid file notification for file (" <> tshow fileId <> ") " <> tshow fileName) diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index d2392adbbd..479febbcaf 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -36,7 +36,7 @@ remoteTests :: SpecWith FilePath remoteTests = describe "Handshake" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - xit "connects desktop and mobile" remoteHandshakeTest + it "connects desktop and mobile" remoteHandshakeTest it "send messages via remote desktop" remoteCommandTest -- * Low-level TLS with ephemeral credentials @@ -129,6 +129,24 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" + + traceM " - Session active" + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. TODO (active)" + mobile ##> "/list remote ctrls" + mobile <## "Remote controllers:" + mobile <## "1. TODO (active)" + + traceM " - Shutting desktop" + desktop ##> "/stop remote host 1" + desktop <## "remote host 1 stopped" + desktop ##> "/delete remote host 1" + desktop <## "remote host 1 deleted" + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + + traceM " - Shutting mobile" mobile ##> "/stop remote ctrl" mobile <## "ok" mobile <## "remote controller stopped" @@ -137,13 +155,6 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile ##> "/list remote ctrls" mobile <## "No remote controllers" - desktop ##> "/stop remote host 1" - desktop <## "remote host 1 stopped" - desktop ##> "/delete remote host 1" - desktop <## "remote host 1 deleted" - desktop ##> "/list remote hosts" - desktop <## "No remote hosts" - remoteCommandTest :: (HasCallStack) => FilePath -> IO () remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do let mobileFiles = "./tests/tmp/mobile_files" From 5e6aaffb09b528f9d7af62c07a0f48cd8b3a9d73 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 22:35:30 +0100 Subject: [PATCH 016/174] simplify remote api, add ios api (#3213) --- apps/ios/Shared/Model/SimpleXAPI.swift | 32 +++++++ apps/ios/SimpleXChat/APITypes.swift | 72 ++++++++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 96 ++++++++----------- src/Simplex/Chat.hs | 24 ++--- src/Simplex/Chat/Controller.hs | 14 +-- src/Simplex/Chat/Remote.hs | 45 ++++----- src/Simplex/Chat/View.hs | 6 -- tests/RemoteTests.hs | 18 ++-- tests/Test.hs | 2 +- 9 files changed, 184 insertions(+), 125 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a1c8cee774..ad76364e96 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -882,6 +882,38 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? { } } +func startRemoteCtrl() async throws { + try await sendCommandOkResp(.startRemoteCtrl) +} + +func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> Int64 { + let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB)) + if case let .remoteCtrlRegistered(rcId) = r { return rcId } + throw r +} + +func listRemoteCtrls() async throws -> [RemoteCtrlInfo] { + let r = await chatSendCmd(.listRemoteCtrls) + if case let .remoteCtrlList(rcInfo) = r { return rcInfo } + throw r +} + +func acceptRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.acceptRemoteCtrl(remoteCtrlId: rcId)) +} + +func rejectRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.rejectRemoteCtrl(remoteCtrlId: rcId)) +} + +func stopRemoteCtrl() async throws { + try await sendCommandOkResp(.stopRemoteCtrl) +} + +func deleteRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId)) +} + func networkErrorAlert(_ r: ChatResponse) -> Alert? { switch r { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 9eb9b9084c..4b79800e1a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -117,6 +117,13 @@ public enum ChatCommand { case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) case setFileToReceive(fileId: Int64, encrypted: Bool) case cancelFile(fileId: Int64) + case startRemoteCtrl + case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB) + case listRemoteCtrls + case acceptRemoteCtrl(remoteCtrlId: Int64) + case rejectRemoteCtrl(remoteCtrlId: Int64) + case stopRemoteCtrl + case deleteRemoteCtrl(remoteCtrlId: Int64) case showVersion case string(String) @@ -255,6 +262,13 @@ public enum ChatCommand { return s case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" case let .cancelFile(fileId): return "/fcancel \(fileId)" + case .startRemoteCtrl: return "/start remote ctrl" + case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)" + case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)" + case let .rejectRemoteCtrl(rcId): return "/reject remote ctrl \(rcId)" + case .listRemoteCtrls: return "/list remote ctrls" + case .stopRemoteCtrl: return "/stop remote ctrl" + case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" case .showVersion: return "/version" case let .string(str): return str } @@ -367,6 +381,13 @@ public enum ChatCommand { case .receiveFile: return "receiveFile" case .setFileToReceive: return "setFileToReceive" case .cancelFile: return "cancelFile" + case .startRemoteCtrl: return "startRemoteCtrl" + case .registerRemoteCtrl: return "registerRemoteCtrl" + case .listRemoteCtrls: return "listRemoteCtrls" + case .acceptRemoteCtrl: return "acceptRemoteCtrl" + case .rejectRemoteCtrl: return "rejectRemoteCtrl" + case .stopRemoteCtrl: return "stopRemoteCtrl" + case .deleteRemoteCtrl: return "deleteRemoteCtrl" case .showVersion: return "showVersion" case .string: return "console command" } @@ -563,6 +584,13 @@ public enum ChatResponse: Decodable, Error { case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case newContactConnection(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) + case remoteCtrlRegistered(remoteCtrlId: Int64) + case remoteCtrlAnnounce(fingerprint: String) + case remoteCtrlFound(remoteCtrl: RemoteCtrl) + case remoteCtrlConnecting(remoteCtrlId: Int64, displayName: String) + case remoteCtrlConnected(remoteCtrlId: Int64, displayName: String) + case remoteCtrlStopped case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) case chatCmdError(user_: UserRef?, chatError: ChatError) @@ -699,6 +727,13 @@ public enum ChatResponse: Decodable, Error { case .ntfMessages: return "ntfMessages" case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" + case .remoteCtrlList: return "remoteCtrlList" + case .remoteCtrlRegistered: return "remoteCtrlRegistered" + case .remoteCtrlAnnounce: return "remoteCtrlAnnounce" + case .remoteCtrlFound: return "remoteCtrlFound" + case .remoteCtrlConnecting: return "remoteCtrlConnecting" + case .remoteCtrlConnected: return "remoteCtrlConnected" + case .remoteCtrlStopped: return "remoteCtrlStopped" case .versionInfo: return "versionInfo" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" @@ -838,6 +873,13 @@ public enum ChatResponse: Decodable, Error { case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) + case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) + case let .remoteCtrlRegistered(rcId): return "remote ctrl ID: \(rcId)" + case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)" + case let .remoteCtrlFound(remoteCtrl): return "remote ctrl: \(String(describing: remoteCtrl))" + case let .remoteCtrlConnecting(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" + case let .remoteCtrlConnected(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" + case .remoteCtrlStopped: return noDetails 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)) @@ -1461,6 +1503,23 @@ public enum NotificationPreviewMode: String, SelectableItem { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } +public struct RemoteCtrlOOB { + public var caFingerprint: String +} + +public struct RemoteCtrlInfo: Decodable { + public var remoteCtrlId: Int64 + public var displayName: String + public var sessionActive: Bool +} + +public struct RemoteCtrl: Decodable { + var remoteCtrlId: Int64 + var displayName: String + var fingerprint: String + var accepted: Bool? +} + public struct CoreVersionInfo: Decodable { public var version: String public var simplexmqVersion: String @@ -1488,6 +1547,7 @@ public enum ChatError: Decodable { case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) case errorDatabase(databaseError: DatabaseError) + case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) case invalidJSON(json: String) } @@ -1739,3 +1799,15 @@ public enum ArchiveError: Decodable { case `import`(chatError: ChatError) case importFile(file: String, chatError: ChatError) } + +public enum RemoteCtrlError: Decodable { + case missing(remoteCtrlId: Int64) + case inactive + case busy + case timeout + case disconnected(remoteCtrlId: Int64, reason: String) + case connectionLost(remoteCtrlId: Int64, reason: String) + case certificateExpired(remoteCtrlId: Int64) + case certificateUntrusted(remoteCtrlId: Int64) + case badFingerprint +} 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 9d726c620d..9ab6060fe9 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 @@ -1938,8 +1938,8 @@ sealed class CC { class StartRemoteHost(val remoteHostId: Long): CC() class StopRemoteHost(val remoteHostId: Long): CC() class DeleteRemoteHost(val remoteHostId: Long): CC() - class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() class StartRemoteCtrl(): CC() + class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() class ListRemoteCtrls(): CC() class AcceptRemoteCtrl(val remoteCtrlId: Long): CC() class RejectRemoteCtrl(val remoteCtrlId: Long): CC() @@ -2167,8 +2167,8 @@ sealed class CC { is StartRemoteHost -> "startRemoteHost" is StopRemoteHost -> "stopRemoteHost" is DeleteRemoteHost -> "deleteRemoteHost" - is RegisterRemoteCtrl -> "registerRemoteCtrl" is StartRemoteCtrl -> "startRemoteCtrl" + is RegisterRemoteCtrl -> "registerRemoteCtrl" is ListRemoteCtrls -> "listRemoteCtrls" is AcceptRemoteCtrl -> "acceptRemoteCtrl" is RejectRemoteCtrl -> "rejectRemoteCtrl" @@ -3483,30 +3483,24 @@ sealed class CR { @Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() + // remote events (desktop) + @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() + @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() + @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() + // remote events (mobile) + @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() + @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() @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() @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() - // remote events (desktop) - @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() - @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() - @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHostId: Long): CR() - @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() - @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() - @Serializable @SerialName("remoteHostDeleted") class RemoteHostDeleted(val remoteHostId: Long): CR() - // remote events (mobile) - @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() - @Serializable @SerialName("remoteCtrlStarted") class RemoteCtrlStarted(): CR() - @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() - @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() - @Serializable @SerialName("remoteCtrlAccepted") class RemoteCtrlAccepted(val remoteCtrlId: Long): CR() - @Serializable @SerialName("remoteCtrlRejected") class RemoteCtrlRejected(val remoteCtrlId: Long): CR() - @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() - @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() - @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() - @Serializable @SerialName("remoteCtrlDeleted") class RemoteCtrlDeleted(val remoteCtrlId: Long): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -3632,28 +3626,22 @@ sealed class CR { is CallEnded -> "callEnded" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" + is RemoteHostCreated -> "remoteHostCreated" + is RemoteHostList -> "remoteHostList" + is RemoteHostConnected -> "remoteHostConnected" + is RemoteHostStopped -> "remoteHostStopped" + is RemoteCtrlList -> "remoteCtrlList" + is RemoteCtrlRegistered -> "remoteCtrlRegistered" + is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" + is RemoteCtrlFound -> "remoteCtrlFound" + is RemoteCtrlConnecting -> "remoteCtrlConnecting" + is RemoteCtrlConnected -> "remoteCtrlConnected" + is RemoteCtrlStopped -> "remoteCtrlStopped" is VersionInfo -> "versionInfo" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" is ArchiveImported -> "archiveImported" - is RemoteHostCreated -> "remoteHostCreated" - is RemoteHostList -> "remoteHostList" - is RemoteHostStarted -> "remoteHostStarted" - is RemoteHostConnected -> "remoteHostConnected" - is RemoteHostStopped -> "remoteHostStopped" - is RemoteHostDeleted -> "remoteHostDeleted" - is RemoteCtrlList -> "remoteCtrlList" - is RemoteCtrlRegistered -> "remoteCtrlRegistered" - is RemoteCtrlStarted -> "remoteCtrlStarted" - is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" - is RemoteCtrlFound -> "remoteCtrlFound" - is RemoteCtrlAccepted -> "remoteCtrlAccepted" - is RemoteCtrlRejected -> "remoteCtrlRejected" - is RemoteCtrlConnecting -> "remoteCtrlConnecting" - is RemoteCtrlConnected -> "remoteCtrlConnected" - is RemoteCtrlStopped -> "remoteCtrlStopped" - is RemoteCtrlDeleted -> "remoteCtrlDeleted" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -3779,6 +3767,17 @@ sealed class CR { is CallEnded -> withUser(user, "contact: ${contact.id}") is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) + is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" + is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" + is RemoteHostConnected -> "remote host ID: $remoteHostId" + is RemoteHostStopped -> "remote host ID: $remoteHostId" + is RemoteCtrlList -> json.encodeToString(remoteCtrls) + is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" + is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" + is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlStopped -> "" 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 })}" @@ -3786,23 +3785,6 @@ sealed class CR { is ChatCmdError -> withUser(user_, chatError.string) is ChatRespError -> withUser(user_, chatError.string) is ArchiveImported -> "${archiveErrors.map { it.string } }" - is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" - is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" - is RemoteHostStarted -> "remote host $remoteHostId" - is RemoteHostConnected -> "remote host ID: $remoteHostId" - is RemoteHostStopped -> "remote host ID: $remoteHostId" - is RemoteHostDeleted -> "remote host ID: $remoteHostId" - is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" - is RemoteCtrlStarted -> "" - is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" - is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" - is RemoteCtrlAccepted -> "remote ctrl ID: $remoteCtrlId" - is RemoteCtrlRejected -> "remote ctrl ID: $remoteCtrlId" - is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlStopped -> "" - is RemoteCtrlDeleted -> "remote ctrl ID: $remoteCtrlId" is Response -> json is Invalid -> str } @@ -3948,16 +3930,16 @@ sealed class ChatError { is ChatErrorAgent -> "agent ${agentError.string}" is ChatErrorStore -> "store ${storeError.string}" is ChatErrorDatabase -> "database ${databaseError.string}" - is ChatErrorRemoteCtrl -> "remoteCtrl ${remoteCtrlError.string}" is ChatErrorRemoteHost -> "remoteHost ${remoteHostError.string}" + is ChatErrorRemoteCtrl -> "remoteCtrl ${remoteCtrlError.string}" is ChatErrorInvalidJSON -> "invalid json ${json}" } @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError() @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError() @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError() @Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError() - @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() @Serializable @SerialName("errorRemoteHost") class ChatErrorRemoteHost(val remoteHostError: RemoteHostError): ChatError() + @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() @Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError() } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3e91bd621c..454e87ef55 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1891,18 +1891,18 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - CreateRemoteHost -> createRemoteHost - ListRemoteHosts -> listRemoteHosts - StartRemoteHost rh -> startRemoteHost rh - StopRemoteHost rh -> closeRemoteHostSession rh - DeleteRemoteHost rh -> deleteRemoteHost rh - StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) - AcceptRemoteCtrl rc -> acceptRemoteCtrl rc - RejectRemoteCtrl rc -> rejectRemoteCtrl rc - StopRemoteCtrl -> stopRemoteCtrl - RegisterRemoteCtrl oob -> registerRemoteCtrl oob - ListRemoteCtrls -> listRemoteCtrls - DeleteRemoteCtrl rc -> deleteRemoteCtrl rc + CreateRemoteHost -> uncurry CRRemoteHostCreated <$> createRemoteHost + ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts + StartRemoteHost rh -> startRemoteHost rh >> ok_ + StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ + DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ + StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ + AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ + RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ + StopRemoteCtrl -> stopRemoteCtrl >> ok_ + RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob + ListRemoteCtrls -> CRRemoteCtrlList <$> listRemoteCtrls + DeleteRemoteCtrl rc -> deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e4085ca791..5448f49603 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -425,8 +425,8 @@ data ChatCommand -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data - | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers + | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | ListRemoteCtrls | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data @@ -631,21 +631,15 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteCtrlOOB} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup - | CRRemoteHostStarted {remoteHostId :: RemoteHostId} | CRRemoteHostConnected {remoteHostId :: RemoteHostId} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} - | CRRemoteHostDeleted {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlStarted | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect - | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlStopped - | CRRemoteCtrlDeleted {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -667,21 +661,15 @@ allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case CRRemoteHostCreated {} -> False CRRemoteHostList {} -> False - CRRemoteHostStarted {} -> False CRRemoteHostConnected {} -> False CRRemoteHostStopped {} -> False - CRRemoteHostDeleted {} -> False CRRemoteCtrlList {} -> False CRRemoteCtrlRegistered {} -> False - CRRemoteCtrlStarted {} -> False CRRemoteCtrlAnnounce {} -> False CRRemoteCtrlFound {} -> False - CRRemoteCtrlAccepted {} -> False - CRRemoteCtrlRejected {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlConnected {} -> False CRRemoteCtrlStopped {} -> False - CRRemoteCtrlDeleted {} -> False _ -> True logResponseToFile :: ChatResponse -> Bool diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 26d4f4bfd2..37283511fc 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -79,21 +79,20 @@ withRemoteHost remoteHostId action = Nothing -> throwError $ ChatErrorRemoteHost remoteHostId RHMissing Just rh -> action rh -startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +startRemoteHost :: (ChatMonad m) => RemoteHostId -> m () startRemoteHost remoteHostId = do asks remoteHostSessions >>= atomically . TM.lookup remoteHostId >>= \case Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy Nothing -> withRemoteHost remoteHostId $ \rh -> do announcer <- async $ run rh chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} - pure CRRemoteHostStarted {remoteHostId} where cleanup finished = do logInfo "Remote host http2 client fininshed" atomically $ writeTVar finished True M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId - Just _ -> closeRemoteHostSession remoteHostId >>= toView + Just _ -> closeRemoteHostSession remoteHostId >> toView (CRRemoteHostStopped remoteHostId) run RemoteHost {storePath, caKey, caCert} = do finished <- newTVarIO False let parent = (C.signatureKeyPair caKey, caCert) @@ -142,42 +141,41 @@ pollRemote finished http path action = loop readTVarIO finished >>= (`unless` loop) req = HTTP2Client.requestNoBody "GET" path mempty -closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse +closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do logInfo $ "Closing remote host session for " <> tshow remoteHostId liftIO $ cancelRemoteHostSession session chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId - pure CRRemoteHostStopped {remoteHostId} cancelRemoteHostSession :: (MonadUnliftIO m) => RemoteHostSession -> m () cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient -createRemoteHost :: (ChatMonad m) => m ChatResponse +createRemoteHost :: (ChatMonad m) => m (RemoteHostId, RemoteCtrlOOB) createRemoteHost = do let displayName = "TODO" -- you don't have remote host name here, it will be passed from remote host ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName storePath <- liftIO randomStorePath remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert let oobData = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert} - pure CRRemoteHostCreated {remoteHostId, oobData} + pure (remoteHostId, oobData) -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 -listRemoteHosts :: (ChatMonad m) => m ChatResponse +listRemoteHosts :: (ChatMonad m) => m [RemoteHostInfo] listRemoteHosts = do stored <- withStore' getRemoteHosts active <- chatReadVar remoteHostSessions - pure $ CRRemoteHostList $ do + pure $ do RemoteHost {remoteHostId, storePath, displayName} <- stored let sessionActive = M.member remoteHostId active pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} -deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do chatReadVar filesFolder >>= \case Just baseDir -> do @@ -185,7 +183,6 @@ deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {store logError $ "TODO: remove " <> tshow hostStore Nothing -> logWarn "Local file store not available while deleting remote host" withStore' $ \db -> deleteRemoteHostRecord db remoteHostId - pure CRRemoteHostDeleted {remoteHostId} processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" @@ -393,7 +390,7 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s -- * ChatRequest handlers -startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m ChatResponse +startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m () startRemoteCtrl execChatCommand = chatReadVar remoteCtrlSession >>= \case Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy @@ -416,7 +413,6 @@ startRemoteCtrl execChatCommand = chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} - pure CRRemoteCtrlStarted discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.withListener go @@ -445,33 +441,32 @@ discoverRemoteCtrls discovered = Discovery.withListener go Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock -registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m ChatResponse +registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m RemoteCtrlId registerRemoteCtrl RemoteCtrlOOB {caFingerprint} = do let displayName = "TODO" -- maybe include into OOB data remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint - pure $ CRRemoteCtrlRegistered {remoteCtrlId} + pure remoteCtrlId -listRemoteCtrls :: (ChatMonad m) => m ChatResponse +listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] listRemoteCtrls = do stored <- withStore' getRemoteCtrls active <- chatReadVar remoteCtrlSession >>= \case Nothing -> pure Nothing Just RemoteCtrlSession {accepted} -> atomically (tryReadTMVar accepted) - pure $ CRRemoteCtrlList $ do + pure $ do RemoteCtrl {remoteCtrlId, displayName} <- stored let sessionActive = active == Just remoteCtrlId pure RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} -acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () acceptRemoteCtrl remoteCtrlId = do withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId True chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive Just RemoteCtrlSession {accepted} -> atomically . void $ tryPutTMVar accepted remoteCtrlId -- the remote host can now proceed with connection - pure $ CRRemoteCtrlAccepted {remoteCtrlId} -rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () rejectRemoteCtrl remoteCtrlId = do withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId False chatReadVar remoteCtrlSession >>= \case @@ -479,9 +474,8 @@ rejectRemoteCtrl remoteCtrlId = do Just RemoteCtrlSession {discoverer, supervisor} -> do cancel discoverer cancel supervisor - pure $ CRRemoteCtrlRejected {remoteCtrlId} -stopRemoteCtrl :: (ChatMonad m) => m ChatResponse +stopRemoteCtrl :: (ChatMonad m) => m () stopRemoteCtrl = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive @@ -489,7 +483,6 @@ stopRemoteCtrl = cancelRemoteCtrlSession rcs $ do chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped - pure $ CRCmdOk Nothing cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () @@ -503,12 +496,10 @@ cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} c cancel supervisor -- supervisor is blocked until session progresses cleanup -deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () deleteRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case - Nothing -> do - withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId - pure $ CRRemoteCtrlDeleted {remoteCtrlId} + Nothing -> withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy withRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrl -> m a) -> m a diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1d474792a6..b5dce1ba84 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -264,21 +264,15 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRNtfMessages {} -> [] CRRemoteHostCreated rhId oobData -> ("remote host " <> sShow rhId <> " created") : viewRemoteCtrlOOBData oobData CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted rhId -> ["remote host " <> sShow rhId <> " started"] CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] - CRRemoteHostDeleted rhId -> ["remote host " <> sShow rhId <> " deleted"] CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlStarted -> ["remote controller started"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlAccepted rcId -> ["remote controller " <> sShow rcId <> " accepted"] - CRRemoteCtrlRejected rcId -> ["remote controller " <> sShow rcId <> " rejected"] CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] CRRemoteCtrlStopped -> ["remote controller stopped"] - CRRemoteCtrlDeleted rcId -> ["remote controller " <> sShow rcId <> " deleted"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 479febbcaf..68ef6788e9 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -110,10 +110,10 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop <## "Remote hosts:" desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <## "ok" mobile ##> "/start remote ctrl" - mobile <## "remote controller started" + mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" fingerprint' <- getTermLine mobile @@ -126,7 +126,7 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "Remote controllers:" mobile <## "1. TODO" mobile ##> "/accept remote ctrl 1" - mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "ok" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" @@ -140,9 +140,9 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do traceM " - Shutting desktop" desktop ##> "/stop remote host 1" - desktop <## "remote host 1 stopped" + desktop <## "ok" desktop ##> "/delete remote host 1" - desktop <## "remote host 1 deleted" + desktop <## "ok" desktop ##> "/list remote hosts" desktop <## "No remote hosts" @@ -151,7 +151,7 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "ok" mobile <## "remote controller stopped" mobile ##> "/delete remote ctrl 1" - mobile <## "remote controller 1 deleted" + mobile <## "ok" mobile ##> "/list remote ctrls" mobile <## "No remote controllers" @@ -173,10 +173,10 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob fingerprint <- getTermLine desktop desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <## "ok" mobile ##> "/start remote ctrl" - mobile <## "remote controller started" + mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" fingerprint' <- getTermLine mobile @@ -184,7 +184,7 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob mobile ##> ("/register remote ctrl " <> fingerprint') mobile <## "remote controller 1 registered" mobile ##> "/accept remote ctrl 1" - mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "ok" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" desktop <## "remote host 1 connected" diff --git a/tests/Test.hs b/tests/Test.hs index 071ff3791e..6af51a0724 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -33,7 +33,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests - describe "Remote session" remoteTests + fdescribe "Remote session" remoteTests where testBracket test = do t <- getSystemTime From f5e9bd4f8b60707f5f56df16c41a4782ab03d0f0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 14 Oct 2023 13:10:06 +0100 Subject: [PATCH 017/174] core: add set display name (#3216) * core: add set display name * enable all tests --- src/Simplex/Chat.hs | 12 +++++--- src/Simplex/Chat/Controller.hs | 12 +++++--- src/Simplex/Chat/Remote.hs | 50 ++++++++++++++++------------------ src/Simplex/Chat/View.hs | 16 +++++------ tests/RemoteTests.hs | 16 +++++------ tests/Test.hs | 2 +- 6 files changed, 57 insertions(+), 51 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 454e87ef55..726fbdce88 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -205,6 +205,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty + localDeviceName <- newTVarIO "" -- TODO set in config remoteHostSessions <- atomically TM.empty remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder @@ -236,6 +237,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles, rcvFiles, currentCalls, + localDeviceName, remoteHostSessions, remoteCtrlSession, config, @@ -1891,16 +1893,17 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - CreateRemoteHost -> uncurry CRRemoteHostCreated <$> createRemoteHost + SetLocalDeviceName name -> withUser $ \_ -> chatWriteVar localDeviceName name >> ok_ + CreateRemoteHost -> CRRemoteHostCreated <$> createRemoteHost ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts StartRemoteHost rh -> startRemoteHost rh >> ok_ StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ + RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ StopRemoteCtrl -> stopRemoteCtrl >> ok_ - RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob ListRemoteCtrls -> CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess @@ -5810,14 +5813,15 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, + "/set device name " *> (SetLocalDeviceName <$> textP), "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/start remote ctrl" $> StartRemoteCtrl, - -- TODO *** you need to pass multiple parameters here - "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP)), + "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)), + "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), "/list remote ctrls" $> ListRemoteCtrls, "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5448f49603..2a2b7cff98 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -179,6 +179,7 @@ data ChatController = ChatController sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, + localDeviceName :: TVar Text, remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers config :: ChatConfig, @@ -419,6 +420,7 @@ data ChatCommand | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) + | SetLocalDeviceName Text | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host @@ -629,9 +631,9 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} - | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteCtrlOOB} - | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup - | CRRemoteHostConnected {remoteHostId :: RemoteHostId} + | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} + | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} + | CRRemoteHostConnected {remoteHostId :: RemoteHostId} -- TODO add displayName | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} @@ -692,7 +694,8 @@ logResponseToFile = \case _ -> False data RemoteCtrlOOB = RemoteCtrlOOB - { caFingerprint :: C.KeyHash + { caFingerprint :: C.KeyHash, + displayName :: Text } deriving (Show, Generic, FromJSON) @@ -702,6 +705,7 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, storePath :: FilePath, displayName :: Text, + remoteCtrlOOB :: RemoteCtrlOOB, sessionActive :: Bool } deriving (Show, Generic, FromJSON) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 37283511fc..4d031634ff 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -62,7 +62,7 @@ import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow, ifM, tshow) +import Simplex.Messaging.Util (bshow, ifM, tshow, ($>>=)) import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) @@ -153,14 +153,15 @@ cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient -createRemoteHost :: (ChatMonad m) => m (RemoteHostId, RemoteCtrlOOB) +createRemoteHost :: (ChatMonad m) => m RemoteHostInfo createRemoteHost = do - let displayName = "TODO" -- you don't have remote host name here, it will be passed from remote host - ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName + let hostDisplayName = "TODO" -- you don't have remote host name here, it will be passed from remote host + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) hostDisplayName storePath <- liftIO randomStorePath - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert - let oobData = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert} - pure (remoteHostId, oobData) + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath hostDisplayName caKey caCert + displayName <- chatReadVar localDeviceName + let remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName} + pure RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive = False} -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -168,12 +169,14 @@ randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 listRemoteHosts :: (ChatMonad m) => m [RemoteHostInfo] listRemoteHosts = do - stored <- withStore' getRemoteHosts active <- chatReadVar remoteHostSessions - pure $ do - RemoteHost {remoteHostId, storePath, displayName} <- stored - let sessionActive = M.member remoteHostId active - pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} + rcName <- chatReadVar localDeviceName + map (rhInfo active rcName) <$> withStore' getRemoteHosts + where + rhInfo active rcName RemoteHost {remoteHostId, storePath, displayName, caCert} = + let sessionActive = M.member remoteHostId active + remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName = rcName} + in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do @@ -442,22 +445,20 @@ discoverRemoteCtrls discovered = Discovery.withListener go _nonV4 -> go sock registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m RemoteCtrlId -registerRemoteCtrl RemoteCtrlOOB {caFingerprint} = do - let displayName = "TODO" -- maybe include into OOB data +registerRemoteCtrl RemoteCtrlOOB {caFingerprint, displayName} = do remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint pure remoteCtrlId listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] listRemoteCtrls = do - stored <- withStore' getRemoteCtrls active <- - chatReadVar remoteCtrlSession >>= \case - Nothing -> pure Nothing - Just RemoteCtrlSession {accepted} -> atomically (tryReadTMVar accepted) - pure $ do - RemoteCtrl {remoteCtrlId, displayName} <- stored - let sessionActive = active == Just remoteCtrlId - pure RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} + chatReadVar remoteCtrlSession + $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted + map (rcInfo active) <$> withStore' getRemoteCtrls + where + rcInfo active RemoteCtrl {remoteCtrlId, displayName} = + let sessionActive = active == Just remoteCtrlId + in RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () acceptRemoteCtrl remoteCtrlId = do @@ -479,10 +480,7 @@ stopRemoteCtrl :: (ChatMonad m) => m () stopRemoteCtrl = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just rcs -> do - cancelRemoteCtrlSession rcs $ do - chatWriteVar remoteCtrlSession Nothing - toView CRRemoteCtrlStopped + Just rcs -> cancelRemoteCtrlSession rcs $ chatWriteVar remoteCtrlSession Nothing cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b5dce1ba84..d6826c8774 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -262,7 +262,7 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] - CRRemoteHostCreated rhId oobData -> ("remote host " <> sShow rhId <> " created") : viewRemoteCtrlOOBData oobData + CRRemoteHostCreated RemoteHostInfo {remoteHostId, remoteCtrlOOB} -> ("remote host " <> sShow remoteHostId <> " created") : viewRemoteCtrlOOBData remoteCtrlOOB CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] @@ -320,14 +320,14 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei | otherwise = [] ttyUserPrefix :: User -> [StyledString] -> [StyledString] ttyUserPrefix _ [] = [] - ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst prefix ss + ttyUserPrefix User {userId, localDisplayName = u} ss + | null prefix = ss + | otherwise = prependFirst ("[" <> mconcat prefix <> "] ") ss where - prefix = if outputRH /= currentRH then r else userPrefix - r = case outputRH of - Nothing -> "[local] " <> userPrefix - Just rh -> "[remote: ]" <> highlight (show rh) <> "] " - userPrefix = if Just userId /= currentUserId then "[user: " <> highlight u <> "] " else "" - currentUserId = fmap (\User {userId} -> userId) user_ + prefix = intersperse ", " $ remotePrefix <> userPrefix + remotePrefix = [maybe "local" (("remote: " <>) . highlight . show) outputRH | outputRH /= currentRH] + userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] + currentUserId = (\User {userId = uId} -> uId) <$> user_ ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 68ef6788e9..5bc1845803 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -120,15 +120,15 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do fingerprint' `shouldBe` fingerprint mobile ##> "/list remote ctrls" mobile <## "No remote controllers" - mobile ##> ("/register remote ctrl " <> fingerprint') + mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") mobile <## "remote controller 1 registered" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" - mobile <## "1. TODO" + mobile <## "1. My desktop" mobile ##> "/accept remote ctrl 1" mobile <## "ok" -- alternative scenario: accepted before controller start - mobile <## "remote controller 1 connecting to TODO" - mobile <## "remote controller 1 connected, TODO" + mobile <## "remote controller 1 connecting to My desktop" + mobile <## "remote controller 1 connected, My desktop" traceM " - Session active" desktop ##> "/list remote hosts" @@ -136,7 +136,7 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop <## "1. TODO (active)" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" - mobile <## "1. TODO (active)" + mobile <## "1. My desktop (active)" traceM " - Shutting desktop" desktop ##> "/stop remote host 1" @@ -181,12 +181,12 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob mobile <## "connection code:" fingerprint' <- getTermLine mobile fingerprint' `shouldBe` fingerprint - mobile ##> ("/register remote ctrl " <> fingerprint') + mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") mobile <## "remote controller 1 registered" mobile ##> "/accept remote ctrl 1" mobile <## "ok" -- alternative scenario: accepted before controller start - mobile <## "remote controller 1 connecting to TODO" - mobile <## "remote controller 1 connected, TODO" + mobile <## "remote controller 1 connecting to My desktop" + mobile <## "remote controller 1 connected, My desktop" desktop <## "remote host 1 connected" traceM " - exchanging contacts" diff --git a/tests/Test.hs b/tests/Test.hs index 6af51a0724..071ff3791e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -33,7 +33,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests - fdescribe "Remote session" remoteTests + describe "Remote session" remoteTests where testBracket test = do t <- getSystemTime From 41b86e07f18b0cceee3a881cfb0ce1b17cea8450 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 00:18:04 +0100 Subject: [PATCH 018/174] core: update api (#3221) --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- apps/ios/SimpleXChat/APITypes.swift | 19 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 149 ++++++++++++------ src/Simplex/Chat.hs | 3 +- src/Simplex/Chat/Controller.hs | 38 +---- src/Simplex/Chat/Remote.hs | 52 +++--- src/Simplex/Chat/Remote/Types.hs | 32 +++- src/Simplex/Chat/Store/Remote.hs | 12 +- src/Simplex/Chat/View.hs | 16 +- 9 files changed, 199 insertions(+), 126 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ad76364e96..0089fd0879 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -886,9 +886,9 @@ func startRemoteCtrl() async throws { try await sendCommandOkResp(.startRemoteCtrl) } -func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> Int64 { +func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> RemoteCtrlInfo { let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB)) - if case let .remoteCtrlRegistered(rcId) = r { return rcId } + if case let .remoteCtrlRegistered(rcInfo) = r { return rcInfo } throw r } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4b79800e1a..756ab3034f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -117,6 +117,7 @@ public enum ChatCommand { case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) case setFileToReceive(fileId: Int64, encrypted: Bool) case cancelFile(fileId: Int64) + case setLocalDeviceName(displayName: String) case startRemoteCtrl case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB) case listRemoteCtrls @@ -262,6 +263,7 @@ public enum ChatCommand { return s case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" case let .cancelFile(fileId): return "/fcancel \(fileId)" + case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" case .startRemoteCtrl: return "/start remote ctrl" case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)" case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)" @@ -381,6 +383,7 @@ public enum ChatCommand { case .receiveFile: return "receiveFile" case .setFileToReceive: return "setFileToReceive" case .cancelFile: return "cancelFile" + case .setLocalDeviceName: return "setLocalDeviceName" case .startRemoteCtrl: return "startRemoteCtrl" case .registerRemoteCtrl: return "registerRemoteCtrl" case .listRemoteCtrls: return "listRemoteCtrls" @@ -585,11 +588,11 @@ public enum ChatResponse: Decodable, Error { case newContactConnection(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlRegistered(remoteCtrlId: Int64) + case remoteCtrlRegistered(remoteCtrl: RemoteCtrlInfo) case remoteCtrlAnnounce(fingerprint: String) - case remoteCtrlFound(remoteCtrl: RemoteCtrl) - case remoteCtrlConnecting(remoteCtrlId: Int64, displayName: String) - case remoteCtrlConnected(remoteCtrlId: Int64, displayName: String) + case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlConnecting(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlStopped case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) @@ -874,11 +877,11 @@ public enum ChatResponse: Decodable, Error { case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlRegistered(rcId): return "remote ctrl ID: \(rcId)" + case let .remoteCtrlRegistered(remoteCtrl): return String(describing: remoteCtrl) case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)" - case let .remoteCtrlFound(remoteCtrl): return "remote ctrl: \(String(describing: remoteCtrl))" - case let .remoteCtrlConnecting(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" - case let .remoteCtrlConnected(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" + case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlConnecting(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case .remoteCtrlStopped: return noDetails 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/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 9ab6060fe9..f128fcb75f 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 @@ -166,6 +166,7 @@ class AppPreferences { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) + val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, "Desktop") private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -306,6 +307,7 @@ class AppPreferences { private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion" private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" + private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" } } @@ -342,6 +344,11 @@ object ChatController { val users = listUsers() chatModel.users.clear() chatModel.users.addAll(users) + val remoteHosts = listRemoteHosts() + if (remoteHosts != null) { + chatModel.remoteHosts.clear() + chatModel.remoteHosts.addAll(remoteHosts) + } if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true @@ -432,15 +439,16 @@ object ChatController { } } - private fun recvMsg(ctrl: ChatCtrl): CR? { + private fun recvMsg(ctrl: ChatCtrl): APIResponse? { val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (json == "") { null } else { - val r = APIResponse.decodeStr(json).resp + val apiResp = APIResponse.decodeStr(json) + val r = apiResp.resp Log.d(TAG, "chatRecvMsg: ${r.responseType}") if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json") - r + apiResp } } @@ -1327,6 +1335,59 @@ object ChatController { } } + suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName)) + + suspend fun createRemoteHost(): RemoteHostInfo? { + val r = sendCmd(CC.CreateRemoteHost()) + if (r is CR.RemoteHostCreated) return r.remoteHost + apiErrorAlert("createRemoteHost", generalGetString(MR.strings.error), r) + return null + } + + suspend fun listRemoteHosts(): List? { + val r = sendCmd(CC.ListRemoteHosts()) + if (r is CR.RemoteHostList) return r.remoteHosts + apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error), r) + return null + } + + suspend fun startRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StartRemoteHost(rhId)) + + suspend fun registerRemoteCtrl(oob: RemoteCtrlOOB): RemoteCtrlInfo? { + val r = sendCmd(CC.RegisterRemoteCtrl(oob)) + if (r is CR.RemoteCtrlRegistered) return r.remoteCtrl + apiErrorAlert("registerRemoteCtrl", generalGetString(MR.strings.error), r) + return null + } + + suspend fun listRemoteCtrls(): List? { + val r = sendCmd(CC.ListRemoteCtrls()) + if (r is CR.RemoteCtrlList) return r.remoteCtrls + apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error), r) + return null + } + + suspend fun stopRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) + + suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) + + suspend fun startRemoteCtrl(): Boolean = sendCommandOkResp(CC.StartRemoteCtrl()) + + suspend fun acceptRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.AcceptRemoteCtrl(rcId)) + + suspend fun rejectRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.RejectRemoteCtrl(rcId)) + + suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl()) + + suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId)) + + private suspend fun sendCommandOkResp(cmd: CC): Boolean { + val r = sendCmd(cmd) + val ok = r is CR.CmdOk + if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r) + return ok + } + suspend fun apiGetVersion(): CoreVersionInfo? { val r = sendCmd(CC.ShowVersion()) return if (r is CR.VersionInfo) { @@ -1361,14 +1422,15 @@ object ChatController { } } - fun apiErrorAlert(method: String, title: String, r: CR) { + private fun apiErrorAlert(method: String, title: String, r: CR) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") AlertManager.shared.showAlertMsg(title, errMsg) } - suspend fun processReceivedMsg(r: CR) { + private suspend fun processReceivedMsg(apiResp: APIResponse) { lastMsgReceivedTimestamp = System.currentTimeMillis() + val r = apiResp.resp chatModel.addTerminalItem(TerminalItem.resp(r)) when (r) { is CR.NewContactConnection -> { @@ -1674,6 +1736,13 @@ object ChatController { chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) is CR.GroupMemberRatchetSync -> chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + is CR.RemoteHostConnected -> { + // update + chatModel.connectingRemoteHost.value = r.remoteHost + } + is CR.RemoteHostStopped -> { + // + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1933,6 +2002,7 @@ sealed class CC { class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() + class SetLocalDeviceName(val displayName: String): CC() class CreateRemoteHost(): CC() class ListRemoteHosts(): CC() class StartRemoteHost(val remoteHostId: Long): CC() @@ -2053,13 +2123,14 @@ sealed class CC { is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" + is SetLocalDeviceName -> "/set device name $displayName" is CreateRemoteHost -> "/create remote host" is ListRemoteHosts -> "/list remote hosts" is StartRemoteHost -> "/start remote host $remoteHostId" is StopRemoteHost -> "/stop remote host $remoteHostId" is DeleteRemoteHost -> "/delete remote host $remoteHostId" is StartRemoteCtrl -> "/start remote ctrl" - is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.caFingerprint}" + is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.fingerprint}" is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId" is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId" is ListRemoteCtrls -> "/list remote ctrls" @@ -2162,6 +2233,7 @@ sealed class CC { is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" + is SetLocalDeviceName -> "setLocalDeviceName" is CreateRemoteHost -> "createRemoteHost" is ListRemoteHosts -> "listRemoteHosts" is StartRemoteHost -> "startRemoteHost" @@ -3246,7 +3318,8 @@ data class RemoteCtrl ( @Serializable data class RemoteCtrlOOB ( - val caFingerprint: String + val fingerprint: String, + val displayName: String ) @Serializable @@ -3261,6 +3334,7 @@ data class RemoteHostInfo ( val remoteHostId: Long, val storePath: String, val displayName: String, + val remoteCtrlOOB: RemoteCtrlOOB, val sessionActive: Boolean ) @@ -3277,7 +3351,7 @@ val yaml = Yaml(configuration = YamlConfiguration( )) @Serializable -class APIResponse(val resp: CR, val corr: String? = null) { +class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = null) { companion object { fun decodeStr(str: String): APIResponse { return try { @@ -3287,48 +3361,35 @@ class APIResponse(val resp: CR, val corr: String? = null) { Log.d(TAG, e.localizedMessage ?: "") val data = json.parseToJsonElement(str).jsonObject val resp = data["resp"]!!.jsonObject - val type = resp["type"]?.jsonPrimitive?.content ?: "invalid" + val type = resp["type"]?.jsonPrimitive?.contentOrNull ?: "invalid" + val corr = data["corr"]?.toString() + val remoteHostId = data["remoteHostId"]?.jsonPrimitive?.longOrNull try { if (type == "apiChats") { val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chats: List = resp["chats"]!!.jsonArray.map { parseChatData(it) } - return APIResponse( - resp = CR.ApiChats(user, chats), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ApiChats(user, chats), remoteHostId, corr) } else if (type == "apiChat") { val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chat = parseChatData(resp["chat"]!!) - return APIResponse( - resp = CR.ApiChat(user, chat), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr) } else if (type == "chatCmdError") { val userObject = resp["user_"]?.jsonObject val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse( - resp = CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } else if (type == "chatError") { val userObject = resp["user_"]?.jsonObject val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse( - resp = CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } } catch (e: Exception) { Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString()) } - APIResponse( - resp = CR.Response(type, json.encodeToString(data)), - corr = data["corr"]?.toString() - ) + APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr) } catch(e: Exception) { - APIResponse(CR.Invalid(str)) + APIResponse(CR.Invalid(str), remoteHostId = null) } } } @@ -3484,17 +3545,17 @@ sealed class CR { @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() // remote events (desktop) - @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() + @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() - @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() // remote events (mobile) @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() - @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() - @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() - @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @@ -3767,17 +3828,17 @@ sealed class CR { is CallEnded -> withUser(user, "contact: ${contact.id}") is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) - is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" - is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" - is RemoteHostConnected -> "remote host ID: $remoteHostId" + is RemoteHostCreated -> json.encodeToString(remoteHost) + is RemoteHostList -> json.encodeToString(remoteHosts) + is RemoteHostConnected -> json.encodeToString(remoteHost) is RemoteHostStopped -> "remote host ID: $remoteHostId" is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlRegistered -> json.encodeToString(remoteCtrl) is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" - is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" - is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlStopped -> "" + is RemoteCtrlFound -> json.encodeToString(remoteCtrl) + is RemoteCtrlConnecting -> json.encodeToString(remoteCtrl) + is RemoteCtrlConnected -> json.encodeToString(remoteCtrl) + is RemoteCtrlStopped -> noDetails() 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/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 726fbdce88..9c25afbd51 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -70,6 +70,7 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -1900,7 +1901,7 @@ processChatCommand = \case StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ - RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob + RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ StopRemoteCtrl -> stopRemoteCtrl >> ok_ diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2a2b7cff98..22c2649f5a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -633,14 +633,14 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} - | CRRemoteHostConnected {remoteHostId :: RemoteHostId} -- TODO add displayName + | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation - | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} - | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} + | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect + | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} + | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} @@ -693,34 +693,6 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False -data RemoteCtrlOOB = RemoteCtrlOOB - { caFingerprint :: C.KeyHash, - displayName :: Text - } - deriving (Show, Generic, FromJSON) - -instance ToJSON RemoteCtrlOOB where toEncoding = J.genericToEncoding J.defaultOptions - -data RemoteHostInfo = RemoteHostInfo - { remoteHostId :: RemoteHostId, - storePath :: FilePath, - displayName :: Text, - remoteCtrlOOB :: RemoteCtrlOOB, - sessionActive :: Bool - } - deriving (Show, Generic, FromJSON) - -instance ToJSON RemoteHostInfo where toEncoding = J.genericToEncoding J.defaultOptions - -data RemoteCtrlInfo = RemoteCtrlInfo - { remoteCtrlId :: RemoteCtrlId, - displayName :: Text, - sessionActive :: Bool - } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RemoteCtrlInfo where toEncoding = J.genericToEncoding J.defaultOptions - data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4d031634ff..256e00d6d4 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -33,6 +33,7 @@ import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) +import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Network.HTTP.Types as HTTP @@ -93,7 +94,7 @@ startRemoteHost remoteHostId = do M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId Just _ -> closeRemoteHostSession remoteHostId >> toView (CRRemoteHostStopped remoteHostId) - run RemoteHost {storePath, caKey, caCert} = do + run rh@RemoteHost {storePath, caKey, caCert} = do finished <- newTVarIO False let parent = (C.signatureKeyPair caKey, caCert) sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" @@ -120,7 +121,9 @@ startRemoteHost remoteHostId = do Nothing -> toViewRemote chatResponse Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}} _ -> toViewRemote chatResponse - toView CRRemoteHostConnected {remoteHostId} + rcName <- chatReadVar localDeviceName + -- TODO what sets session active? + toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) @@ -155,13 +158,13 @@ cancelRemoteHostSession = \case createRemoteHost :: (ChatMonad m) => m RemoteHostInfo createRemoteHost = do - let hostDisplayName = "TODO" -- you don't have remote host name here, it will be passed from remote host - ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) hostDisplayName + let rhName = "TODO" -- you don't have remote host name here, it will be passed from remote host + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) rhName storePath <- liftIO randomStorePath - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath hostDisplayName caKey caCert - displayName <- chatReadVar localDeviceName - let remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName} - pure RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive = False} + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath rhName caKey caCert + rcName <- chatReadVar localDeviceName + let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} + pure RemoteHostInfo {remoteHostId, storePath, displayName = rhName, remoteCtrlOOB, sessionActive = False} -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -173,10 +176,13 @@ listRemoteHosts = do rcName <- chatReadVar localDeviceName map (rhInfo active rcName) <$> withStore' getRemoteHosts where - rhInfo active rcName RemoteHost {remoteHostId, storePath, displayName, caCert} = - let sessionActive = M.member remoteHostId active - remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName = rcName} - in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} + rhInfo active rcName rh@RemoteHost {remoteHostId} = + remoteHostInfo rh (M.member remoteHostId active) rcName + +remoteHostInfo :: RemoteHost -> Bool -> Text -> RemoteHostInfo +remoteHostInfo RemoteHost {remoteHostId, storePath, displayName, caCert} sessionActive rcName = + let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} + in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do @@ -405,13 +411,13 @@ startRemoteCtrl execChatCommand = accepted <- newEmptyTMVarIO supervisor <- async $ do remoteCtrlId <- atomically (readTMVar accepted) - withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do + withRemoteCtrl remoteCtrlId $ \rc@RemoteCtrl {fingerprint} -> do source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure - toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName} + toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName} + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped @@ -436,7 +442,7 @@ discoverRemoteCtrls discovered = Discovery.withListener go withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of - Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required + Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required Just False -> pure () -- skipping a rejected item Just True -> chatReadVar remoteCtrlSession >>= \case @@ -444,11 +450,6 @@ discoverRemoteCtrls discovered = Discovery.withListener go Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock -registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m RemoteCtrlId -registerRemoteCtrl RemoteCtrlOOB {caFingerprint, displayName} = do - remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint - pure remoteCtrlId - listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] listRemoteCtrls = do active <- @@ -456,9 +457,12 @@ listRemoteCtrls = do $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted map (rcInfo active) <$> withStore' getRemoteCtrls where - rcInfo active RemoteCtrl {remoteCtrlId, displayName} = - let sessionActive = active == Just remoteCtrlId - in RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} + rcInfo active rc@RemoteCtrl {remoteCtrlId} = + remoteCtrlInfo rc $ active == Just remoteCtrlId + +remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo +remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive = + RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive} acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () acceptRemoteCtrl remoteCtrlId = do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index cdff2b7acc..67fe7c6ffa 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -24,6 +24,25 @@ data RemoteHost = RemoteHost } deriving (Show) +data RemoteCtrlOOB = RemoteCtrlOOB + { fingerprint :: C.KeyHash, + displayName :: Text + } + deriving (Show) + +$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) + +data RemoteHostInfo = RemoteHostInfo + { remoteHostId :: RemoteHostId, + storePath :: FilePath, + displayName :: Text, + remoteCtrlOOB :: RemoteCtrlOOB, + sessionActive :: Bool + } + deriving (Show) + +$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) + type RemoteCtrlId = Int64 data RemoteCtrl = RemoteCtrl @@ -34,4 +53,15 @@ data RemoteCtrl = RemoteCtrl } deriving (Show) -$(J.deriveJSON J.defaultOptions ''RemoteCtrl) +$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) + +data RemoteCtrlInfo = RemoteCtrlInfo + { remoteCtrlId :: RemoteCtrlId, + displayName :: Text, + fingerprint :: C.KeyHash, + accepted :: Maybe Bool, + sessionActive :: Bool + } + deriving (Show) + +$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index c231a535b5..9189a27769 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -9,14 +9,15 @@ import Data.Text (Text) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Chat.Remote.Types (RemoteCtrl (..), RemoteCtrlId, RemoteHost (..), RemoteHostId) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Remote.Types import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) import qualified Simplex.Messaging.Crypto as C insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId insertRemoteHost db storePath displayName caKey caCert = do DB.execute db "INSERT INTO remote_hosts (store_path, display_name, ca_key, ca_cert) VALUES (?,?,?,?)" (storePath, displayName, caKey, C.SignedObject caCert) - fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" + insertedRowId db getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = @@ -37,10 +38,11 @@ toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) -insertRemoteCtrl :: DB.Connection -> Text -> C.KeyHash -> IO RemoteCtrlId -insertRemoteCtrl db displayName fingerprint = do +insertRemoteCtrl :: DB.Connection -> RemoteCtrlOOB -> IO RemoteCtrlInfo +insertRemoteCtrl db RemoteCtrlOOB {fingerprint, displayName} = do DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint) - fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" + remoteCtrlId <- insertedRowId db + pure RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted = Nothing, sessionActive = False} getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] getRemoteCtrls db = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d6826c8774..51dcd0c6b6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -264,14 +264,14 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRNtfMessages {} -> [] CRRemoteHostCreated RemoteHostInfo {remoteHostId, remoteCtrlOOB} -> ("remote host " <> sShow remoteHostId <> " created") : viewRemoteCtrlOOBData remoteCtrlOOB CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] + CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] + CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] - CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] + CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> @@ -1633,8 +1633,8 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo parens s = " (" <> s <> ")" viewRemoteCtrlOOBData :: RemoteCtrlOOB -> [StyledString] -viewRemoteCtrlOOBData RemoteCtrlOOB {caFingerprint} = - ["connection code:", plain $ strEncode caFingerprint] +viewRemoteCtrlOOBData RemoteCtrlOOB {fingerprint} = + ["connection code:", plain $ strEncode fingerprint] viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case @@ -1653,8 +1653,8 @@ viewRemoteCtrls = \case plain $ tshow remoteCtrlId <> ". " <> displayName <> if sessionActive then " (active)" else "" -- TODO fingerprint, accepted? -viewRemoteCtrl :: RemoteCtrl -> StyledString -viewRemoteCtrl RemoteCtrl {remoteCtrlId, displayName} = +viewRemoteCtrl :: RemoteCtrlInfo -> StyledString +viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, displayName} = plain $ tshow remoteCtrlId <> ". " <> displayName viewChatError :: ChatLogLevel -> ChatError -> [StyledString] From fc1bba8817c93c51c03fa0d905ec87f5cf3bbbb2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 14:17:36 +0100 Subject: [PATCH 019/174] remote: refactor (WIP) (#3222) * remote: refactor (WIP) * refactor discoverRemoteCtrls * refactor processRemoteCommand, storeRemoteFile * refactor fetchRemoteFile * refactor startRemoteHost, receiving files * refactor relayCommand --- apps/ios/SimpleXChat/APITypes.swift | 1 - .../chat/simplex/common/model/SimpleXAPI.kt | 2 - src/Simplex/Chat.hs | 5 +- src/Simplex/Chat/Controller.hs | 15 +- src/Simplex/Chat/Remote.hs | 479 +++++++++--------- src/Simplex/Chat/Remote/Discovery.hs | 6 +- src/Simplex/Chat/Store/Remote.hs | 13 +- src/Simplex/Chat/Store/Shared.hs | 3 + tests/RemoteTests.hs | 4 +- 9 files changed, 274 insertions(+), 254 deletions(-) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 756ab3034f..761c1daf7c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1804,7 +1804,6 @@ public enum ArchiveError: Decodable { } public enum RemoteCtrlError: Decodable { - case missing(remoteCtrlId: Int64) case inactive case busy case timeout 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 f128fcb75f..1f5cc09d47 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 @@ -4521,7 +4521,6 @@ sealed class RemoteHostError { @Serializable sealed class RemoteCtrlError { val string: String get() = when (this) { - is Missing -> "missing" is Inactive -> "inactive" is Busy -> "busy" is Timeout -> "timeout" @@ -4531,7 +4530,6 @@ sealed class RemoteCtrlError { is CertificateUntrusted -> "certificateUntrusted" is BadFingerprint -> "badFingerprint" } - @Serializable @SerialName("missing") class Missing(val remoteCtrlId: Long): RemoteCtrlError() @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() @Serializable @SerialName("busy") object Busy: RemoteCtrlError() @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9c25afbd51..e1bd795b38 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -397,7 +397,7 @@ execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> (ByteString, ChatCommand) -> m ChatResponse -execRemoteCommand u rh scmd = either (CRChatCmdError u) id <$> runExceptT (withRemoteHostSession rh $ \rhs -> processRemoteCommand rhs scmd) +execRemoteCommand u rhId scmd = either (CRChatCmdError u) id <$> runExceptT (getRemoteHostSession rhId >>= (`processRemoteCommand` scmd)) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace @@ -5154,9 +5154,6 @@ closeFileHandle fileId files = do h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m) liftIO $ mapM_ hClose h_ `catchAll_` pure () -throwChatError :: ChatMonad m => ChatErrorType -> m a -throwChatError = throwError . ChatError - deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () deleteMembersConnections user members = do let memberConns = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 22c2649f5a..78848cca22 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1158,8 +1158,7 @@ instance ToJSON RemoteHostError where -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError - = RCEMissing {remoteCtrlId :: RemoteCtrlId} -- ^ No remote session matches this identifier - | RCEInactive -- ^ No session is running + = RCEInactive -- ^ No session is running | RCEBusy -- ^ A session is already running | RCETimeout -- ^ Remote operation timed out | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller @@ -1167,6 +1166,9 @@ data RemoteCtrlError | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection | RCEBadFingerprint -- ^ Bad fingerprint data provided in OOB + | RCEHTTP2Error {http2Error :: String} + | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove + | RCEInvalidResponse {responseError :: String} deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where @@ -1199,7 +1201,7 @@ data RemoteHostSession } data RemoteCtrlSession = RemoteCtrlSession - { -- | Server side of transport to process remote commands and forward notifications + { -- | Host (mobile) side of transport to process remote commands and forward notifications discoverer :: Async (), supervisor :: Async (), hostServer :: Maybe (Async ()), @@ -1239,6 +1241,10 @@ chatFinally :: ChatMonad m => m a -> m b -> m a chatFinally = allFinally mkChatError {-# INLINE chatFinally #-} +onChatError :: ChatMonad m => m a -> m b -> m a +a `onChatError` onErr = a `catchChatError` \e -> onErr >> throwError e +{-# INLINE onChatError #-} + mkChatError :: SomeException -> ChatError mkChatError = ChatError . CEException . show {-# INLINE mkChatError #-} @@ -1246,6 +1252,9 @@ mkChatError = ChatError . CEException . show chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError +throwChatError :: ChatMonad m => ChatErrorType -> m a +throwChatError = throwError . ChatError + -- | Emit local events. toView :: ChatMonad' m => ChatResponse -> m () toView event = do diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 256e00d6d4..336b5d2cf9 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -38,8 +38,8 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP.Types.Status as Status -import qualified Network.HTTP2.Client as HTTP2Client -import qualified Network.HTTP2.Server as HTTP2Server +import qualified Network.HTTP2.Client as HC +import qualified Network.HTTP2.Server as HS import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller import Simplex.Chat.Messages (AChatItem (..), CIFile (..), CIFileStatus (..), ChatItem (..), chatNameStr) @@ -60,103 +60,100 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, HTTP2Response (..)) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow, ifM, tshow, ($>>=)) +import Simplex.Messaging.Util (bshow, ifM, liftEitherError, liftEitherWith, tshow, ($>>=)) import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) -withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a -withRemoteHostSession remoteHostId action = do - chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteHostId +getRemoteHostSession :: ChatMonad m => RemoteHostId -> m RemoteHostSession +getRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe err pure . M.lookup rhId where - err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing + err = throwError $ ChatErrorRemoteHost rhId RHMissing -withRemoteHost :: (ChatMonad m) => RemoteHostId -> (RemoteHost -> m a) -> m a -withRemoteHost remoteHostId action = - withStore' (`getRemoteHost` remoteHostId) >>= \case - Nothing -> throwError $ ChatErrorRemoteHost remoteHostId RHMissing - Just rh -> action rh +checkNoRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +checkNoRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe (pure ()) err . M.lookup rhId + where + err _ = throwError $ ChatErrorRemoteHost rhId RHBusy -startRemoteHost :: (ChatMonad m) => RemoteHostId -> m () -startRemoteHost remoteHostId = do - asks remoteHostSessions >>= atomically . TM.lookup remoteHostId >>= \case - Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy - Nothing -> withRemoteHost remoteHostId $ \rh -> do - announcer <- async $ run rh - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} +startRemoteHost :: ChatMonad m => RemoteHostId -> m () +startRemoteHost rhId = do + checkNoRemoteHostSession rhId + rh <- withStore (`getRemoteHost` rhId) + announcer <- async $ do + finished <- newTVarIO False + http <- start rh finished `onChatError` cleanup finished + run rh finished http + chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarting {announcer} where cleanup finished = do logInfo "Remote host http2 client fininshed" atomically $ writeTVar finished True - M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case - Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId - Just _ -> closeRemoteHostSession remoteHostId >> toView (CRRemoteHostStopped remoteHostId) - run rh@RemoteHost {storePath, caKey, caCert} = do - finished <- newTVarIO False + -- TODO why this is not an error? + M.lookup rhId <$> chatReadVar remoteHostSessions >>= \case + Nothing -> logInfo $ "Session already closed for remote host " <> tshow rhId + Just _ -> closeRemoteHostSession rhId >> toView (CRRemoteHostStopped rhId) + start rh@RemoteHost {storePath, caKey, caCert} finished = do let parent = (C.signatureKeyPair caKey, caCert) sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] - Discovery.announceRevHTTP2 (cleanup finished) fingerprint credentials >>= \case - Left h2ce -> do - logError $ "Failed to set up remote host connection: " <> tshow h2ce - cleanup finished - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} - chatWriteVar currentRemoteHost $ Just remoteHostId - sendHello ctrlClient >>= \case - Left h2ce -> do - logError $ "Failed to send initial remote host request: " <> tshow h2ce - cleanup finished - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got initial from remote host: " <> tshow bodyHead - oq <- asks outputQ - let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just remoteHostId,) - void . async $ pollRemote finished ctrlClient "/recv" $ \chatResponse -> do - case chatResponse of - CRRcvFileComplete {user = ru, chatItem = AChatItem c d@SMDRcv i ci@ChatItem {file = Just ciFile}} -> do - handleRcvFileComplete ctrlClient storePath ru ciFile >>= \case - Nothing -> toViewRemote chatResponse - Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}} - _ -> toViewRemote chatResponse - rcName <- chatReadVar localDeviceName - -- TODO what sets session active? - toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} + u <- askUnliftIO + ctrlClient <- liftHTTP2 $ Discovery.announceRevHTTP2 fingerprint credentials $ unliftIO u (cleanup finished) -- >>= \case + chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarted {storePath, ctrlClient} + chatWriteVar currentRemoteHost $ Just rhId + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- sendHello ctrlClient + rcName <- chatReadVar localDeviceName + -- TODO what sets session active? + toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} + pure ctrlClient + run RemoteHost {storePath} finished ctrlClient = do + oq <- asks outputQ + let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just rhId,) + -- TODO remove REST + void . async $ pollRemote finished ctrlClient "/recv" $ handleFile >=> toViewRemote + where + -- TODO move to view / terminal + handleFile = \case + cr@CRRcvFileComplete {user, chatItem = AChatItem c SMDRcv i ci@ChatItem {file = Just ciFile@CIFile {fileStatus = CIFSRcvComplete}}} -> do + maybe cr update <$> handleRcvFileComplete ctrlClient storePath user ciFile + where + update localFile = cr {chatItem = AChatItem c SMDRcv i ci {file = Just localFile}} + cr -> pure cr -sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) -sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) +sendHello :: ChatMonad m => HTTP2Client -> m HTTP2Response +sendHello http = liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing where - req = HTTP2Client.requestNoBody "GET" "/" mempty + req = HC.requestNoBody "GET" "/" mempty -pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> m ()) -> m () -pollRemote finished http path action = loop +-- TODO how (on what condition) it would stop polling? +-- TODO add JSON translation +pollRemote :: ChatMonad m => TVar Bool -> HTTP2Client -> ByteString -> (ChatResponse -> m ()) -> m () +pollRemote finished http path action = loop `catchChatError` \e -> action (CRChatError Nothing e) >> loop where loop = do - liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case - Left e -> logError $ "pollRemote: " <> tshow (path, e) - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got /recv response: " <> decodeUtf8 bodyHead - case J.eitherDecodeStrict' bodyHead of - Left e -> logError $ "pollRemote/decode: " <> tshow (path, e) - Right o -> action o + -- TODO this will never load full body + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing + json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead -- of + action json readTVarIO finished >>= (`unless` loop) - req = HTTP2Client.requestNoBody "GET" path mempty + req = HC.requestNoBody "GET" path mempty -closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () -closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do +closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +closeRemoteHostSession remoteHostId = do + session <- getRemoteHostSession remoteHostId logInfo $ "Closing remote host session for " <> tshow remoteHostId liftIO $ cancelRemoteHostSession session chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId -cancelRemoteHostSession :: (MonadUnliftIO m) => RemoteHostSession -> m () +cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient -createRemoteHost :: (ChatMonad m) => m RemoteHostInfo +createRemoteHost :: ChatMonad m => m RemoteHostInfo createRemoteHost = do let rhName = "TODO" -- you don't have remote host name here, it will be passed from remote host ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) rhName @@ -170,7 +167,7 @@ createRemoteHost = do randomStorePath :: IO FilePath randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 -listRemoteHosts :: (ChatMonad m) => m [RemoteHostInfo] +listRemoteHosts :: ChatMonad m => m [RemoteHostInfo] listRemoteHosts = do active <- chatReadVar remoteHostSessions rcName <- chatReadVar localDeviceName @@ -184,75 +181,72 @@ remoteHostInfo RemoteHost {remoteHostId, storePath, displayName, caCert} session let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} -deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () -deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do +deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () +deleteRemoteHost rhId = do + RemoteHost {storePath} <- withStore (`getRemoteHost` rhId) chatReadVar filesFolder >>= \case Just baseDir -> do let hostStore = baseDir storePath logError $ "TODO: remove " <> tshow hostStore Nothing -> logWarn "Local file store not available while deleting remote host" - withStore' $ \db -> deleteRemoteHostRecord db remoteHostId + withStore' (`deleteRemoteHostRecord` rhId) -processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" -processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = do - logDebug $ "processRemoteCommand: " <> tshow (s, cmd) - case cmd of - SendFile cn ctrlPath -> do - storeRemoteFile ctrlClient ctrlPath >>= \case - -- TODO: use Left - Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" - Just hostPath -> relayCommand ctrlClient $ "/file " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath - SendImage cn ctrlPath -> do - storeRemoteFile ctrlClient ctrlPath >>= \case - Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store image on remote host" - Just hostPath -> relayCommand ctrlClient $ "/image " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath - APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do - storeRemoteFile ctrlClient ctrlPath >>= \case - Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" - Just hostPath -> do - let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage - relayCommand ctrlClient $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') - _ -> relayCommand ctrlClient s - -relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse -relayCommand http s = - postBytestring Nothing http "/send" mempty s >>= \case - Left e -> err $ "relayCommand/post: " <> show e - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got /send response: " <> decodeUtf8 bodyHead - remoteChatResponse <- case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> err $ "relayCommand/decodeValue: " <> show e - Right json -> case J.fromJSON $ toTaggedJSON json of - J.Error e -> err $ "relayCommand/fromJSON: " <> show e - J.Success cr -> pure cr - case remoteChatResponse of - -- TODO: intercept file responses and fetch files when needed - -- XXX: is that even possible, to have a file response to a command? - _ -> pure remoteChatResponse +processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse +processRemoteCommand RemoteHostSessionStarting {} _ = pure $ chatCmdError Nothing "remote command sent before session started" +processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = + uploadFile cmd >>= relayCommand ctrlClient where - err = pure . CRChatError Nothing . ChatError . CEInternalError + fileCmd cmdPfx cn hostPath = utf8String $ unwords [cmdPfx, chatNameStr cn, hostPath] + uploadFile = \case + SendFile cn ctrlPath -> fileCmd "/file" cn <$> storeRemoteFile ctrlClient ctrlPath + SendImage cn ctrlPath -> fileCmd "/image" cn <$> storeRemoteFile ctrlClient ctrlPath + -- TODO APISendMessage should only be used with host path already, and UI has to upload file first. + -- The problem is that we cannot have different file names in host and controller, because it simply won't be able to show files. + -- So we need to ask the host to store files BEFORE storing them in the app storage and use host names in the command and to store the file locally if it has to be shown, + -- or don't even store it if it's not image/video. + -- The current approach won't work. + -- It also does not account for local file encryption. + -- Also, local file encryption setting should be tracked in the controller, as otherwise host won't be able to decide what to do having received the upload command. + APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do + hostPath <- storeRemoteFile ctrlClient ctrlPath + let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage + -- TODO we shouldn't manipulate JSON like that + pure $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') + _ -> pure s + +relayCommand :: ChatMonad m => HTTP2Client -> ByteString -> m ChatResponse +relayCommand http s = do + -- TODO ExceptT + let timeout' = Nothing + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- + liftHTTP2 $ HTTP2.sendRequestDirect http req timeout' + -- TODO: large JSONs can overflow into buffered chunks + json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead + case J.fromJSON $ toTaggedJSON json of + J.Error e -> err $ show e + J.Success cr -> pure cr + where + err = pure . CRChatError Nothing . ChatErrorRemoteCtrl . RCEInvalidResponse toTaggedJSON :: J.Value -> J.Value toTaggedJSON = id -- owsf2tagged TODO: get from RemoteHost - -- XXX: extract to http2 transport - postBytestring timeout' c path hs body = liftIO $ HTTP2.sendRequestDirect c req timeout' - where - req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) + req = HC.requestBuilder "POST" "/send" mempty (Binary.fromByteString s) -handleRcvFileComplete :: (ChatMonad m) => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) -handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fileStatus} = case fileStatus of - CIFSRcvComplete -> - chatReadVar filesFolder >>= \case - Just baseDir -> do - let hostStore = baseDir storePath - createDirectoryIfMissing True hostStore - localPath <- uniqueCombine hostStore fileName - ok <- fetchRemoteFile http remoteUser fileId localPath - if ok - then pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) - else Nothing <$ logError "fetchRemoteFile failed" - Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" - _ -> Nothing <$ logDebug ("Ingoring invalid file notification for file (" <> tshow fileId <> ") " <> tshow fileName) +-- TODO fileName is just metadata that does not determine the actual file location for UI, or whether it is encrypted or not +-- fileSource is the actual file location (with information whether it is locally encrypted) +handleRcvFileComplete :: ChatMonad m => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) +handleRcvFileComplete http storePath remoteUser f@CIFile {fileId, fileName} = + chatReadVar filesFolder >>= \case + Just baseDir -> do + let hostStore = baseDir storePath + createDirectoryIfMissing True hostStore + -- TODO the problem here is that the name may turn out to be different and nothing will work + -- file processing seems to work "accidentally", not "by design" + localPath <- uniqueCombine hostStore fileName + fetchRemoteFile http remoteUser fileId localPath + pure $ Just (f {fileName = localPath} :: CIFile 'MDRcv) + -- TODO below will not work with CLI, it should store file to download folder when not specified + -- It should not load all files when received, instead it should only load files received with /fr commands + Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value @@ -288,36 +282,42 @@ owsf2tagged = fst . convert pattern OwsfTag :: (JK.Key, J.Value) pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) -storeRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> FilePath -> m (Maybe FilePath) +storeRemoteFile :: ChatMonad m => HTTP2Client -> FilePath -> m FilePath storeRemoteFile http localFile = do - putFile Nothing http uri mempty localFile >>= \case - Left h2ce -> Nothing <$ logError (tshow h2ce) - Right HTTP2.HTTP2Response {response, respBody = HTTP2Body {bodyHead}} -> - case HTTP.statusCode <$> HTTP2Client.responseStatus response of - Just 200 -> pure . Just $ B.unpack bodyHead - notOk -> Nothing <$ logError ("Bad response status: " <> tshow notOk) + fileSize <- liftIO $ fromIntegral <$> getFileSize localFile + -- TODO configure timeout + let timeout' = Nothing + r@HTTP2Response {respBody = HTTP2Body {bodyHead}} <- + liftHTTP2 $ HTTP2.sendRequestDirect http (req fileSize) timeout' + responseStatusOK r + -- TODO what if response doesn't fit in the head? + -- it'll be solved when processing moved to POST with Command/Response types + pure $ B.unpack bodyHead where + -- TODO local file encryption? uri = "/store?" <> HTTP.renderSimpleQuery False [("file_name", utf8String $ takeFileName localFile)] - putFile timeout' c path hs file = liftIO $ do - fileSize <- fromIntegral <$> getFileSize file - HTTP2.sendRequestDirect c (req fileSize) timeout' - where - req size = HTTP2Client.requestFile "PUT" path hs (HTTP2Client.FileSpec file 0 size) + req size = HC.requestFile "PUT" uri mempty (HC.FileSpec localFile 0 size) -fetchRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> User -> Int64 -> FilePath -> m Bool +liftHTTP2 :: ChatMonad m => IO (Either HTTP2ClientError a) -> m a +liftHTTP2 = liftEitherError $ ChatErrorRemoteCtrl . RCEHTTP2Error . show + +responseStatusOK :: ChatMonad m => HTTP2Response -> m () +responseStatusOK HTTP2Response {response} = do + let s = HC.responseStatus response + unless (s == Just Status.ok200) $ + throwError $ ChatErrorRemoteCtrl $ RCEHTTP2RespStatus $ Status.statusCode <$> s + +fetchRemoteFile :: ChatMonad m => HTTP2Client -> User -> Int64 -> FilePath -> m () fetchRemoteFile http User {userId = remoteUserId} remoteFileId localPath = do - liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case - Left h2ce -> False <$ logError (tshow h2ce) - Right HTTP2.HTTP2Response {response, respBody} -> - if HTTP2Client.responseStatus response == Just Status.ok200 - then True <$ writeBodyToFile localPath respBody - else False <$ (logError $ "Request failed: " <> maybe "(??)" tshow (HTTP2Client.responseStatus response) <> " " <> decodeUtf8 (bodyHead respBody)) + r@HTTP2Response {respBody} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing + responseStatusOK r + writeBodyToFile localPath respBody where - req = HTTP2Client.requestNoBody "GET" path mempty + req = HC.requestNoBody "GET" path mempty path = "/fetch?" <> HTTP.renderSimpleQuery False [("user_id", bshow remoteUserId), ("file_id", bshow remoteFileId)] -- XXX: extract to Transport.HTTP2 ? -writeBodyToFile :: (MonadUnliftIO m) => FilePath -> HTTP2Body -> m () +writeBodyToFile :: MonadUnliftIO m => FilePath -> HTTP2Body -> m () writeBodyToFile path HTTP2Body {bodyHead, bodySize, bodyPart} = do logInfo $ "Receiving " <> tshow bodySize <> " bytes to " <> tshow path liftIO . withFile path WriteMode $ \h -> do @@ -331,7 +331,8 @@ hPutBodyChunks h getChunk = do hPut h chunk hPutBodyChunks h getChunk -processControllerRequest :: forall m. (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () +-- TODO command/response pattern, remove REST conventions +processControllerRequest :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, sendResponse} = do logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) res <- tryChatError $ case (method, ps) of @@ -345,8 +346,8 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" where - method = fromMaybe "" $ HTTP2Server.requestMethod request - path = fromMaybe "/" $ HTTP2Server.requestPath request + method = fromMaybe "" $ HS.requestMethod request + path = fromMaybe "/" $ HS.requestPath request (ps, query) = HTTP.decodePath path getHello = respond "OK" sendCommand = execChatCommand (bodyHead reqBody) >>= respondJSON @@ -354,6 +355,7 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s chatReadVar remoteCtrlSession >>= \case Nothing -> respondWith Status.internalServerError500 "session not active" Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON + -- TODO liftEither storeFileQuery storeFile = case storeFileQuery of Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) Right fileName -> do @@ -365,6 +367,7 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s respond $ Binary.putStringUtf8 storeRelative where storeFileQuery = parseField "file_name" $ A.many1 (A.satisfy $ not . isPathSeparator) + -- TODO move to ExceptT monad, catch errors in one place, convert errors to responses fetchFile = case fetchFileQuery of Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) Right (userId, fileId) -> do @@ -372,12 +375,13 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s x <- withStore' $ \db -> runExceptT $ do user <- getUser db userId getRcvFileTransfer db user fileId + -- TODO this error handling is very ad-hoc, there is no separation between Chat errors and responses case x of Right RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> do baseDir <- fromMaybe "." <$> chatReadVar filesFolder let fullPath = baseDir filePath size <- fromInteger <$> getFileSize fullPath - liftIO . sendResponse . HTTP2Server.responseFile Status.ok200 mempty $ HTTP2Server.FileSpec fullPath 0 size + liftIO . sendResponse . HS.responseFile Status.ok200 mempty $ HS.FileSpec fullPath 0 size Right _ -> respondWith Status.internalServerError500 "The requested file is not complete" Left SEUserNotFound {} -> respondWith Status.notFound404 "User not found" Left SERcvFileNotFound {} -> respondWith Status.notFound404 "File not found" @@ -395,101 +399,106 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s respondJSON = respond . Binary.fromLazyByteString . J.encode respond = respondWith Status.ok200 - respondWith status = liftIO . sendResponse . HTTP2Server.responseBuilder status [] + respondWith status = liftIO . sendResponse . HS.responseBuilder status [] -- * ChatRequest handlers -startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m () -startRemoteCtrl execChatCommand = - chatReadVar remoteCtrlSession >>= \case - Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy - Nothing -> do - size <- asks $ tbqSize . config - remoteOutputQ <- newTBQueueIO size - discovered <- newTVarIO mempty - discoverer <- async $ discoverRemoteCtrls discovered - accepted <- newEmptyTMVarIO - supervisor <- async $ do - remoteCtrlId <- atomically (readTMVar accepted) - withRemoteCtrl remoteCtrlId $ \rc@RemoteCtrl {fingerprint} -> do - source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure - toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False - atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) - chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True - _ <- waitCatch server - chatWriteVar remoteCtrlSession Nothing - toView CRRemoteCtrlStopped - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} - -discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () -discoverRemoteCtrls discovered = Discovery.withListener go +startRemoteCtrl :: ChatMonad m => (ByteString -> m ChatResponse) -> m () +startRemoteCtrl execChatCommand = do + checkNoRemoteCtrlSession + size <- asks $ tbqSize . config + remoteOutputQ <- newTBQueueIO size + discovered <- newTVarIO mempty + discoverer <- async $ discoverRemoteCtrls discovered + accepted <- newEmptyTMVarIO + supervisor <- async $ runSupervisor discovered accepted + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} where - go sock = + runSupervisor discovered accepted = do + remoteCtrlId <- atomically (readTMVar accepted) + rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) + source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure + toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False + atomically $ writeTVar discovered mempty -- flush unused sources + server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) + chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True + _ <- waitCatch server + chatWriteVar remoteCtrlSession Nothing + toView CRRemoteCtrlStopped + +-- TODO the problem with this code was that it wasn't clear where the recursion can happen, +-- by splitting receiving and processing to two functions it becomes clear +discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m () +discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process + where + -- TODO how would it receive more than one fingerprint? + receive sock = Discovery.recvAnnounce sock >>= \case (SockAddrInet _sockPort sockAddr, invite) -> case strDecode invite of - Left _ -> go sock -- ignore malformed datagrams - Right fingerprint -> do - let addr = THIPv4 (hostAddressToTuple sockAddr) - ifM - (atomically $ TM.member fingerprint discovered) - (logDebug $ "Fingerprint announce already knwon: " <> tshow (addr, invite)) - ( do - logInfo $ "New fingerprint announce: " <> tshow (addr, invite) - atomically $ TM.insert fingerprint addr discovered - ) - withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required - Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of - Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required - Just False -> pure () -- skipping a rejected item - Just True -> - chatReadVar remoteCtrlSession >>= \case - Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" - Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically - _nonV4 -> go sock + -- TODO it is probably better to report errors to view here + Left _ -> receive sock + Right fingerprint -> pure (sockAddr, fingerprint) + _nonV4 -> receive sock + process (sockAddr, fingerprint) = do + let addr = THIPv4 (hostAddressToTuple sockAddr) + ifM + (atomically $ TM.member fingerprint discovered) + (logDebug $ "Fingerprint already known: " <> tshow (addr, fingerprint)) + ( do + logInfo $ "New fingerprint announced: " <> tshow (addr, fingerprint) + atomically $ TM.insert fingerprint addr discovered + ) + -- TODO we check fingerprint for duplicate where id doesn't matter - to prevent re-insert - and don't check to prevent duplicate events, + -- so UI now will have to check for duplicates again + withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required + -- TODO Maybe Bool is very confusing - the intent is very unclear here + Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of + Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required + Just False -> pure () -- skipping a rejected item + Just True -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" + Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically -listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] +listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do active <- chatReadVar remoteCtrlSession $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted map (rcInfo active) <$> withStore' getRemoteCtrls where - rcInfo active rc@RemoteCtrl {remoteCtrlId} = - remoteCtrlInfo rc $ active == Just remoteCtrlId + rcInfo activeRcId rc@RemoteCtrl {remoteCtrlId} = + remoteCtrlInfo rc $ activeRcId == Just remoteCtrlId remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive = RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive} -acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () -acceptRemoteCtrl remoteCtrlId = do - withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId True - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> atomically . void $ tryPutTMVar accepted remoteCtrlId -- the remote host can now proceed with connection +acceptRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +acceptRemoteCtrl rcId = do + -- TODO check it exists, check the ID is the same as in session + RemoteCtrlSession {accepted} <- getRemoteCtrlSession + withStore' $ \db -> markRemoteCtrlResolution db rcId True + atomically . void $ tryPutTMVar accepted rcId -- the remote host can now proceed with connection -rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () -rejectRemoteCtrl remoteCtrlId = do - withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId False - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {discoverer, supervisor} -> do - cancel discoverer - cancel supervisor +rejectRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +rejectRemoteCtrl rcId = do + withStore' $ \db -> markRemoteCtrlResolution db rcId False + RemoteCtrlSession {discoverer, supervisor} <- getRemoteCtrlSession + cancel discoverer + cancel supervisor -stopRemoteCtrl :: (ChatMonad m) => m () -stopRemoteCtrl = - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just rcs -> cancelRemoteCtrlSession rcs $ chatWriteVar remoteCtrlSession Nothing +stopRemoteCtrl :: ChatMonad m => m () +stopRemoteCtrl = do + rcs <- getRemoteCtrlSession + cancelRemoteCtrlSession rcs $ chatWriteVar remoteCtrlSession Nothing -cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () +cancelRemoteCtrlSession_ :: MonadUnliftIO m => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () -cancelRemoteCtrlSession :: (MonadUnliftIO m) => RemoteCtrlSession -> m () -> m () +cancelRemoteCtrlSession :: MonadUnliftIO m => RemoteCtrlSession -> m () -> m () cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do cancel discoverer -- may be gone by now case hostServer of @@ -498,17 +507,19 @@ cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} c cancel supervisor -- supervisor is blocked until session progresses cleanup -deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () -deleteRemoteCtrl remoteCtrlId = - chatReadVar remoteCtrlSession >>= \case - Nothing -> withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId - Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy +deleteRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +deleteRemoteCtrl rcId = do + checkNoRemoteCtrlSession + -- TODO check it exists + withStore' (`deleteRemoteCtrlRecord` rcId) -withRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrl -> m a) -> m a -withRemoteCtrl remoteCtrlId action = - withStore' (`getRemoteCtrl` remoteCtrlId) >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEMissing {remoteCtrlId} - Just rc -> action rc +getRemoteCtrlSession :: ChatMonad m => m RemoteCtrlSession +getRemoteCtrlSession = + chatReadVar remoteCtrlSession >>= maybe (throwError $ ChatErrorRemoteCtrl RCEInactive) pure + +checkNoRemoteCtrlSession :: ChatMonad m => m () +checkNoRemoteCtrlSession = + chatReadVar remoteCtrlSession >>= maybe (pure ()) (\_ -> throwError $ ChatErrorRemoteCtrl RCEBusy) utf8String :: [Char] -> ByteString utf8String = encodeUtf8 . T.pack diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 40314b4cb5..01c6d12c6e 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -53,8 +53,8 @@ pattern BROADCAST_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => m () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 finishAction invite credentials = do +announceRevHTTP2 :: StrEncoding a => a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 invite credentials finishAction = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar @@ -77,6 +77,8 @@ runAnnouncer inviteBS = do UDP.send sock inviteBS threadDelay 1000000 +-- TODO what prevents second client from connecting to the same server? +-- Do we need to start multiple TLS servers for different mobile hosts? startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig where diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 9189a27769..a4c2ef85e1 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -4,14 +4,15 @@ module Simplex.Chat.Store.Remote where +import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Store.Shared import Simplex.Chat.Remote.Types -import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Crypto as C insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId @@ -23,9 +24,9 @@ getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = map toRemoteHost <$> DB.query_ db remoteHostQuery -getRemoteHost :: DB.Connection -> RemoteHostId -> IO (Maybe RemoteHost) +getRemoteHost :: DB.Connection -> RemoteHostId -> ExceptT StoreError IO RemoteHost getRemoteHost db remoteHostId = - maybeFirstRow toRemoteHost $ + ExceptT . firstRow toRemoteHost (SERemoteHostNotFound remoteHostId) $ DB.query db (remoteHostQuery <> " WHERE remote_host_id = ?") (Only remoteHostId) remoteHostQuery :: SQL.Query @@ -48,9 +49,9 @@ getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] getRemoteCtrls db = map toRemoteCtrl <$> DB.query_ db remoteCtrlQuery -getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO (Maybe RemoteCtrl) +getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> ExceptT StoreError IO RemoteCtrl getRemoteCtrl db remoteCtrlId = - maybeFirstRow toRemoteCtrl $ + ExceptT . firstRow toRemoteCtrl (SERemoteCtrlNotFound remoteCtrlId) $ DB.query db (remoteCtrlQuery <> " WHERE remote_controller_id = ?") (Only remoteCtrlId) getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index fabe5b9962..5cc1e87d58 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -31,6 +31,7 @@ import Database.SQLite.Simple.QQ (sql) import GHC.Generics (Generic) import Simplex.Chat.Messages import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) @@ -100,6 +101,8 @@ data StoreError | SEHostMemberIdNotFound {groupId :: Int64} | SEContactNotFoundByFileId {fileId :: FileTransferId} | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} + | SERemoteHostNotFound {remoteHostId :: RemoteHostId} + | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} deriving (Show, Exception, Generic) instance FromJSON StoreError where diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 5bc1845803..b739c19882 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -33,7 +33,7 @@ import UnliftIO import UnliftIO.Directory remoteTests :: SpecWith FilePath -remoteTests = describe "Handshake" $ do +remoteTests = fdescribe "Handshake" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test it "connects desktop and mobile" remoteHandshakeTest @@ -70,7 +70,7 @@ announceDiscoverHttp2Test _tmp = do controller <- async $ do traceM " - Controller: starting" bracket - (Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure) + (Discovery.announceRevHTTP2 fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do traceM " - Controller: got client" From 0d1a080a6e299ea34ece5445ac6b1484c6eaf541 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sun, 22 Oct 2023 11:42:19 +0300 Subject: [PATCH 020/174] remote protocol (#3225) * draft remote protocol types and external api * types (it compiles) * add error * move remote controller from http to remote host client protocol * refactor (doesnt compile) * fix compile * Connect remote session * WIP: wire in remote protocol * add commands and events * cleanup * fix desktop shutdown * prepare for testing remote files * Add file IO * update simplexmq to master with http2 to 4.1.4 * use json transcoder * update simplexmq * collapse RemoteHostSession states * fold RemoteHello back into the protocol command move http-command-response-http wrapper to protocol * use sendRemoteCommand with optional attachments use streaming request/response * ditch lazy body streaming * fix formatting * put body builder/processor closer together * wrap handleRemoteCommand around sending files * handle ChatError's too * remove binary, use 32-bit encoding for JSON bodies * enable tests * refactor * refactor request handling * return ChatError * Flatten remote host --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 4 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 4 +- simplex-chat.cabal | 15 +- src/Simplex/Chat.hs | 12 +- src/Simplex/Chat/Controller.hs | 13 +- src/Simplex/Chat/Remote.hs | 515 ++++++++++----------------- src/Simplex/Chat/Remote/Discovery.hs | 61 +++- src/Simplex/Chat/Remote/Protocol.hs | 199 +++++++++++ src/Simplex/Chat/Remote/Types.hs | 70 +++- stack.yaml | 2 +- tests/JSONTests.hs | 2 +- tests/RemoteTests.hs | 328 +++++++++-------- tests/Test.hs | 2 +- tests/ViewTests.hs | 1 - 15 files changed, 693 insertions(+), 537 deletions(-) create mode 100644 src/Simplex/Chat/Remote/Protocol.hs diff --git a/cabal.project b/cabal.project index 9a9a3e25da..aebe18ae91 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + tag: deb3fc73595ceae34902d3402d075e3a531d5221 source-repository-package type: git @@ -19,7 +19,7 @@ source-repository-package source-repository-package type: git location: https://github.com/kazu-yamamoto/http2.git - tag: b5a1b7200cf5bc7044af34ba325284271f6dff25 + tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517 source-repository-package type: git diff --git a/package.yaml b/package.yaml index df2624ac83..23fefc7ea3 100644 --- a/package.yaml +++ b/package.yaml @@ -19,7 +19,6 @@ dependencies: - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 - - binary >= 0.8 && < 0.9 - bytestring == 0.11.* - composition == 1.0.* - constraints >= 0.12 && < 0.14 @@ -36,6 +35,7 @@ dependencies: - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 + - network-transport == 0.5.6 - network-udp >= 0.0 && < 0.1 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 17d650cb09..6d5e1b9e7b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."1ad69cf74f18f25713ce564e1629d2538313b9e0" = "1kil0962pn3ksnxh7dcwcbnkidz95yl31rm4m585ps7wnh6fp0l9"; + "https://github.com/simplex-chat/simplexmq.git"."deb3fc73595ceae34902d3402d075e3a531d5221" = "031zrk32p8ji8hlvk8aj1v99g5zpcsran8qhq36sgi34sy6864z6"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; + "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 512f1427c9..2260f46e49 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -127,6 +127,7 @@ library Simplex.Chat.Protocol Simplex.Chat.Remote Simplex.Chat.Remote.Discovery + Simplex.Chat.Remote.Protocol Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections @@ -160,7 +161,6 @@ library , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -177,6 +177,7 @@ library , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -213,7 +214,6 @@ executable simplex-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -230,6 +230,7 @@ executable simplex-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -267,7 +268,6 @@ executable simplex-bot-advanced , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -284,6 +284,7 @@ executable simplex-bot-advanced , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -323,7 +324,6 @@ executable simplex-broadcast-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -340,6 +340,7 @@ executable simplex-broadcast-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -378,7 +379,6 @@ executable simplex-chat , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -395,6 +395,7 @@ executable simplex-chat , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -437,7 +438,6 @@ executable simplex-directory-service , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -454,6 +454,7 @@ executable simplex-directory-service , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -519,7 +520,6 @@ test-suite simplex-chat-test , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -539,6 +539,7 @@ test-suite simplex-chat-test , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 87a8fd3569..a491561cca 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -109,6 +109,7 @@ import System.Random (randomRIO) import Text.Read (readMaybe) import UnliftIO.Async import UnliftIO.Concurrent (forkFinally, forkIO, mkWeakThreadId, threadDelay) +import qualified UnliftIO.Exception as E import UnliftIO.Directory import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM @@ -389,17 +390,20 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId (s, cmd) + Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId s _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse -execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) +execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd -execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> (ByteString, ChatCommand) -> m ChatResponse -execRemoteCommand u rhId scmd = either (CRChatCmdError u) id <$> runExceptT (getRemoteHostSession rhId >>= (`processRemoteCommand` scmd)) +execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ByteString -> m ChatResponse +execRemoteCommand u rhId s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh s + +handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse +handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError)) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 276bc69c3e..d2179b1a95 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -72,7 +72,6 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Cor import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) @@ -1153,6 +1152,7 @@ data RemoteHostError | RHTimeout -- ^ A discovery or a remote operation has timed out | RHDisconnected {reason :: Text} -- ^ A session disconnected by a host | RHConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues + | RHProtocolError RemoteProtocolError deriving (Show, Exception, Generic) instance FromJSON RemoteHostError where @@ -1175,6 +1175,7 @@ data RemoteCtrlError | RCEHTTP2Error {http2Error :: String} | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove | RCEInvalidResponse {responseError :: String} + | RCEProtocolError {protocolError :: RemoteProtocolError} deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where @@ -1196,16 +1197,6 @@ instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" -data RemoteHostSession - = RemoteHostSessionStarting - { announcer :: Async () - } - | RemoteHostSessionStarted - { -- | Path for local resources to be synchronized with host - storePath :: FilePath, - ctrlClient :: HTTP2Client - } - data RemoteCtrlSession = RemoteCtrlSession { -- | Host (mobile) side of transport to process remote commands and forward notifications discoverer :: Async (), diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 336b5d2cf9..c195b4631d 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -5,7 +5,6 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -20,148 +19,136 @@ import Control.Monad.IO.Class import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) -import Data.Aeson ((.=)) import qualified Data.Aeson as J -import qualified Data.Aeson.Key as JK -import qualified Data.Aeson.KeyMap as JM -import qualified Data.Attoparsec.ByteString.Char8 as A -import qualified Data.Binary.Builder as Binary -import Data.ByteString (ByteString, hPut) +import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U +import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B -import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeUtf8, encodeUtf8) -import qualified Network.HTTP.Types as HTTP -import qualified Network.HTTP.Types.Status as Status -import qualified Network.HTTP2.Client as HC -import qualified Network.HTTP2.Server as HS +import Data.Text.Encoding (encodeUtf8) +import Data.Word (Word32) +import Network.HTTP2.Server (responseStreaming) +import qualified Network.HTTP.Types as N import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller -import Simplex.Chat.Messages (AChatItem (..), CIFile (..), CIFileStatus (..), ChatItem (..), chatNameStr) -import Simplex.Chat.Messages.CIContent (MsgDirection (..), SMsgDirection (..)) import qualified Simplex.Chat.Remote.Discovery as Discovery +import Simplex.Chat.Remote.Protocol import Simplex.Chat.Remote.Types -import Simplex.Chat.Store.Files (getRcvFileTransfer) -import Simplex.Chat.Store.Profiles (getUser) import Simplex.Chat.Store.Remote -import Simplex.Chat.Store.Shared (StoreError (..)) -import Simplex.Chat.Types -import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) -import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, HTTP2Response (..)) -import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 -import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow, ifM, liftEitherError, liftEitherWith, tshow, ($>>=)) -import System.FilePath (isPathSeparator, takeFileName, ()) +import Simplex.Messaging.Transport.HTTP2.File (hSendFile) +import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=)) +import System.FilePath (()) import UnliftIO -import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) + +-- * Desktop side getRemoteHostSession :: ChatMonad m => RemoteHostId -> m RemoteHostSession -getRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe err pure . M.lookup rhId - where - err = throwError $ ChatErrorRemoteHost rhId RHMissing +getRemoteHostSession rhId = withRemoteHostSession rhId $ \_ s -> pure $ Right s -checkNoRemoteHostSession :: ChatMonad m => RemoteHostId -> m () -checkNoRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe (pure ()) err . M.lookup rhId +withRemoteHostSession :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> RemoteHostSession -> STM (Either ChatError a)) -> m a +withRemoteHostSession rhId = withRemoteHostSession_ rhId missing where - err _ = throwError $ ChatErrorRemoteHost rhId RHBusy + missing _ = pure . Left $ ChatErrorRemoteHost rhId RHMissing + +withNoRemoteHostSession :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> STM (Either ChatError a)) -> m a +withNoRemoteHostSession rhId action = withRemoteHostSession_ rhId action busy + where + busy _ _ = pure . Left $ ChatErrorRemoteHost rhId RHBusy + +-- | Atomically process controller state wrt. specific remote host session +withRemoteHostSession_ :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> STM (Either ChatError a)) -> (TM.TMap RemoteHostId RemoteHostSession -> RemoteHostSession -> STM (Either ChatError a)) -> m a +withRemoteHostSession_ rhId missing present = do + sessions <- asks remoteHostSessions + liftIOEither . atomically $ TM.lookup rhId sessions >>= maybe (missing sessions) (present sessions) startRemoteHost :: ChatMonad m => RemoteHostId -> m () startRemoteHost rhId = do - checkNoRemoteHostSession rhId rh <- withStore (`getRemoteHost` rhId) - announcer <- async $ do - finished <- newTVarIO False - http <- start rh finished `onChatError` cleanup finished - run rh finished http - chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarting {announcer} + tasks <- startRemoteHostSession rh + logInfo $ "Remote host session starting for " <> tshow rhId + asyncRegistered tasks $ run rh tasks `catchAny` \err -> do + logError $ "Remote host session startup failed for " <> tshow rhId <> ": " <> tshow err + cancelTasks tasks + chatModifyVar remoteHostSessions $ M.delete rhId + throwError $ fromMaybe (mkChatError err) $ fromException err + -- logInfo $ "Remote host session starting for " <> tshow rhId where - cleanup finished = do - logInfo "Remote host http2 client fininshed" - atomically $ writeTVar finished True - -- TODO why this is not an error? - M.lookup rhId <$> chatReadVar remoteHostSessions >>= \case - Nothing -> logInfo $ "Session already closed for remote host " <> tshow rhId - Just _ -> closeRemoteHostSession rhId >> toView (CRRemoteHostStopped rhId) - start rh@RemoteHost {storePath, caKey, caCert} finished = do - let parent = (C.signatureKeyPair caKey, caCert) - sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" - let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] - u <- askUnliftIO - ctrlClient <- liftHTTP2 $ Discovery.announceRevHTTP2 fingerprint credentials $ unliftIO u (cleanup finished) -- >>= \case - chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarted {storePath, ctrlClient} - chatWriteVar currentRemoteHost $ Just rhId - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- sendHello ctrlClient + run :: ChatMonad m => RemoteHost -> Tasks -> m () + run rh@RemoteHost {storePath} tasks = do + (fingerprint, credentials) <- liftIO $ genSessionCredentials rh + cleanupIO <- toIO $ do + logNote $ "Remote host session stopping for " <> tshow rhId + cancelTasks tasks -- cancel our tasks anyway + chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH + withRemoteHostSession rhId $ \sessions _ -> Right <$> TM.delete rhId sessions + toView (CRRemoteHostStopped rhId) -- only signal "stopped" when the session is unregistered cleanly + -- block until some client is connected or an error happens + logInfo $ "Remote host session connecting for " <> tshow rhId + httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks fingerprint credentials cleanupIO + logInfo $ "Remote host session connected for " <> tshow rhId rcName <- chatReadVar localDeviceName - -- TODO what sets session active? - toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} - pure ctrlClient - run RemoteHost {storePath} finished ctrlClient = do + -- test connection and establish a protocol layer + remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient rcName + -- set up message polling oq <- asks outputQ - let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just rhId,) - -- TODO remove REST - void . async $ pollRemote finished ctrlClient "/recv" $ handleFile >=> toViewRemote + asyncRegistered tasks . forever $ do + liftRH rhId (remoteRecv remoteHostClient 1000000) >>= mapM_ (atomically . writeTBQueue oq . (Nothing,Just rhId,)) + -- update session state + logInfo $ "Remote host session started for " <> tshow rhId + chatModifyVar remoteHostSessions $ M.adjust (\rhs -> rhs {remoteHostClient = Just remoteHostClient}) rhId + chatWriteVar currentRemoteHost $ Just rhId + toView $ CRRemoteHostConnected RemoteHostInfo + { remoteHostId = rhId, + storePath = storePath, + displayName = remoteDeviceName remoteHostClient, + remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName}, + sessionActive = True + } + + genSessionCredentials RemoteHost {caKey, caCert} = do + sessionCreds <- genCredentials (Just parent) (0, 24) "Session" + pure . tlsCredentials $ sessionCreds :| [parent] where - -- TODO move to view / terminal - handleFile = \case - cr@CRRcvFileComplete {user, chatItem = AChatItem c SMDRcv i ci@ChatItem {file = Just ciFile@CIFile {fileStatus = CIFSRcvComplete}}} -> do - maybe cr update <$> handleRcvFileComplete ctrlClient storePath user ciFile - where - update localFile = cr {chatItem = AChatItem c SMDRcv i ci {file = Just localFile}} - cr -> pure cr + parent = (C.signatureKeyPair caKey, caCert) -sendHello :: ChatMonad m => HTTP2Client -> m HTTP2Response -sendHello http = liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing - where - req = HC.requestNoBody "GET" "/" mempty - --- TODO how (on what condition) it would stop polling? --- TODO add JSON translation -pollRemote :: ChatMonad m => TVar Bool -> HTTP2Client -> ByteString -> (ChatResponse -> m ()) -> m () -pollRemote finished http path action = loop `catchChatError` \e -> action (CRChatError Nothing e) >> loop - where - loop = do - -- TODO this will never load full body - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing - json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead -- of - action json - readTVarIO finished >>= (`unless` loop) - req = HC.requestNoBody "GET" path mempty +-- | Atomically check/register session and prepare its task list +startRemoteHostSession :: ChatMonad m => RemoteHost -> m Tasks +startRemoteHostSession RemoteHost {remoteHostId, storePath} = withNoRemoteHostSession remoteHostId $ \sessions -> do + remoteHostTasks <- newTVar [] + TM.insert remoteHostId RemoteHostSession {remoteHostTasks, storePath, remoteHostClient = Nothing} sessions + pure $ Right remoteHostTasks closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () -closeRemoteHostSession remoteHostId = do - session <- getRemoteHostSession remoteHostId - logInfo $ "Closing remote host session for " <> tshow remoteHostId - liftIO $ cancelRemoteHostSession session - chatWriteVar currentRemoteHost Nothing - chatModifyVar remoteHostSessions $ M.delete remoteHostId +closeRemoteHostSession rhId = do + logNote $ "Closing remote host session for " <> tshow rhId + chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH + session <- withRemoteHostSession rhId $ \sessions rhs -> Right rhs <$ TM.delete rhId sessions + cancelRemoteHostSession session cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () -cancelRemoteHostSession = \case - RemoteHostSessionStarting {announcer} -> cancel announcer - RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient +cancelRemoteHostSession RemoteHostSession {remoteHostTasks, remoteHostClient} = do + cancelTasks remoteHostTasks + mapM_ closeRemoteHostClient remoteHostClient createRemoteHost :: ChatMonad m => m RemoteHostInfo createRemoteHost = do - let rhName = "TODO" -- you don't have remote host name here, it will be passed from remote host - ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) rhName + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) "Host" storePath <- liftIO randomStorePath - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath rhName caKey caCert - rcName <- chatReadVar localDeviceName - let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} - pure RemoteHostInfo {remoteHostId, storePath, displayName = rhName, remoteCtrlOOB, sessionActive = False} + let remoteName = "" -- will be passed from remote host in hello + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath remoteName caKey caCert + localName <- chatReadVar localDeviceName + let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = localName} + pure RemoteHostInfo {remoteHostId, storePath, displayName = remoteName, remoteCtrlOOB, sessionActive = False} -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -191,241 +178,111 @@ deleteRemoteHost rhId = do Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) -processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand RemoteHostSessionStarting {} _ = pure $ chatCmdError Nothing "remote command sent before session started" -processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = - uploadFile cmd >>= relayCommand ctrlClient - where - fileCmd cmdPfx cn hostPath = utf8String $ unwords [cmdPfx, chatNameStr cn, hostPath] - uploadFile = \case - SendFile cn ctrlPath -> fileCmd "/file" cn <$> storeRemoteFile ctrlClient ctrlPath - SendImage cn ctrlPath -> fileCmd "/image" cn <$> storeRemoteFile ctrlClient ctrlPath - -- TODO APISendMessage should only be used with host path already, and UI has to upload file first. - -- The problem is that we cannot have different file names in host and controller, because it simply won't be able to show files. - -- So we need to ask the host to store files BEFORE storing them in the app storage and use host names in the command and to store the file locally if it has to be shown, - -- or don't even store it if it's not image/video. - -- The current approach won't work. - -- It also does not account for local file encryption. - -- Also, local file encryption setting should be tracked in the controller, as otherwise host won't be able to decide what to do having received the upload command. - APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do - hostPath <- storeRemoteFile ctrlClient ctrlPath - let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage - -- TODO we shouldn't manipulate JSON like that - pure $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') - _ -> pure s +processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ByteString -> m ChatResponse +processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} s = liftRH remoteHostId $ remoteSend rhc s +processRemoteCommand _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started" -relayCommand :: ChatMonad m => HTTP2Client -> ByteString -> m ChatResponse -relayCommand http s = do - -- TODO ExceptT - let timeout' = Nothing - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- - liftHTTP2 $ HTTP2.sendRequestDirect http req timeout' - -- TODO: large JSONs can overflow into buffered chunks - json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead - case J.fromJSON $ toTaggedJSON json of - J.Error e -> err $ show e - J.Success cr -> pure cr - where - err = pure . CRChatError Nothing . ChatErrorRemoteCtrl . RCEInvalidResponse - toTaggedJSON :: J.Value -> J.Value - toTaggedJSON = id -- owsf2tagged TODO: get from RemoteHost - req = HC.requestBuilder "POST" "/send" mempty (Binary.fromByteString s) +liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a +liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError) --- TODO fileName is just metadata that does not determine the actual file location for UI, or whether it is encrypted or not --- fileSource is the actual file location (with information whether it is locally encrypted) -handleRcvFileComplete :: ChatMonad m => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) -handleRcvFileComplete http storePath remoteUser f@CIFile {fileId, fileName} = - chatReadVar filesFolder >>= \case - Just baseDir -> do - let hostStore = baseDir storePath - createDirectoryIfMissing True hostStore - -- TODO the problem here is that the name may turn out to be different and nothing will work - -- file processing seems to work "accidentally", not "by design" - localPath <- uniqueCombine hostStore fileName - fetchRemoteFile http remoteUser fileId localPath - pure $ Just (f {fileName = localPath} :: CIFile 'MDRcv) - -- TODO below will not work with CLI, it should store file to download folder when not specified - -- It should not load all files when received, instead it should only load files received with /fr commands - Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" +-- * Mobile side --- | Convert swift single-field sum encoding into tagged/discriminator-field -owsf2tagged :: J.Value -> J.Value -owsf2tagged = fst . convert - where - convert val = case val of - J.Object o - | JM.size o == 2 -> - case JM.toList o of - [OwsfTag, o'] -> tagged o' - [o', OwsfTag] -> tagged o' - _ -> props - | otherwise -> props - where - props = (J.Object $ fmap owsf2tagged o, False) - J.Array a -> (J.Array $ fmap owsf2tagged a, False) - _ -> (val, False) - -- `tagged` converts the pair of single-field object encoding to tagged encoding. - -- It sets innerTag returned by `convert` to True to prevent the tag being overwritten. - tagged (k, v) = (J.Object pairs, True) - where - (v', innerTag) = convert v - pairs = case v' of - -- `innerTag` indicates that internal object already has tag, - -- so the current tag cannot be inserted into it. - J.Object o - | innerTag -> pair - | otherwise -> JM.insert TaggedObjectJSONTag tag o - _ -> pair - tag = J.String $ JK.toText k - pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v'] - -pattern OwsfTag :: (JK.Key, J.Value) -pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) - -storeRemoteFile :: ChatMonad m => HTTP2Client -> FilePath -> m FilePath -storeRemoteFile http localFile = do - fileSize <- liftIO $ fromIntegral <$> getFileSize localFile - -- TODO configure timeout - let timeout' = Nothing - r@HTTP2Response {respBody = HTTP2Body {bodyHead}} <- - liftHTTP2 $ HTTP2.sendRequestDirect http (req fileSize) timeout' - responseStatusOK r - -- TODO what if response doesn't fit in the head? - -- it'll be solved when processing moved to POST with Command/Response types - pure $ B.unpack bodyHead - where - -- TODO local file encryption? - uri = "/store?" <> HTTP.renderSimpleQuery False [("file_name", utf8String $ takeFileName localFile)] - req size = HC.requestFile "PUT" uri mempty (HC.FileSpec localFile 0 size) - -liftHTTP2 :: ChatMonad m => IO (Either HTTP2ClientError a) -> m a -liftHTTP2 = liftEitherError $ ChatErrorRemoteCtrl . RCEHTTP2Error . show - -responseStatusOK :: ChatMonad m => HTTP2Response -> m () -responseStatusOK HTTP2Response {response} = do - let s = HC.responseStatus response - unless (s == Just Status.ok200) $ - throwError $ ChatErrorRemoteCtrl $ RCEHTTP2RespStatus $ Status.statusCode <$> s - -fetchRemoteFile :: ChatMonad m => HTTP2Client -> User -> Int64 -> FilePath -> m () -fetchRemoteFile http User {userId = remoteUserId} remoteFileId localPath = do - r@HTTP2Response {respBody} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing - responseStatusOK r - writeBodyToFile localPath respBody - where - req = HC.requestNoBody "GET" path mempty - path = "/fetch?" <> HTTP.renderSimpleQuery False [("user_id", bshow remoteUserId), ("file_id", bshow remoteFileId)] - --- XXX: extract to Transport.HTTP2 ? -writeBodyToFile :: MonadUnliftIO m => FilePath -> HTTP2Body -> m () -writeBodyToFile path HTTP2Body {bodyHead, bodySize, bodyPart} = do - logInfo $ "Receiving " <> tshow bodySize <> " bytes to " <> tshow path - liftIO . withFile path WriteMode $ \h -> do - hPut h bodyHead - mapM_ (hPutBodyChunks h) bodyPart - -hPutBodyChunks :: Handle -> (Int -> IO ByteString) -> IO () -hPutBodyChunks h getChunk = do - chunk <- getChunk defaultHTTP2BufferSize - unless (B.null chunk) $ do - hPut h chunk - hPutBodyChunks h getChunk - --- TODO command/response pattern, remove REST conventions -processControllerRequest :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () -processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, sendResponse} = do - logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) - res <- tryChatError $ case (method, ps) of - ("GET", []) -> getHello - ("POST", ["send"]) -> sendCommand - ("GET", ["recv"]) -> recvMessage - ("PUT", ["store"]) -> storeFile - ("GET", ["fetch"]) -> fetchFile - unexpected -> respondWith Status.badRequest400 $ "unexpected method/path: " <> Binary.putStringUtf8 (show unexpected) - case res of - Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e - Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" - where - method = fromMaybe "" $ HS.requestMethod request - path = fromMaybe "/" $ HS.requestPath request - (ps, query) = HTTP.decodePath path - getHello = respond "OK" - sendCommand = execChatCommand (bodyHead reqBody) >>= respondJSON - recvMessage = - chatReadVar remoteCtrlSession >>= \case - Nothing -> respondWith Status.internalServerError500 "session not active" - Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON - -- TODO liftEither storeFileQuery - storeFile = case storeFileQuery of - Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) - Right fileName -> do - baseDir <- fromMaybe "." <$> chatReadVar filesFolder - localPath <- uniqueCombine baseDir fileName - logDebug $ "Storing controller file to " <> tshow (baseDir, localPath) - writeBodyToFile localPath reqBody - let storeRelative = takeFileName localPath - respond $ Binary.putStringUtf8 storeRelative - where - storeFileQuery = parseField "file_name" $ A.many1 (A.satisfy $ not . isPathSeparator) - -- TODO move to ExceptT monad, catch errors in one place, convert errors to responses - fetchFile = case fetchFileQuery of - Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) - Right (userId, fileId) -> do - logInfo $ "Fetching file " <> tshow fileId <> " from user " <> tshow userId - x <- withStore' $ \db -> runExceptT $ do - user <- getUser db userId - getRcvFileTransfer db user fileId - -- TODO this error handling is very ad-hoc, there is no separation between Chat errors and responses - case x of - Right RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> do - baseDir <- fromMaybe "." <$> chatReadVar filesFolder - let fullPath = baseDir filePath - size <- fromInteger <$> getFileSize fullPath - liftIO . sendResponse . HS.responseFile Status.ok200 mempty $ HS.FileSpec fullPath 0 size - Right _ -> respondWith Status.internalServerError500 "The requested file is not complete" - Left SEUserNotFound {} -> respondWith Status.notFound404 "User not found" - Left SERcvFileNotFound {} -> respondWith Status.notFound404 "File not found" - _ -> respondWith Status.internalServerError500 "Store error" - where - fetchFileQuery = - (,) - <$> parseField "user_id" A.decimal - <*> parseField "file_id" A.decimal - - parseField :: ByteString -> A.Parser a -> Either String a - parseField field p = maybe (Left $ "missing " <> B.unpack field) (A.parseOnly $ p <* A.endOfInput) (join $ lookup field query) - - respondJSON :: (J.ToJSON a) => a -> m () - respondJSON = respond . Binary.fromLazyByteString . J.encode - - respond = respondWith Status.ok200 - respondWith status = liftIO . sendResponse . HS.responseBuilder status [] - --- * ChatRequest handlers - -startRemoteCtrl :: ChatMonad m => (ByteString -> m ChatResponse) -> m () +startRemoteCtrl :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> m () startRemoteCtrl execChatCommand = do - checkNoRemoteCtrlSession + logInfo "Starting remote host" + checkNoRemoteCtrlSession -- tiny race with the final @chatWriteVar@ until the setup finishes and supervisor spawned + discovered <- newTVarIO mempty + discoverer <- async $ discoverRemoteCtrls discovered -- TODO extract to a controller service singleton size <- asks $ tbqSize . config remoteOutputQ <- newTBQueueIO size - discovered <- newTVarIO mempty - discoverer <- async $ discoverRemoteCtrls discovered accepted <- newEmptyTMVarIO - supervisor <- async $ runSupervisor discovered accepted + supervisor <- async $ runHost discovered accepted $ handleRemoteCommand execChatCommand remoteOutputQ chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} + +-- | Track remote host lifecycle in controller session state and signal UI on its progress +runHost :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () +runHost discovered accepted handleHttp = do + remoteCtrlId <- atomically (readTMVar accepted) -- wait for ??? + rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) + source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint + toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False + atomically $ writeTVar discovered mempty -- flush unused sources + server <- async $ Discovery.connectRevHTTP2 source fingerprint handleHttp -- spawn server for remote protocol commands + chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True + _ <- waitCatch server -- wait for the server to finish + chatWriteVar remoteCtrlSession Nothing + toView CRRemoteCtrlStopped + +handleRemoteCommand :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> TBQueue ChatResponse -> HTTP2Request -> m () +handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do + logDebug "handleRemoteCommand" + liftRC (tryRemoteError parseRequest) >>= \case + Right (getNext, rc) -> processCommand getNext rc `catchAny` (reply . RRProtocolError . RPEException . tshow) + Left e -> reply $ RRProtocolError e where - runSupervisor discovered accepted = do - remoteCtrlId <- atomically (readTMVar accepted) - rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) - source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure - toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False - atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) - chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True - _ <- waitCatch server - chatWriteVar remoteCtrlSession Nothing - toView CRRemoteCtrlStopped + parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand) + parseRequest = do + (header, getNext) <- parseHTTP2Body request reqBody + (getNext,) <$> liftEitherWith (RPEInvalidJSON . T.pack) (J.eitherDecodeStrict' header) + processCommand :: GetChunk -> RemoteCommand -> m () + processCommand getNext = \case + RCHello {deviceName = desktopName} -> handleHello desktopName >>= reply + RCSend {command} -> handleSend execChatCommand command >>= reply + RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply + RCStoreFile {fileSize, encrypt} -> handleStoreFile fileSize encrypt getNext >>= reply + RCGetFile {filePath} -> handleGetFile filePath replyWith + reply :: RemoteResponse -> m () + reply = (`replyWith` \_ -> pure ()) + replyWith :: Respond m + replyWith rr attach = + liftIO . sendResponse . responseStreaming N.status200 [] $ \send flush -> do + send $ sizePrefixedEncode rr + attach send + flush + +type GetChunk = Int -> IO ByteString + +type SendChunk = Builder -> IO () + +type Respond m = RemoteResponse -> (SendChunk -> IO ()) -> m () + +liftRC :: ChatMonad m => ExceptT RemoteProtocolError IO a -> m a +liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) + +tryRemoteError :: ExceptT RemoteProtocolError IO a -> ExceptT RemoteProtocolError IO (Either RemoteProtocolError a) +tryRemoteError = tryAllErrors (RPEException . tshow) +{-# INLINE tryRemoteError #-} + +handleHello :: ChatMonad m => Text -> m RemoteResponse +handleHello desktopName = do + logInfo $ "Hello from " <> tshow desktopName + mobileName <- chatReadVar localDeviceName + pure RRHello {encoding = localEncoding, deviceName = mobileName} + +handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse +handleSend execChatCommand command = do + logDebug $ "Send: " <> tshow command + -- execChatCommand checks for remote-allowed commands + -- convert errors thrown in ChatMonad into error responses to prevent aborting the protocol wrapper + RRChatResponse <$> execChatCommand (encodeUtf8 command) `catchError` (pure . CRChatError Nothing) + +handleRecv :: MonadUnliftIO m => Int -> TBQueue ChatResponse -> m RemoteResponse +handleRecv time events = do + logDebug $ "Recv: " <> tshow time + RRChatEvent <$> (timeout time . atomically $ readTBQueue events) + +handleStoreFile :: ChatMonad m => Word32 -> Maybe Bool -> GetChunk -> m RemoteResponse +handleStoreFile _fileSize _encrypt _getNext = error "TODO" <$ logError "TODO: handleStoreFile" + +handleGetFile :: ChatMonad m => FilePath -> Respond m -> m () +handleGetFile path reply = do + logDebug $ "GetFile: " <> tshow path + withFile path ReadMode $ \h -> do + fileSize' <- hFileSize h + when (fileSize' > toInteger (maxBound :: Word32)) $ throwIO RPEFileTooLarge + let fileSize = fromInteger fileSize' + reply RRFile {fileSize} $ \send -> hSendFile h send fileSize -- TODO the problem with this code was that it wasn't clear where the recursion can happen, -- by splitting receiving and processing to two functions it becomes clear diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 01c6d12c6e..5630c540da 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -1,4 +1,5 @@ {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} @@ -20,6 +21,7 @@ module Simplex.Chat.Remote.Discovery ) where +import Control.Logger.Simple import Control.Monad import Data.ByteString (ByteString) import Data.Default (def) @@ -27,16 +29,17 @@ import Data.String (IsString) import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP +import Simplex.Chat.Remote.Types (Tasks, registerAsync) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, connTimeout, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) -import Simplex.Messaging.Util (whenM) +import Simplex.Messaging.Util (ifM, tshow, whenM) import UnliftIO import UnliftIO.Concurrent @@ -53,18 +56,33 @@ pattern BROADCAST_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: StrEncoding a => a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 invite credentials finishAction = do +announceRevHTTP2 :: StrEncoding a => Tasks -> a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 tasks invite credentials finishAction = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar - announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ runAnnouncer (strEncode invite) - tlsServer <- startTLSServer started credentials $ \tls -> cancel announcer >> runHTTP2Client finished httpClient tls - _ <- forkIO $ do - readMVar finished + _ <- forkIO $ readMVar finished >> finishAction -- attach external cleanup action to session lock + announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ do + logInfo $ "Starting announcer for " <> tshow (strEncode invite) + runAnnouncer (strEncode invite) + tasks `registerAsync` announcer + tlsServer <- startTLSServer started credentials $ \tls -> do + logInfo $ "Incoming connection for " <> tshow (strEncode invite) cancel announcer - cancel tlsServer - finishAction + runHTTP2Client finished httpClient tls `catchAny` (logError . tshow) + logInfo $ "Client finished for " <> tshow (strEncode invite) + -- BUG: this should be handled in HTTP2Client wrapper + _ <- forkIO $ do + waitCatch tlsServer >>= \case + Left err | fromException err == Just AsyncCancelled -> logDebug "tlsServer cancelled" + Left err -> do + logError $ "tlsServer failed to start: " <> tshow err + void $ tryPutMVar httpClient $ Left HCNetworkError + void . atomically $ tryPutTMVar started False + Right () -> pure () + void $ tryPutMVar finished () + tasks `registerAsync` tlsServer + logInfo $ "Waiting for client for " <> tshow (strEncode invite) readMVar httpClient -- | Broadcast invite with link-local datagrams @@ -77,8 +95,7 @@ runAnnouncer inviteBS = do UDP.send sock inviteBS threadDelay 1000000 --- TODO what prevents second client from connecting to the same server? --- Do we need to start multiple TLS servers for different mobile hosts? +-- XXX: Do we need to start multiple TLS servers for different mobile hosts? startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig where @@ -92,11 +109,17 @@ startTLSServer started credentials = async . liftIO . runTransportServer started -- | Attach HTTP2 client and hold the TLS until the attached client finishes. runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () -runHTTP2Client finishedVar clientVar tls = do - attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar - readMVar finishedVar +runHTTP2Client finishedVar clientVar tls = + ifM (isEmptyMVar clientVar) + attachClient + (logError "HTTP2 session already started on this listener") where - config = defaultHTTP2ClientConfig { connTimeout = 86400000000 } + attachClient = do + client <- attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls + putMVar clientVar client + readMVar finishedVar + -- TODO connection timeout + config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a withListener = bracket openListener (liftIO . UDP.stop) @@ -122,5 +145,9 @@ attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TL attachHTTP2Server processRequest tls = do withRunInIO $ \unlift -> runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r defaultHTTP2BufferSize + reqBody <- getHTTP2Body r doNotPrefetchHead unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + +-- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks +doNotPrefetchHead :: Int +doNotPrefetchHead = 0 diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs new file mode 100644 index 0000000000..65a851f718 --- /dev/null +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -0,0 +1,199 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Remote.Protocol where + +import Control.Logger.Simple +import Control.Monad +import Control.Monad.Except +import Data.Aeson ((.=)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Key as JK +import qualified Data.Aeson.KeyMap as JM +import Data.Aeson.TH (deriveJSON) +import qualified Data.Aeson.Types as JT +import Data.ByteString (ByteString) +import Data.ByteString.Builder (Builder, word32BE, lazyByteString) +import qualified Data.ByteString.Lazy as BL +import Data.String (fromString) +import Data.Text (Text) +import Data.Text.Encoding (decodeUtf8) +import Data.Word (Word32) +import qualified Network.HTTP.Types as N +import qualified Network.HTTP2.Client as H +import Network.Transport.Internal (decodeWord32) +import Simplex.Chat.Controller (ChatResponse) +import Simplex.Chat.Remote.Types +import Simplex.Messaging.Crypto.File (CryptoFile) +import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) +import Simplex.Messaging.Transport.Buffer (getBuffered) +import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) +import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile, hSendFile) +import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow, whenM) +import System.FilePath (()) +import UnliftIO +import UnliftIO.Directory (doesFileExist, getFileSize) + +data RemoteCommand + = RCHello {deviceName :: Text} + | RCSend {command :: Text} -- TODO maybe ChatCommand here? + | RCRecv {wait :: Int} -- this wait should be less than HTTP timeout + | -- local file encryption is determined by the host, but can be overridden for videos + RCStoreFile {fileSize :: Word32, encrypt :: Maybe Bool} -- requires attachment + | RCGetFile {filePath :: FilePath} + deriving (Show) + +data RemoteResponse + = RRHello {encoding :: PlatformEncoding, deviceName :: Text} + | RRChatResponse {chatResponse :: ChatResponse} + | RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout + | RRFileStored {fileSource :: CryptoFile} + | RRFile {fileSize :: Word32} -- provides attachment + | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side + deriving (Show) + +-- Force platform-independent encoding as the types aren't UI-visible +$(deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand) +$(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) + +-- * Client side / desktop + +createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient +createRemoteHostClient httpClient desktopName = do + logInfo "Sending initial hello" + (_getNext, rr) <- sendRemoteCommand httpClient localEncoding Nothing RCHello {deviceName = desktopName} + case rr of + rrh@RRHello {encoding, deviceName = mobileName} -> do + logInfo $ "Got initial hello: " <> tshow rrh + when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding + pure RemoteHostClient {remoteEncoding = encoding, remoteDeviceName = mobileName, httpClient} + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () +closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient + +-- ** Commands + +remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend RemoteHostClient {httpClient, remoteEncoding} cmd = do + (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCSend {command = decodeUtf8 cmd} + case rr of + RRChatResponse cr -> pure cr + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv RemoteHostClient {httpClient, remoteEncoding} ms = do + (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCRecv {wait=ms} + case rr of + RRChatEvent cr_ -> pure cr_ + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +remoteStoreFile :: RemoteHostClient -> FilePath -> Maybe Bool -> ExceptT RemoteProtocolError IO CryptoFile +remoteStoreFile RemoteHostClient {httpClient, remoteEncoding} localPath encrypt = do + (_getNext, rr) <- withFile localPath ReadMode $ \h -> do + fileSize' <- hFileSize h + when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileTooLarge + let fileSize = fromInteger fileSize' + sendRemoteCommand httpClient remoteEncoding (Just (h, fileSize)) RCStoreFile {encrypt, fileSize} + case rr of + RRFileStored {fileSource} -> pure fileSource + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +-- TODO this should work differently for CLI and UI clients +-- CLI - potentially, create new unique names and report them as created +-- UI - always use the same names and report error if file already exists +-- alternatively, CLI should also use a fixed folder for remote session +-- Possibly, path in the database should be optional and CLI commands should allow configuring it per session or use temp or download folder +remoteGetFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath +remoteGetFile RemoteHostClient {httpClient, remoteEncoding} baseDir filePath = do + (getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCGetFile {filePath} + expectedSize <- case rr of + RRFile {fileSize} -> pure fileSize + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + whenM (liftIO $ doesFileExist localFile) $ throwError RPEStoredFileExists + rc <- liftIO $ withFile localFile WriteMode $ \h -> hReceiveFile getNext h expectedSize + when (rc /= 0) $ throwError RPEInvalidSize + whenM ((== expectedSize) . fromIntegral <$> getFileSize localFile) $ throwError RPEInvalidSize + pure localFile + where + localFile = baseDir filePath + +sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) +sendRemoteCommand http remoteEncoding attachment_ rc = do + HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect http httpRequest Nothing + (header, getNext) <- parseHTTP2Body response respBody + rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecodeStrict header >>= JT.parseEither J.parseJSON . convertJSON remoteEncoding localEncoding + pure (getNext, rr) + where + httpRequest = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do + send $ sizePrefixedEncode rc + case attachment_ of + Nothing -> pure () + Just (h, sz) -> hSendFile h send sz + flush + +-- * Transport-level wrappers + +convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value +convertJSON _remote@PEKotlin _local@PEKotlin = id +convertJSON PESwift PESwift = id +convertJSON PESwift PEKotlin = owsf2tagged +convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by createRemoteHostClient + +-- | Convert swift single-field sum encoding into tagged/discriminator-field +owsf2tagged :: J.Value -> J.Value +owsf2tagged = fst . convert + where + convert val = case val of + J.Object o + | JM.size o == 2 -> + case JM.toList o of + [OwsfTag, o'] -> tagged o' + [o', OwsfTag] -> tagged o' + _ -> props + | otherwise -> props + where + props = (J.Object $ fmap owsf2tagged o, False) + J.Array a -> (J.Array $ fmap owsf2tagged a, False) + _ -> (val, False) + -- `tagged` converts the pair of single-field object encoding to tagged encoding. + -- It sets innerTag returned by `convert` to True to prevent the tag being overwritten. + tagged (k, v) = (J.Object pairs, True) + where + (v', innerTag) = convert v + pairs = case v' of + -- `innerTag` indicates that internal object already has tag, + -- so the current tag cannot be inserted into it. + J.Object o + | innerTag -> pair + | otherwise -> JM.insert TaggedObjectJSONTag tag o + _ -> pair + tag = J.String $ JK.toText k + pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v'] + +pattern OwsfTag :: (JK.Key, J.Value) +pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) + +-- | Convert a command or a response into 'Builder'. +sizePrefixedEncode :: J.ToJSON a => a -> Builder +sizePrefixedEncode value = word32BE (fromIntegral $ BL.length json) <> lazyByteString json + where + json = J.encode value + +-- | Parse HTTP request or response to a size-prefixed chunk and a function to read more. +parseHTTP2Body :: HTTP2BodyChunk a => a -> HTTP2Body -> ExceptT RemoteProtocolError IO (ByteString, Int -> IO ByteString) +parseHTTP2Body hr HTTP2Body {bodyBuffer} = do + rSize <- liftIO $ decodeWord32 <$> getNext 4 + when (rSize > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize + r <- liftIO $ getNext $ fromIntegral rSize + pure (r, getNext) + where + getNext sz = getBuffered bodyBuffer sz Nothing $ getBodyChunk hr diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 67fe7c6ffa..de54813a4d 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -5,10 +6,39 @@ module Simplex.Chat.Remote.Types where +import Control.Exception import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) +import UnliftIO + +data RemoteHostClient = RemoteHostClient + { remoteEncoding :: PlatformEncoding, + remoteDeviceName :: Text, + httpClient :: HTTP2Client + } + +data RemoteHostSession = RemoteHostSession + { remoteHostTasks :: Tasks, + remoteHostClient :: Maybe RemoteHostClient, + storePath :: FilePath + } + +data RemoteProtocolError + = RPEInvalidSize -- ^ size prefix is malformed + | RPEInvalidJSON {invalidJSON :: Text} -- ^ failed to parse RemoteCommand or RemoteResponse + | RPEIncompatibleEncoding + | RPEUnexpectedFile + | RPENoFile + | RPEFileTooLarge + | RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent + | RPEStoredFileExists -- ^ A file already exists in the destination position + | RPEHTTP2 {http2Error :: Text} + | RPEException {someException :: Text} + deriving (Show, Exception) type RemoteHostId = Int64 @@ -30,8 +60,6 @@ data RemoteCtrlOOB = RemoteCtrlOOB } deriving (Show) -$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) - data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, storePath :: FilePath, @@ -41,8 +69,6 @@ data RemoteHostInfo = RemoteHostInfo } deriving (Show) -$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) - type RemoteCtrlId = Int64 data RemoteCtrl = RemoteCtrl @@ -53,8 +79,6 @@ data RemoteCtrl = RemoteCtrl } deriving (Show) -$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) - data RemoteCtrlInfo = RemoteCtrlInfo { remoteCtrlId :: RemoteCtrlId, displayName :: Text, @@ -64,4 +88,38 @@ data RemoteCtrlInfo = RemoteCtrlInfo } deriving (Show) +-- TODO: put into a proper place +data PlatformEncoding + = PESwift + | PEKotlin + deriving (Show, Eq) + +localEncoding :: PlatformEncoding +#if defined(darwin_HOST_OS) && defined(swiftJSON) +localEncoding = PESwift +#else +localEncoding = PEKotlin +#endif + +type Tasks = TVar [Async ()] + +asyncRegistered :: MonadUnliftIO m => Tasks -> m () -> m () +asyncRegistered tasks action = async action >>= registerAsync tasks + +registerAsync :: MonadIO m => Tasks -> Async () -> m () +registerAsync tasks = atomically . modifyTVar tasks . (:) + +cancelTasks :: (MonadIO m) => Tasks -> m () +cancelTasks tasks = readTVarIO tasks >>= mapM_ cancel + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) + +$(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) + +$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) + +$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) + +$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) + $(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrlInfo) diff --git a/stack.yaml b/stack.yaml index 6e047f7e6c..49aae2639a 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + commit: deb3fc73595ceae34902d3402d075e3a531d5221 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index a250cdfcf6..188fe27597 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -9,7 +9,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import MobileTests -import Simplex.Chat.Remote (owsf2tagged) +import Simplex.Chat.Remote.Protocol (owsf2tagged) import Simplex.Messaging.Parsers import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index b739c19882..452f9ca21d 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -8,17 +7,18 @@ module RemoteTests where import ChatClient import ChatTests.Utils +import Control.Logger.Simple import Control.Monad import qualified Data.ByteString as B import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Debug.Trace import Network.HTTP.Types (ok200) import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Simplex.Chat.Controller as Controller +import Simplex.Chat.Remote.Types import qualified Simplex.Chat.Remote.Discovery as Discovery import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -27,17 +27,21 @@ import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import Simplex.Messaging.Util import System.FilePath (makeRelative, ()) import Test.Hspec import UnliftIO +import UnliftIO.Concurrent import UnliftIO.Directory remoteTests :: SpecWith FilePath -remoteTests = fdescribe "Handshake" $ do +remoteTests = describe "Remote" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - it "connects desktop and mobile" remoteHandshakeTest - it "send messages via remote desktop" remoteCommandTest + it "performs protocol handshake" remoteHandshakeTest + it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check + it "sends messages" remoteMessageTest + xit "sends files" remoteFileTest -- * Low-level TLS with ephemeral credentials @@ -51,14 +55,14 @@ genCredentialsTest _tmp = do Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler where serverHandler serverTls = do - traceM " - Sending from server" + logNote "Sending from server" Transport.putLn serverTls "hi client" - traceM " - Reading from server" + logNote "Reading from server" Transport.getLn serverTls `shouldReturn` "hi server" clientHandler clientTls = do - traceM " - Sending from client" + logNote "Sending from client" Transport.putLn clientTls "hi server" - traceM " - Reading from client" + logNote "Reading from client" Transport.getLn clientTls `shouldReturn` "hi client" -- * UDP discovery and rever HTTP2 @@ -66,34 +70,37 @@ genCredentialsTest _tmp = do announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do (fingerprint, credentials) <- genTestCredentials + tasks <- newTVarIO [] finished <- newEmptyMVar controller <- async $ do - traceM " - Controller: starting" + logNote "Controller: starting" bracket - (Discovery.announceRevHTTP2 fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) + (Discovery.announceRevHTTP2 tasks fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do - traceM " - Controller: got client" + logNote "Controller: got client" sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case Left err -> do - traceM " - Controller: got error" + logNote "Controller: got error" fail $ show err Right HTTP2Response {} -> - traceM " - Controller: got response" + logNote "Controller: got response" ) host <- async $ Discovery.withListener $ \sock -> do (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock strDecode invite `shouldBe` Right fingerprint - traceM " - Host: connecting" + logNote "Host: connecting" server <- async $ Discovery.connectTLSClient (THIPv4 $ N.hostAddressToTuple addr) fingerprint $ \tls -> do - traceM " - Host: got tls" + logNote "Host: got tls" flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do - traceM " - Host: got request" + logNote "Host: got request" sendResponse $ S.responseNoBody ok200 [] - traceM " - Host: sent response" + logNote "Host: sent response" takeMVar finished `finally` cancel server - traceM " - Host: finished" - (waitBoth host controller `shouldReturn` ((), ())) `onException` (cancel host >> cancel controller) + logNote "Host: finished" + tasks `registerAsync` controller + tasks `registerAsync` host + (waitBoth host controller `shouldReturn` ((), ())) `finally` cancelTasks tasks -- * Chat commands @@ -101,62 +108,59 @@ remoteHandshakeTest :: (HasCallStack) => FilePath -> IO () remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" - desktop ##> "/create remote host" - desktop <## "remote host 1 created" - desktop <## "connection code:" - fingerprint <- getTermLine desktop + + startRemote mobile desktop + + logNote "Session active" desktop ##> "/list remote hosts" desktop <## "Remote hosts:" - desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet - desktop ##> "/start remote host 1" - desktop <## "ok" - - mobile ##> "/start remote ctrl" - mobile <## "ok" - mobile <## "remote controller announced" - mobile <## "connection code:" - fingerprint' <- getTermLine mobile - fingerprint' `shouldBe` fingerprint - mobile ##> "/list remote ctrls" - mobile <## "No remote controllers" - mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") - mobile <## "remote controller 1 registered" - mobile ##> "/list remote ctrls" - mobile <## "Remote controllers:" - mobile <## "1. My desktop" - mobile ##> "/accept remote ctrl 1" - mobile <## "ok" -- alternative scenario: accepted before controller start - mobile <## "remote controller 1 connecting to My desktop" - mobile <## "remote controller 1 connected, My desktop" - - traceM " - Session active" - desktop ##> "/list remote hosts" - desktop <## "Remote hosts:" - desktop <## "1. TODO (active)" + desktop <## "1. (active)" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" mobile <## "1. My desktop (active)" - traceM " - Shutting desktop" - desktop ##> "/stop remote host 1" - desktop <## "ok" + stopMobile mobile desktop `catchAny` (logError . tshow) + -- TODO: add a case for 'stopDesktop' + desktop ##> "/delete remote host 1" desktop <## "ok" desktop ##> "/list remote hosts" desktop <## "No remote hosts" - traceM " - Shutting mobile" - mobile ##> "/stop remote ctrl" - mobile <## "ok" - mobile <## "remote controller stopped" mobile ##> "/delete remote ctrl 1" mobile <## "ok" mobile ##> "/list remote ctrls" mobile <## "No remote controllers" -remoteCommandTest :: (HasCallStack) => FilePath -> IO () -remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do +remoteMessageTest :: (HasCallStack) => FilePath -> IO () +remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + startRemote mobile desktop + contactBob desktop bob + + logNote "sending messages" + desktop #> "@bob hello there 🙂" + bob <# "alice> hello there 🙂" + bob #> "@alice hi" + desktop <# "bob> hi" + + logNote "post-remote checks" + stopMobile mobile desktop + + mobile ##> "/contacts" + mobile <## "bob (Bob)" + + bob ##> "/contacts" + bob <## "alice (Alice)" + + desktop ##> "/contacts" + -- empty contact list on desktop-local + + threadDelay 1000000 + logNote "done" + +remoteFileTest :: (HasCallStack) => FilePath -> IO () +remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" @@ -167,6 +171,89 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob bob ##> ("/_files_folder " <> bobFiles) bob <## "ok" + startRemote mobile desktop + contactBob desktop bob + + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopStore <- case M.lookup 1 rhs of + Just RemoteHostSession {storePath} -> pure storePath + _ -> fail "Host session 1 should be started" + + doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False + doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False + mobileName <- userName mobile + + bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" + bob #> ("/f @" <> mobileName <> " " <> bobsFile) + bob <## "use /fc 1 to cancel sending" + + desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" + desktop <## "use /fr 1 [/ | ] to receive it" + desktop ##> "/fr 1" + concurrentlyN_ + [ do + bob <## "started sending file 1 (test.pdf) to alice" + bob <## "completed sending file 1 (test.pdf) to alice", + do + desktop <## "saving file 1 from bob to test.pdf" + desktop <## "started receiving file 1 (test.pdf) from bob" + ] + let desktopReceived = desktopFiles desktopStore "test.pdf" + -- desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") + desktop <## "completed receiving file 1 (test.pdf) from bob" + bobsFileSize <- getFileSize bobsFile + -- getFileSize desktopReceived `shouldReturn` bobsFileSize + bobsFileBytes <- B.readFile bobsFile + -- B.readFile desktopReceived `shouldReturn` bobsFileBytes + + -- test file transit on mobile + mobile ##> "/fs 1" + mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" + getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize + B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes + + logNote "file received" + + desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f + logNote $ "sending " <> tshow desktopFile + doesFileExist (bobFiles "logo.jpg") `shouldReturn` False + doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False + desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" + desktop <# "@bob hi, sending a file" + desktop <# "/f @bob logo.jpg" + desktop <## "use /fc 2 to cancel sending" + + bob <# "alice> hi, sending a file" + bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> "/fr 2" + concurrentlyN_ + [ do + bob <## "saving file 2 from alice to logo.jpg" + bob <## "started receiving file 2 (logo.jpg) from alice" + bob <## "completed receiving file 2 (logo.jpg) from alice" + bob ##> "/fs 2" + bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg", + do + desktop <## "started sending file 2 (logo.jpg) to bob" + desktop <## "completed sending file 2 (logo.jpg) to bob" + ] + desktopFileSize <- getFileSize desktopFile + getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize + getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize + + desktopFileBytes <- B.readFile desktopFile + B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes + B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes + + logNote "file sent" + + stopMobile mobile desktop + +-- * Utils + +startRemote :: TestCC -> TestCC -> IO () +startRemote mobile desktop = do desktop ##> "/create remote host" desktop <## "remote host 1 created" desktop <## "connection code:" @@ -189,7 +276,9 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob mobile <## "remote controller 1 connected, My desktop" desktop <## "remote host 1 connected" - traceM " - exchanging contacts" +contactBob :: TestCC -> TestCC -> IO () +contactBob desktop bob = do + logNote "exchanging contacts" bob ##> "/c" inv' <- getInvitation bob desktop ##> ("/c " <> inv') @@ -198,102 +287,33 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob (desktop <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - traceM " - sending messages" - desktop #> "@bob hello there 🙂" - bob <# "alice> hello there 🙂" - bob #> "@alice hi" - desktop <# "bob> hi" - - withXFTPServer $ do - rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) - desktopStore <- case M.lookup 1 rhs of - Just Controller.RemoteHostSessionStarted {storePath} -> pure storePath - _ -> fail "Host session 1 should be started" - - doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False - doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False - mobileName <- userName mobile - - bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" - bob #> ("/f @" <> mobileName <> " " <> bobsFile) - bob <## "use /fc 1 to cancel sending" - - desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" - desktop <## "use /fr 1 [/ | ] to receive it" - desktop ##> "/fr 1" - concurrently_ - do - bob <## "started sending file 1 (test.pdf) to alice" - bob <## "completed sending file 1 (test.pdf) to alice" - - do - desktop <## "saving file 1 from bob to test.pdf" - desktop <## "started receiving file 1 (test.pdf) from bob" - - let desktopReceived = desktopFiles desktopStore "test.pdf" - desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") - bobsFileSize <- getFileSize bobsFile - getFileSize desktopReceived `shouldReturn` bobsFileSize - bobsFileBytes <- B.readFile bobsFile - B.readFile desktopReceived `shouldReturn` bobsFileBytes - - -- test file transit on mobile - mobile ##> "/fs 1" - mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" - getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize - B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes - - traceM " - file received" - - desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f - traceM $ " - sending " <> show desktopFile - doesFileExist (bobFiles "logo.jpg") `shouldReturn` False - doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False - desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" - desktop <# "@bob hi, sending a file" - desktop <# "/f @bob logo.jpg" - desktop <## "use /fc 2 to cancel sending" - - bob <# "alice> hi, sending a file" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "use /fr 2 [/ | ] to receive it" - bob ##> "/fr 2" - concurrently_ - do - bob <## "saving file 2 from alice to logo.jpg" - bob <## "started receiving file 2 (logo.jpg) from alice" - bob <## "completed receiving file 2 (logo.jpg) from alice" - bob ##> "/fs 2" - bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg" - do - desktop <## "started sending file 2 (logo.jpg) to bob" - desktop <## "completed sending file 2 (logo.jpg) to bob" - desktopFileSize <- getFileSize desktopFile - getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize - getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize - - desktopFileBytes <- B.readFile desktopFile - B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes - B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes - - traceM " - file sent" - - traceM " - post-remote checks" - mobile ##> "/stop remote ctrl" - mobile <## "ok" - concurrently_ - (mobile <## "remote controller stopped") - (desktop <## "remote host 1 stopped") - - mobile ##> "/contacts" - mobile <## "bob (Bob)" - - traceM " - done" - --- * Utils - genTestCredentials :: IO (C.KeyHash, TLS.Credentials) genTestCredentials = do caCreds <- liftIO $ genCredentials Nothing (0, 24) "CA" sessionCreds <- liftIO $ genCredentials (Just caCreds) (0, 24) "Session" pure . tlsCredentials $ sessionCreds :| [caCreds] + +stopDesktop :: HasCallStack => TestCC -> TestCC -> IO () +stopDesktop mobile desktop = do + logWarn "stopping via desktop" + desktop ##> "/stop remote host 1" + -- desktop <## "ok" + concurrently_ + (desktop <## "remote host 1 stopped") + (eventually 3 $ mobile <## "remote controller stopped") + +stopMobile :: HasCallStack => TestCC -> TestCC -> IO () +stopMobile mobile desktop = do + logWarn "stopping via mobile" + mobile ##> "/stop remote ctrl" + mobile <## "ok" + concurrently_ + (mobile <## "remote controller stopped") + (eventually 3 $ desktop <## "remote host 1 stopped") + +-- | Run action with extended timeout +eventually :: Int -> IO a -> IO a +eventually retries action = tryAny action >>= \case -- TODO: only catch timeouts + Left err | retries == 0 -> throwIO err + Left _ -> eventually (retries - 1) action + Right r -> pure r diff --git a/tests/Test.hs b/tests/Test.hs index 071ff3791e..568f9688d0 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -19,7 +19,7 @@ import WebRTCTests main :: IO () main = do - setLogLevel LogError -- LogDebug + setLogLevel LogError withGlobalLogging logCfg . hspec $ do describe "Schema dump" schemaDumpTest describe "SimpleX chat markdown" markdownTests diff --git a/tests/ViewTests.hs b/tests/ViewTests.hs index 7c7a2f0e03..085a56af44 100644 --- a/tests/ViewTests.hs +++ b/tests/ViewTests.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE BlockArguments #-} {-# LANGUAGE OverloadedStrings #-} module ViewTests where From e1bd6a93af3bc4b99074d2eef771ae2dbed2f43d Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 23 Oct 2023 15:44:04 +0300 Subject: [PATCH 021/174] use multicast address for announce (#3241) * use multicast address for announce * Add explicit multicast group membership * join multicast group on a correct side --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- simplex-chat.cabal | 1 + src/Simplex/Chat/Remote/Discovery.hs | 43 ++++++++++++++++++--------- src/Simplex/Chat/Remote/Multicast.hsc | 43 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 src/Simplex/Chat/Remote/Multicast.hsc diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 86e97eabff..f061f8ac86 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -128,6 +128,7 @@ library Simplex.Chat.Protocol Simplex.Chat.Remote Simplex.Chat.Remote.Discovery + Simplex.Chat.Remote.Multicast Simplex.Chat.Remote.Protocol Simplex.Chat.Remote.Types Simplex.Chat.Store diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 5630c540da..babc65e6a8 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -29,6 +29,7 @@ import Data.String (IsString) import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP +import Simplex.Chat.Remote.Multicast (setMembership) import Simplex.Chat.Remote.Types (Tasks, registerAsync) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) @@ -43,15 +44,15 @@ import Simplex.Messaging.Util (ifM, tshow, whenM) import UnliftIO import UnliftIO.Concurrent --- | Link-local broadcast address. -pattern BROADCAST_ADDR_V4 :: (IsString a, Eq a) => a -pattern BROADCAST_ADDR_V4 = "0.0.0.0" +-- | mDNS multicast group +pattern MULTICAST_ADDR_V4 :: (IsString a, Eq a) => a +pattern MULTICAST_ADDR_V4 = "224.0.0.251" pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a pattern ANY_ADDR_V4 = "0.0.0.0" -pattern BROADCAST_PORT :: (IsString a, Eq a) => a -pattern BROADCAST_PORT = "5226" +pattern DISCOVERY_PORT :: (IsString a, Eq a) => a +pattern DISCOVERY_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- @@ -88,16 +89,17 @@ announceRevHTTP2 tasks invite credentials finishAction = do -- | Broadcast invite with link-local datagrams runAnnouncer :: ByteString -> IO () runAnnouncer inviteBS = do - bracket (UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False) UDP.close $ \sock -> do - N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - N.setSocketOption (UDP.udpSocket sock) N.ReuseAddr 1 + bracket (UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) UDP.close $ \sock -> do + let raw = UDP.udpSocket sock + N.setSocketOption raw N.Broadcast 1 + N.setSocketOption raw N.ReuseAddr 1 forever $ do UDP.send sock inviteBS threadDelay 1000000 -- XXX: Do we need to start multiple TLS servers for different mobile hosts? startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) -startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig +startTLSServer started credentials = async . liftIO . runTransportServer started DISCOVERY_PORT serverParams defaultTransportServerConfig where serverParams = def @@ -115,21 +117,34 @@ runHTTP2Client finishedVar clientVar tls = (logError "HTTP2 session already started on this listener") where attachClient = do - client <- attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls + client <- attachHTTP2Client config ANY_ADDR_V4 DISCOVERY_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls putMVar clientVar client readMVar finishedVar -- TODO connection timeout config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a -withListener = bracket openListener (liftIO . UDP.stop) +withListener = bracket openListener closeListener openListener :: (MonadIO m) => m UDP.ListenSocket openListener = liftIO $ do - sock <- UDP.serverSocket (ANY_ADDR_V4, read BROADCAST_PORT) - N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 + sock <- UDP.serverSocket (MULTICAST_ADDR_V4, read DISCOVERY_PORT) + logDebug $ "Discovery listener socket: " <> tshow sock + let raw = UDP.listenSocket sock + N.setSocketOption raw N.Broadcast 1 + void $ setMembership raw (listenerHostAddr4 sock) True pure sock +closeListener :: MonadIO m => UDP.ListenSocket -> m () +closeListener sock = liftIO $ do + UDP.stop sock + void $ setMembership (UDP.listenSocket sock) (listenerHostAddr4 sock) False + +listenerHostAddr4 :: UDP.ListenSocket -> N.HostAddress +listenerHostAddr4 sock = case UDP.mySockAddr sock of + N.SockAddrInet _port host -> host + _ -> error "MULTICAST_ADDR_V4 is V4" + recvAnnounce :: (MonadIO m) => UDP.ListenSocket -> m (N.SockAddr, ByteString) recvAnnounce sock = liftIO $ do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock @@ -139,7 +154,7 @@ connectRevHTTP2 :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (HTTP2Requ connectRevHTTP2 host fingerprint = connectTLSClient host fingerprint . attachHTTP2Server connectTLSClient :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) +connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host DISCOVERY_PORT (Just caFingerprint) attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () attachHTTP2Server processRequest tls = do diff --git a/src/Simplex/Chat/Remote/Multicast.hsc b/src/Simplex/Chat/Remote/Multicast.hsc new file mode 100644 index 0000000000..ea015c18e3 --- /dev/null +++ b/src/Simplex/Chat/Remote/Multicast.hsc @@ -0,0 +1,43 @@ +module Simplex.Chat.Remote.Multicast (setMembership) where + +import Foreign (Ptr, allocaBytes, castPtr, pokeByteOff) +import Foreign.C.Types (CInt (..)) +import Network.Socket + +#include + +{- | Toggle multicast group membership. + +NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups. +-} +setMembership :: Socket -> HostAddress -> Bool -> IO Bool +setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do + #{poke struct ip_mreq, imr_multiaddr} mReqPtr group + #{poke struct ip_mreq, imr_interface} mReqPtr (0 :: HostAddress) -- attempt to contact the group on ANY interface + withFdSocket sock $ \fd -> + (/= 0) <$> c_setsockopt fd c_IPPROTO_IP flag (castPtr mReqPtr) (#{size struct ip_mreq}) + where + flag = if membership then c_IP_ADD_MEMBERSHIP else c_IP_DROP_MEMBERSHIP + +#ifdef mingw32_HOST_OS + +foreign import stdcall unsafe "setsockopt" + c_setsockopt :: CInt -> CInt -> CInt -> Ptr CInt -> CInt -> IO CInt + +c_IP_ADD_MEMBERSHIP, c_IP_DROP_MEMBERSHIP :: CInt +c_IP_ADD_MEMBERSHIP = 12 +c_IP_DROP_MEMBERSHIP = 13 + +#else + +foreign import ccall unsafe "setsockopt" + c_setsockopt :: CInt -> CInt -> CInt -> Ptr CInt -> CInt -> IO CInt + +c_IP_ADD_MEMBERSHIP, c_IP_DROP_MEMBERSHIP :: CInt +c_IP_ADD_MEMBERSHIP = #const IP_ADD_MEMBERSHIP +c_IP_DROP_MEMBERSHIP = #const IP_DROP_MEMBERSHIP + +#endif + +c_IPPROTO_IP :: CInt +c_IPPROTO_IP = #const IPPROTO_IP From cd98fabe43579439bb93c712b768ace001174a5e Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 25 Oct 2023 18:39:46 +0300 Subject: [PATCH 022/174] robust discovery RFC (#3276) * add new discovery RFC * update * update * update ports --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- docs/rfcs/2023-10-24-robust-discovery.md | 137 +++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/rfcs/2023-10-24-robust-discovery.md diff --git a/docs/rfcs/2023-10-24-robust-discovery.md b/docs/rfcs/2023-10-24-robust-discovery.md new file mode 100644 index 0000000000..ff06a12ff7 --- /dev/null +++ b/docs/rfcs/2023-10-24-robust-discovery.md @@ -0,0 +1,137 @@ +# Robust discovery + +## Problem + +Remote session protocol has the "discovery" phase where mobile and desktop try to find each other. + +Given how easy it is to spoof UDP datagrams extra care should be taken to avoid unauthenticated data. +In the tech spike for remote sessions, a discovery datagram contains only a TLS key fingerprint. +While this is enough to operate in a safe environment, the datagram itself should be authenticated. + +Using link-local broadcast address of `255.255.255.255` is problematic on MacOS. + +The initial implementation effort shown that discovery process better be running as a stand-alone service. +Additionally, it is desirable to run multiple service announcers in parallel from a single process. +Each announced service may be a remote controller assigned to a different remote host device, or some other site-local service entirely. + +We still want to avoid system interface enumeration due to guesswork involved in filtering them and extra permissions/entitlements required on mobile devices. + +## Solution + +* An OOB data is extended with a public key to authenticate datagrams. +* A datagram is extended with MAC envelope, service address and its tag. +* A site-local multicast group is used for announcement. +* Additional site-local multicast group is used by announcer to find its own public LAN address. + +### Datagram + +- `[4]` Version range encoding +- `[1]` 'A' (Announce) +- `[8]` systemSeconds of SystemTime encoding - does not change within session. +- `[2]` Announce counter. +- `[6]` Service address (host and port). +- `[1 + 32]` SHA256 fingerprint of CA certificate used to issue per-session TLS certificates. +- `[1 + ?]` X25519 DH key encoding to agree for per-session encryption inside TLS (hello received in response to hello from controller will contain host DH key). +- `[1 + ?]` Ed25519 public key used to sign datagram (the host also will receive it in the QR code, it should match this one). +- `[1 + ?]` Ed25519 signature signing the previous bytes. + +"Encoding" here means Encoding class. + +That gives ~250 bytes (TBC) that is well under typical MTU of ~1500. + +A site-local multicast group `224.0.0.251` is used to announce services on port `5227`. + +> The same group and port are used in mDNS (AKA bonjour/avahi/dns-sd/zeroconf) so we expect it to run with most home/SOHO access points without further configuration, although using a different port can make it ineffective. + +### OOB data + +Announcer MUST include: +- ED25519 public key used to sign announce datagrams in its OOB link/QR code (also included in datagram, so they can be validated before scanning QR code). +- the CA certificate fingerprint (also included in datagram). +- device name for better initial contact UX. + +### Discovery announcer + +> announcer is run before the controller service. +> +> Multiple announcers can send to the same group/port simultaneously. + +A typical announce interval is 1 second to balance UI responsiveness with network traffic. + +Announcer MUST first discover its own address and validate with the list of local network interfaces. + +To discover it's address it will send a datagram with this format: + +- `[4]` Version range encoding +- `[1]` 'I' (Identify) +- `[1 + 32]` Random number. + +Announcer MUST NOT announce a service for a different host. + +### Implementation + +``` +ChatController { + ... + multicastSubscribers :: TMVar Int + ... +} +``` + +Controller/host connection steps: + +1. take multicastSubscribers, if 0 subscribe to multicast group +2. increase counter and put it back. +3. send SXC0 datagrams to discover own address. +4. when received, match address to network interfaces, fail if no match after a few datagrams. +5. get free port for TCP session. +6. generate DH key for session. +7. prepare and start sending signed SXC1 datagrams. +8. when host connects to the address in the announcement, stop sending datagrams. +9. take multicastSubscribers, if 1 unsubscribe from multicast group +10. put back min(0, cnt - 1). +11. send Hello to host. +12. get Hello from host with DH key, compute shared secret to be used for remaining commands and responses. + +### Service (TCP server) + +A service submits its port/tag/payload to announcer and cancels it when a client connection is established or the service is shut down. + +A service SHOULD use system-provided dynamic port (i.e. bind to port `0`) to avoid getting "address in use" errors due to multiple service instances running or another/system service running on a designated port. + +### Discovery listener + +> TBD: A listener is most certainly a singleton service. But what would its lifetime be? +> We can run it continously for a snappier discovery and no-brainer client API. +> Or we can run it on-demand, registering there requests for discovery. + +An active listener service receives datagrams and maintains a discovery table mapping service tags and keys to source addresses. +A service key is derived from the payload, which MAY be used as-is. +Source address contains both host and port. + +Listener MUST verify datagram signature against the key it got in datagram. + +Listener MUST verify that the address in the announcement matches the source address of the datagram. + +During the first connection to the new controller: + +OOB must have the same: +- Ed25519 key used to sign datagrams. +- CA cert fingerprint. + +During the subsequent connections, these keys and CA cert fingerprint in the datagram mush match the record. + +### Service (TCP client) + +> TBD: This assumes always-on listener. + +A TCP client will use STM to wait for expected service tag and key to appear in discovery table to get its address. + +E.g. a remote host on a mobile will wait for the remote profile service with a key fingerprint from OOB. + +### Finding own address with multicast + +An host with a multicast entitlement may use it to find its own address. +Receiving your own datagram would reveal source address just as it is used in (unauthenticated) discovery tests. + +The same multicast group `224.0.0.251` is used to send "mirror" datagrams on port `5227`. From 16bda260225d3ee1715b23cf17cda56adb5f4c37 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:44:50 +0100 Subject: [PATCH 023/174] core: derive JSON with TH (#3275) * core: derive JSON with TH * fix tests * simplify events * reduce diff * fix * update simplexmq * update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 1 + src/Simplex/Chat/Call.hs | 135 ++---- src/Simplex/Chat/Controller.hs | 286 +++++------ src/Simplex/Chat/Markdown.hs | 42 +- src/Simplex/Chat/Messages.hs | 445 ++++++++---------- src/Simplex/Chat/Messages/CIContent.hs | 184 +------- src/Simplex/Chat/Messages/CIContent/Events.hs | 116 +++++ src/Simplex/Chat/Mobile.hs | 40 +- src/Simplex/Chat/Mobile/File.hs | 13 +- src/Simplex/Chat/Protocol.hs | 120 ++--- src/Simplex/Chat/Remote/Protocol.hs | 1 - src/Simplex/Chat/Remote/Types.hs | 11 +- src/Simplex/Chat/Store/Profiles.hs | 17 +- src/Simplex/Chat/Store/Shared.hs | 15 +- src/Simplex/Chat/Types.hs | 315 +++++-------- src/Simplex/Chat/Types/Preferences.hs | 202 ++++---- src/Simplex/Chat/Types/Util.hs | 3 - src/Simplex/Chat/View.hs | 22 +- stack.yaml | 2 +- tests/MobileTests.hs | 10 +- 23 files changed, 849 insertions(+), 1136 deletions(-) create mode 100644 src/Simplex/Chat/Messages/CIContent/Events.hs diff --git a/cabal.project b/cabal.project index 5c19fcd446..9cc0a7be6a 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + tag: 511d793b927b1e2f12999e0829718671b3a8f0cb source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26188aa774..658da37f6b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d920a2504b6d4653748da7d297cb13cd0a0f1f48" = "0r53wn01z044h6myvd458n3hiqsz64kpv59khgybzwdw5mmqnp34"; + "https://github.com/simplex-chat/simplexmq.git"."511d793b927b1e2f12999e0829718671b3a8f0cb" = "14zk7g33x4a1g5d1dihaklvwzll86ks6fk87kf6l6l5back581zi"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f061f8ac86..e9036ea604 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -36,6 +36,7 @@ library Simplex.Chat.Markdown Simplex.Chat.Messages Simplex.Chat.Messages.CIContent + Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Migrations.M20220101_initial Simplex.Chat.Migrations.M20220122_v1_1 Simplex.Chat.Migrations.M20220205_chat_item_status diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2ef5c4356f..e8049dcbb5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -58,6 +58,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 7e6e60c8f5..313442838e 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,18 +1,18 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} module Simplex.Chat.Call where -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as J +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -20,12 +20,11 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call { contactId :: ContactId, @@ -47,14 +46,7 @@ data CallStateTag | CSTCallOfferSent | CSTCallOfferReceived | CSTCallNegotiated - deriving (Show, Generic) - -instance FromJSON CallStateTag where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CSTCall" - -instance ToJSON CallStateTag where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall" + deriving (Show) callStateTag :: CallState -> CallStateTag callStateTag = \case @@ -93,21 +85,7 @@ data CallState peerCallSession :: WebRTCSession, sharedKey :: Maybe C.Key } - deriving (Show, Generic) - --- database representation -instance FromJSON CallState where - parseJSON = J.genericParseJSON $ singleFieldJSON fstToLower - -instance ToJSON CallState where - toJSON = J.genericToJSON $ singleFieldJSON fstToLower - toEncoding = J.genericToEncoding $ singleFieldJSON fstToLower - -instance ToField CallState where - toField = toField . encodeJSON - -instance FromField CallState where - fromField = fromTextField_ decodeJSON + deriving (Show) newtype CallId = CallId ByteString deriving (Eq, Show) @@ -135,17 +113,13 @@ data RcvCallInvitation = RcvCallInvitation sharedKey :: Maybe C.Key, callTs :: UTCTime } - deriving (Show, Generic, FromJSON) - -instance ToJSON RcvCallInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data CallType = CallType { media :: CallMedia, capabilities :: CallCapabilities } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) defaultCallType :: CallType defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True} @@ -153,95 +127,54 @@ defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True} encryptedCall :: CallType -> Bool encryptedCall CallType {capabilities = CallCapabilities {encryption}} = encryption -instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions - -- | * Types for chat protocol data CallInvitation = CallInvitation { callType :: CallType, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CallMedia = CMAudio | CMVideo - deriving (Eq, Show, Generic) - -instance FromJSON CallMedia where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CM" - -instance ToJSON CallMedia where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CM" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CM" + deriving (Eq, Show) data CallCapabilities = CallCapabilities { encryption :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallCapabilities where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallOffer = CallOffer { callType :: CallType, rtcSession :: WebRTCSession, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallOffer where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data WebRTCCallOffer = WebRTCCallOffer { callType :: CallType, rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCCallOffer where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CallAnswer = CallAnswer { rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallAnswer where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallExtraInfo = CallExtraInfo { rtcExtraInfo :: WebRTCExtraInfo } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallExtraInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCSession = WebRTCSession { rtcSession :: Text, -- LZW compressed JSON encoding of offer or answer rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCSession where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCExtraInfo = WebRTCExtraInfo { rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCExtraInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCCallStatus = WCSConnecting | WCSConnected | WCSDisconnected | WCSFailed deriving (Show) @@ -259,3 +192,37 @@ instance StrEncoding WebRTCCallStatus where "disconnected" -> pure WCSDisconnected "failed" -> pure WCSFailed _ -> fail "bad WebRTCCallStatus" + +$(J.deriveJSON (enumJSON $ dropPrefix "CSTCall") ''CallStateTag) + +$(J.deriveJSON (enumJSON $ dropPrefix "CM") ''CallMedia) + +$(J.deriveJSON defaultJSON ''CallCapabilities) + +$(J.deriveJSON defaultJSON ''CallType) + +$(J.deriveJSON defaultJSON ''CallInvitation) + +$(J.deriveJSON defaultJSON ''WebRTCSession) + +$(J.deriveJSON defaultJSON ''CallOffer) + +$(J.deriveJSON defaultJSON ''WebRTCCallOffer) + +$(J.deriveJSON defaultJSON ''CallAnswer) + +$(J.deriveJSON defaultJSON ''WebRTCExtraInfo) + +$(J.deriveJSON defaultJSON ''CallExtraInfo) + +-- database representation +$(J.deriveJSON (singleFieldJSON fstToLower) ''CallState) + +instance ToField CallState where + toField = toField . encodeJSON + +instance FromField CallState where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''RcvCallInvitation) + diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1bb28f89d2..66ab513a0d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -2,7 +2,6 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -41,7 +40,6 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) -import GHC.Generics (Generic) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -67,7 +65,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) @@ -196,14 +194,7 @@ data ChatController = ChatController } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase - deriving (Show, Generic) - -instance FromJSON HelpSection where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "HS" - -instance ToJSON HelpSection where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" + deriving (Show) data ChatCommand = ShowActiveUser @@ -698,28 +689,14 @@ data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} | CPGroupLink {groupLinkPlan :: GroupLinkPlan} - deriving (Show, Generic) - -instance FromJSON ConnectionPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CP" - -instance ToJSON ConnectionPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CP" + deriving (Show) data InvitationLinkPlan = ILPOk | ILPOwnLink | ILPConnecting {contact_ :: Maybe Contact} | ILPKnown {contact :: Contact} - deriving (Show, Generic) - -instance FromJSON InvitationLinkPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "ILP" - -instance ToJSON InvitationLinkPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "ILP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "ILP" + deriving (Show) data ContactAddressPlan = CAPOk @@ -727,14 +704,7 @@ data ContactAddressPlan | CAPConnectingConfirmReconnect | CAPConnectingProhibit {contact :: Contact} | CAPKnown {contact :: Contact} - deriving (Show, Generic) - -instance FromJSON ContactAddressPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CAP" - -instance ToJSON ContactAddressPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CAP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CAP" + deriving (Show) data GroupLinkPlan = GLPOk @@ -742,14 +712,7 @@ data GroupLinkPlan | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo} - deriving (Show, Generic) - -instance FromJSON GroupLinkPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "GLP" - -instance ToJSON GroupLinkPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" + deriving (Show) connectionPlanProceed :: ConnectionPlan -> Bool connectionPlanProceed = \case @@ -794,7 +757,7 @@ instance ToJSON AgentQueueId where toEncoding = strToJEncoding data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]} - deriving (Show, Generic, FromJSON) + deriving (Show) data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p) @@ -805,36 +768,17 @@ data UserProtoServers p = UserProtoServers protoServers :: NonEmpty (ServerCfg p), presetServers :: NonEmpty (ProtoServerWithAuth p) } - deriving (Show, Generic) - -instance ProtocolTypeI p => FromJSON (UserProtoServers p) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance ProtocolTypeI p => ToJSON (UserProtoServers p) where - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) -instance FromJSON AUserProtoServers where - parseJSON v = J.withObject "AUserProtoServers" parse v - where - parse o = do - AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" - case userProtocol p of - Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v - Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p - -instance ToJSON AUserProtoServers where - toJSON (AUPS s) = J.genericToJSON J.defaultOptions s - toEncoding (AUPS s) = J.genericToEncoding J.defaultOptions s - deriving instance Show AUserProtoServers data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} - deriving (Show, Generic, FromJSON) + deriving (Show) data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey} - deriving (Show, Generic, FromJSON) + deriving (Show) newtype DBEncryptionKey = DBEncryptionKey String deriving (Show) @@ -852,41 +796,25 @@ data ContactSubStatus = ContactSubStatus { contact :: Contact, contactError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON ContactSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data MemberSubStatus = MemberSubStatus { member :: GroupMember, memberError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON MemberSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data UserContactSubStatus = UserContactSubStatus { userContact :: UserContact, userContactError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserContactSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data PendingSubStatus = PendingSubStatus { connection :: PendingContactConnection, connError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON PendingSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data UserProfileUpdateSummary = UserProfileUpdateSummary { notChanged :: Int, @@ -894,16 +822,14 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary updateFailures :: Int, changedContacts :: [Contact] } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserProfileUpdateSummary where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ComposedMessage = ComposedMessage { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, msgContent :: MsgContent } - deriving (Show, Generic) + deriving (Show) -- This instance is needed for backward compatibility, can be removed in v6.0 instance FromJSON ComposedMessage where @@ -918,24 +844,16 @@ instance FromJSON ComposedMessage where parseJSON invalid = JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) -instance ToJSON ComposedMessage where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - data XFTPFileConfig = XFTPFileConfig { minFileSize :: Integer } - deriving (Show, Generic, FromJSON) + deriving (Show) defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -instance ToJSON XFTPFileConfig where toEncoding = J.genericToEncoding J.defaultOptions - data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} - deriving (Show, Generic, FromJSON) - -instance ToJSON NtfMsgInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} @@ -945,25 +863,19 @@ data SwitchProgress = SwitchProgress switchPhase :: SwitchPhase, connectionStats :: ConnectionStats } - deriving (Show, Generic, FromJSON) - -instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data RatchetSyncProgress = RatchetSyncProgress { ratchetSyncStatus :: RatchetSyncState, connectionStats :: ConnectionStats } - deriving (Show, Generic, FromJSON) - -instance ToJSON RatchetSyncProgress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ParsedServerAddress = ParsedServerAddress { serverAddress :: Maybe ServerAddress, parseError :: String } - deriving (Show, Generic, FromJSON) - -instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ServerAddress = ServerAddress { serverProtocol :: AProtocolType, @@ -972,9 +884,7 @@ data ServerAddress = ServerAddress keyHash :: String, basicAuth :: String } - deriving (Show, Generic, FromJSON) - -instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data TimedMessagesEnabled = TMEEnableSetTTL Int @@ -996,22 +906,18 @@ data CoreVersionInfo = CoreVersionInfo simplexmqVersion :: String, simplexmqCommit :: String } - deriving (Show, Generic, FromJSON) - -instance ToJSON CoreVersionInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data SendFileMode = SendFileSMP (Maybe InlineFileMode) | SendFileXFTP - deriving (Show, Generic) + deriving (Show) data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats } - deriving (Show, Generic, FromJSON) - -instance ToJSON SlowSQLQuery where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ChatError = ChatError {errorType :: ChatErrorType} @@ -1020,14 +926,7 @@ data ChatError | ChatErrorDatabase {databaseError :: DatabaseError} | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} - deriving (Show, Exception, Generic) - -instance FromJSON ChatError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "Chat" - -instance ToJSON ChatError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" + deriving (Show, Exception) data ChatErrorType = CENoActiveUser @@ -1107,14 +1006,7 @@ data ChatErrorType | CEPeerChatVRangeIncompatible | CEInternalError {message :: String} | CEException {message :: String} - deriving (Show, Exception, Generic) - -instance FromJSON ChatErrorType where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CE" - -instance ToJSON ChatErrorType where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" + deriving (Show, Exception) data DatabaseError = DBErrorEncrypted @@ -1122,24 +1014,10 @@ data DatabaseError | DBErrorNoFile {dbFile :: String} | DBErrorExport {sqliteError :: SQLiteError} | DBErrorOpen {sqliteError :: SQLiteError} - deriving (Show, Exception, Generic) - -instance FromJSON DatabaseError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "DB" - -instance ToJSON DatabaseError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DB" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DB" + deriving (Show, Exception) data SQLiteError = SQLiteErrorNotADatabase | SQLiteError String - deriving (Show, Exception, Generic) - -instance FromJSON SQLiteError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SQLite" - -instance ToJSON SQLiteError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SQLite" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SQLite" + deriving (Show, Exception) throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase @@ -1153,14 +1031,7 @@ data RemoteHostError | RHDisconnected {reason :: Text} -- ^ A session disconnected by a host | RHConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues | RHProtocolError RemoteProtocolError - deriving (Show, Exception, Generic) - -instance FromJSON RemoteHostError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RH" - -instance ToJSON RemoteHostError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RH" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RH" + deriving (Show, Exception) -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError @@ -1176,26 +1047,12 @@ data RemoteCtrlError | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove | RCEInvalidResponse {responseError :: String} | RCEProtocolError {protocolError :: RemoteProtocolError} - deriving (Show, Exception, Generic) - -instance FromJSON RemoteCtrlError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RCE" - -instance ToJSON RemoteCtrlError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" + deriving (Show, Exception) data ArchiveError = AEImport {chatError :: ChatError} | AEImportFile {file :: String, chatError :: ChatError} - deriving (Show, Exception, Generic) - -instance FromJSON ArchiveError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "AE" - -instance ToJSON ArchiveError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" + deriving (Show, Exception) data RemoteCtrlSession = RemoteCtrlSession { -- | Host (mobile) side of transport to process remote commands and forward notifications @@ -1295,4 +1152,83 @@ withStoreCtx ctx_ action = do handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr +$(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RemoteHostError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RemoteCtrlError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SQLite") ''SQLiteError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) + +$(JQ.deriveJSON defaultJSON ''ContactSubStatus) + +$(JQ.deriveJSON defaultJSON ''MemberSubStatus) + +$(JQ.deriveJSON defaultJSON ''UserContactSubStatus) + +$(JQ.deriveJSON defaultJSON ''PendingSubStatus) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "AE") ''ArchiveError) + +$(JQ.deriveJSON defaultJSON ''UserProfileUpdateSummary) + +$(JQ.deriveJSON defaultJSON ''NtfMsgInfo) + +$(JQ.deriveJSON defaultJSON ''SwitchProgress) + +$(JQ.deriveJSON defaultJSON ''RatchetSyncProgress) + +$(JQ.deriveJSON defaultJSON ''ServerAddress) + +$(JQ.deriveJSON defaultJSON ''ParsedServerAddress) + +$(JQ.deriveJSON defaultJSON ''CoreVersionInfo) + +$(JQ.deriveJSON defaultJSON ''SlowSQLQuery) + +instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) + +instance ProtocolTypeI p => FromJSON (UserProtoServers p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) + +instance ProtocolTypeI p => ToJSON (UserProtoServers p) where + toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) + +instance FromJSON AUserProtoServers where + parseJSON v = J.withObject "AUserProtoServers" parse v + where + parse o = do + AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" + case userProtocol p of + Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v + Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p + +instance ToJSON AUserProtoServers where + toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s + toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) + +$(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) + +$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) + +$(JQ.deriveJSON defaultJSON ''XFTPFileConfig) + +$(JQ.deriveToJSON defaultJSON ''ComposedMessage) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 793fa753e1..391f43fa37 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -1,9 +1,9 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} @@ -13,6 +13,7 @@ module Simplex.Chat.Markdown where import Control.Applicative (optional, (<|>)) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A import Data.Char (isDigit) @@ -27,12 +28,11 @@ import Data.String import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import GHC.Generics import Simplex.Chat.Types import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types @@ -52,17 +52,10 @@ data Format | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} | Email | Phone - deriving (Eq, Show, Generic) + deriving (Eq, Show) data SimplexLinkType = XLContact | XLInvitation | XLGroup - deriving (Eq, Show, Generic) - -instance FromJSON SimplexLinkType where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "XL" - -instance ToJSON SimplexLinkType where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "XL" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "XL" + deriving (Eq, Show) colored :: Color -> Format colored = Colored . FormatColor @@ -70,13 +63,6 @@ colored = Colored . FormatColor markdown :: Format -> Text -> Markdown markdown = Markdown . Just -instance FromJSON Format where - parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower - -instance ToJSON Format where - toJSON = J.genericToJSON $ sumTypeJSON fstToLower - toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower - instance Semigroup Markdown where m <> (Markdown _ "") = m (Markdown _ "") <> m = m @@ -122,10 +108,7 @@ instance ToJSON FormatColor where White -> "white" data FormattedText = FormattedText {format :: Maybe Format, text :: Text} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FormattedText where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) instance IsString FormattedText where fromString = FormattedText Nothing . T.pack @@ -133,11 +116,6 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} - deriving (Generic) - -instance ToJSON ParsedMarkdown where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} unmarked :: Text -> Markdown unmarked = Markdown Nothing @@ -257,3 +235,11 @@ markdownP = mconcat <$> A.many' fragmentP linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of Just (CRDataGroup _) -> XLGroup Nothing -> XLContact + +$(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) + +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''Format) + +$(JQ.deriveJSON defaultJSON ''FormattedText) + +$(JQ.deriveToJSON defaultJSON ''ParsedMarkdown) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 8bc302f5d9..2718b088ba 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -10,6 +9,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -20,6 +20,7 @@ import Control.Applicative ((<|>)) 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 import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB @@ -33,7 +34,6 @@ import Data.Type.Equality import Data.Typeable (Typeable) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Markdown import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol @@ -43,17 +43,15 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptSta import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection - deriving (Eq, Show, Ord, Generic) + deriving (Eq, Show, Ord) data ChatName = ChatName {chatType :: ChatType, chatName :: Text} - deriving (Show, Generic, FromJSON) - -instance ToJSON ChatName where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) chatTypeStr :: ChatType -> String chatTypeStr = \case @@ -68,13 +66,6 @@ chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name data ChatRef = ChatRef ChatType Int64 deriving (Eq, Show, Ord) -instance FromJSON ChatType where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CT" - -instance ToJSON ChatType where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" - data ChatInfo (c :: ChatType) where DirectChat :: Contact -> ChatInfo 'CTDirect GroupChat :: GroupInfo -> ChatInfo 'CTGroup @@ -113,14 +104,8 @@ data JSONChatInfo | JCInfoGroup {groupInfo :: GroupInfo} | JCInfoContactRequest {contactRequest :: UserContactRequest} | JCInfoContactConnection {contactConnection :: PendingContactConnection} - deriving (Generic) -instance FromJSON JSONChatInfo where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCInfo" - -instance ToJSON JSONChatInfo where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) instance ChatTypeI c => FromJSON (ChatInfo c) where parseJSON v = (\(AChatInfo _ c) -> checkChatType c) <$?> J.parseJSON v @@ -163,14 +148,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem reactions :: [CIReactionCount], file :: Maybe (CIFile d) } - deriving (Show, Generic) - -instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance (ChatTypeI c, MsgDirectionI d) => ToJSON (ChatItem c d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) isMention :: ChatItem c d -> Bool isMention ChatItem {chatDir, quotedItem} = case chatDir of @@ -195,34 +173,14 @@ deriving instance Show (CIDirection c d) data CCIDirection c = forall d. MsgDirectionI d => CCID (SMsgDirection d) (CIDirection c d) -instance ChatTypeI c => FromJSON (CCIDirection c) where - parseJSON v = (\(ACID _ d x) -> checkChatType (CCID d x)) <$?> J.parseJSON v - data ACIDirection = forall c d. (ChatTypeI c, MsgDirectionI d) => ACID (SChatType c) (SMsgDirection d) (CIDirection c d) -instance FromJSON ACIDirection where - parseJSON v = jsonACIDirection <$> J.parseJSON v - data JSONCIDirection = JCIDirectSnd | JCIDirectRcv | JCIGroupSnd | JCIGroupRcv {groupMember :: GroupMember} - deriving (Generic, Show) - -instance FromJSON JSONCIDirection where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" - -instance ToJSON JSONCIDirection where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" - -instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where - parseJSON v = (\(CCID _ x') -> checkDirection x') <$?> J.parseJSON v - -instance ToJSON (CIDirection c d) where - toJSON = J.toJSON . jsonCIDirection - toEncoding = J.toEncoding . jsonCIDirection + deriving (Show) jsonCIDirection :: CIDirection c d -> JSONCIDirection jsonCIDirection = \case @@ -239,26 +197,12 @@ jsonACIDirection = \case JCIGroupRcv m -> ACID SCTGroup SMDRcv $ CIGroupRcv m data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int} - deriving (Show, Generic, FromJSON) - -instance ToJSON CIReactionCount where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d) deriving instance Show (CChatItem c) -instance forall c. ChatTypeI c => FromJSON (CChatItem c) where - parseJSON v = J.withObject "CChatItem" parse v - where - parse o = do - CCID d (_ :: CIDirection c d) <- o .: "chatDir" - ci <- J.parseJSON @(ChatItem c d) v - pure $ CChatItem d ci - -instance ChatTypeI c => ToJSON (CChatItem c) where - toJSON (CChatItem _ ci) = J.toJSON ci - toEncoding (CChatItem _ ci) = J.toEncoding ci - cchatItemId :: CChatItem c -> ChatItemId cchatItemId (CChatItem _ ci) = chatItemId' ci @@ -325,51 +269,25 @@ data Chat c = Chat chatItems :: [CChatItem c], chatStats :: ChatStats } - deriving (Show, Generic) - -instance ChatTypeI c => ToJSON (Chat c) where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AChat = forall c. ChatTypeI c => AChat (SChatType c) (Chat c) deriving instance Show AChat -instance FromJSON AChat where - parseJSON = J.withObject "AChat" $ \o -> do - AChatInfo c chatInfo <- o .: "chatInfo" - chatItems <- o .: "chatItems" - chatStats <- o .: "chatStats" - pure $ AChat c Chat {chatInfo, chatItems, chatStats} - -instance ToJSON AChat where - toJSON (AChat _ c) = J.toJSON c - toEncoding (AChat _ c) = J.toEncoding c - data ChatStats = ChatStats { unreadCount :: Int, minUnreadItemId :: ChatItemId, unreadChat :: Bool } - deriving (Show, Generic, FromJSON) - -instance ToJSON ChatStats where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) -- | type to show a mix of messages from multiple chats data AChatItem = forall c d. (ChatTypeI c, MsgDirectionI d) => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem -instance FromJSON AChatItem where - parseJSON = J.withObject "AChatItem" $ \o -> do - AChatInfo c chatInfo <- o .: "chatInfo" - CChatItem d chatItem <- o .: "chatItem" - pure $ AChatItem c d chatInfo chatItem - -instance ToJSON AChatItem where - toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item - toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item - data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} - deriving (Generic) aChatItems :: AChat -> [AChatItem] aChatItems (AChat ct Chat {chatInfo, chatItems}) = map aChatItem chatItems @@ -387,10 +305,6 @@ updateFileStatus ci@ChatItem {file} status = case file of Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} Nothing -> ci -instance (ChatTypeI c, MsgDirectionI d) => ToJSON (JSONAnyChatItem c d) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - -- This type is not saved to DB, so all JSON encodings are platform-specific data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta { itemId :: ChatItemId, @@ -406,7 +320,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Show, Generic, FromJSON) + deriving (Show) mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs createdAt updatedAt = @@ -415,15 +329,11 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item _ -> False in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt} -instance ChatTypeI c => ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions - data CITimed = CITimed { ttl :: Int, -- seconds deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read } - deriving (Show, Generic, FromJSON) - -instance ToJSON CITimed where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) ttl' :: CITimed -> Int ttl' CITimed {ttl} = ttl @@ -457,14 +367,7 @@ data CIQuote (c :: ChatType) = CIQuote content :: MsgContent, formattedText :: Maybe MarkdownList } - deriving (Show, Generic) - -instance ChatTypeI c => FromJSON (CIQuote c) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance ToJSON (CIQuote c) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction { chatDir :: CIDirection c d, @@ -472,41 +375,15 @@ data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction sentAt :: UTCTime, reaction :: MsgReaction } - deriving (Show, Generic) - -instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIReaction c d) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance ChatTypeI c => ToJSON (CIReaction c d) where - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AnyCIReaction = forall c d. ChatTypeI c => ACIR (SChatType c) (SMsgDirection d) (CIReaction c d) -instance FromJSON AnyCIReaction where - parseJSON v = J.withObject "AnyCIReaction" parse v - where - parse o = do - ACID c d (_ :: CIDirection c d) <- o .: "chatDir" - ACIR c d <$> J.parseJSON @(CIReaction c d) v - data ACIReaction = forall c d. ChatTypeI c => ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) deriving instance Show ACIReaction -instance FromJSON ACIReaction where - parseJSON = J.withObject "ACIReaction" $ \o -> do - ACIR c d reaction <- o .: "chatReaction" - cInfo <- o .: "chatInfo" - pure $ ACIReaction c d cInfo reaction - -instance ToJSON ACIReaction where - toJSON (ACIReaction _ _ cInfo reaction) = J.toJSON $ JSONCIReaction cInfo reaction - toEncoding (ACIReaction _ _ cInfo reaction) = J.toEncoding $ JSONCIReaction cInfo reaction - data JSONCIReaction c d = JSONCIReaction {chatInfo :: ChatInfo c, chatReaction :: CIReaction c d} - deriving (Generic) - -instance ChatTypeI c => ToJSON (JSONCIReaction c d) where toEncoding = J.genericToEncoding J.defaultOptions data CIQDirection (c :: ChatType) where CIQDirectSnd :: CIQDirection 'CTDirect @@ -518,13 +395,6 @@ deriving instance Show (CIQDirection c) data ACIQDirection = forall c. ChatTypeI c => ACIQDirection (SChatType c) (CIQDirection c) -instance ChatTypeI c => FromJSON (CIQDirection c) where - parseJSON v = (\(ACIQDirection _ x) -> checkChatType x) . jsonACIQDirection <$?> J.parseJSON v - -instance ToJSON (CIQDirection c) where - toJSON = J.toJSON . jsonCIQDirection - toEncoding = J.toEncoding . jsonCIQDirection - jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection jsonCIQDirection = \case CIQDirectSnd -> Just JCIDirectSnd @@ -556,14 +426,7 @@ data CIFile (d :: MsgDirection) = CIFile fileStatus :: CIFileStatus d, fileProtocol :: FileProtocol } - deriving (Show, Generic) - -instance MsgDirectionI d => FromJSON (CIFile d) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance MsgDirectionI d => ToJSON (CIFile d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data FileProtocol = FPSMP | FPXFTP deriving (Eq, Show, Ord) @@ -621,17 +484,6 @@ ciFileEnded = \case CIFSRcvError -> True CIFSInvalid {} -> True -instance MsgDirectionI d => FromJSON (CIFileStatus d) where - parseJSON v = (\(AFS _ s) -> checkDirection s) . aciFileStatusJSON <$?> J.parseJSON v - -instance ToJSON (CIFileStatus d) where - toJSON = J.toJSON . jsonCIFileStatus - toEncoding = J.toEncoding . jsonCIFileStatus - -instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode - -instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d) deriving instance Show ACIFileStatus @@ -689,14 +541,6 @@ data JSONCIFileStatus | JCIFSRcvCancelled | JCIFSRcvError | JCIFSInvalid {text :: Text} - deriving (Generic) - -instance FromJSON JSONCIFileStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIFS" - -instance ToJSON JSONCIFileStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIFS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIFS" jsonCIFileStatus :: CIFileStatus d -> JSONCIFileStatus jsonCIFileStatus = \case @@ -758,19 +602,6 @@ deriving instance Eq (CIStatus d) deriving instance Show (CIStatus d) -instance MsgDirectionI d => FromJSON (CIStatus d) where - parseJSON v = (\(ACIStatus _ s) -> checkDirection s) . jsonACIStatus <$?> J.parseJSON v - -instance ToJSON (CIStatus d) where - toJSON = J.toJSON . jsonCIStatus - toEncoding = J.toEncoding . jsonCIStatus - -instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode - -instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - -instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d) deriving instance Show ACIStatus @@ -813,14 +644,7 @@ data JSONCIStatus | JCISRcvNew | JCISRcvRead | JCISInvalid {text :: Text} - deriving (Show, Generic) - -instance FromJSON JSONCIStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIS" - -instance ToJSON JSONCIStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIS" + deriving (Show) jsonCIStatus :: CIStatus d -> JSONCIStatus jsonCIStatus = \case @@ -872,14 +696,7 @@ membersGroupItemStatus memStatusCounts data SndCIStatusProgress = SSPPartial | SSPComplete - deriving (Eq, Show, Generic) - -instance FromJSON SndCIStatusProgress where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "SSP" - -instance ToJSON SndCIStatusProgress where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "SSP" + deriving (Eq, Show) instance StrEncoding SndCIStatusProgress where strEncode = \case @@ -929,13 +746,6 @@ instance ChatTypeI 'CTContactRequest where chatTypeI = SCTContactRequest instance ChatTypeI 'CTContactConnection where chatTypeI = SCTContactConnection -instance ChatTypeI c => FromJSON (SChatType c) where - parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v - -instance ToJSON (SChatType c) where - toJSON = J.toJSON . toChatType - toEncoding = J.toEncoding . toChatType - toChatType :: SChatType c -> ChatType toChatType = \case SCTDirect -> CTDirect @@ -1007,9 +817,7 @@ data MsgMetaJSON = MsgMetaJSON serverTs :: UTCTime, sndId :: Int64 } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) msgMetaToJson :: MsgMeta -> MsgMetaJSON msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = @@ -1022,9 +830,6 @@ msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId sndId } -msgMetaJson :: MsgMeta -> Text -msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson - data MsgDeliveryStatus (d :: MsgDirection) where MDSRcvAgent :: MsgDeliveryStatus 'MDRcv MDSRcvAcknowledged :: MsgDeliveryStatus 'MDRcv @@ -1081,25 +886,11 @@ deriving instance Show (CIDeleted c) data ACIDeleted = forall c. ChatTypeI c => ACIDeleted (SChatType c) (CIDeleted c) -instance ChatTypeI c => FromJSON (CIDeleted c) where - parseJSON v = (\(ACIDeleted _ x) -> checkChatType x) . jsonACIDeleted <$?> J.parseJSON v - -instance ChatTypeI c => ToJSON (CIDeleted c) where - toJSON = J.toJSON . jsonCIDeleted - toEncoding = J.toEncoding . jsonCIDeleted - data JSONCIDeleted = JCIDDeleted {deletedTs :: Maybe UTCTime, chatType :: ChatType} | JCIDBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} - deriving (Show, Generic) - -instance FromJSON JSONCIDeleted where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCID" - -instance ToJSON JSONCIDeleted where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCID" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCID" + deriving (Show) jsonCIDeleted :: forall d. ChatTypeI d => CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case @@ -1123,9 +914,7 @@ data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe [MemberDeliveryStatus] } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ChatItemInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, @@ -1134,9 +923,7 @@ data ChatItemVersion = ChatItemVersion itemVersionTs :: UTCTime, createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ChatItemVersion where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content @@ -1155,9 +942,7 @@ data MemberDeliveryStatus = MemberDeliveryStatus { groupMemberId :: GroupMemberId, memberDeliveryStatus :: CIStatus 'MDSnd } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberDeliveryStatus where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CIModeration = CIModeration { moderationId :: Int64, @@ -1166,3 +951,187 @@ data CIModeration = CIModeration moderatedAt :: UTCTime } deriving (Show) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CT") ''ChatType) + +instance ChatTypeI c => FromJSON (SChatType c) where + parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v + +instance ToJSON (SChatType c) where + toJSON = J.toJSON . toChatType + toEncoding = J.toEncoding . toChatType + +$(JQ.deriveJSON defaultJSON ''ChatName) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCID") ''JSONCIDeleted) + +instance ChatTypeI c => FromJSON (CIDeleted c) where + parseJSON v = (\(ACIDeleted _ x) -> checkChatType x) . jsonACIDeleted <$?> J.parseJSON v + +instance ChatTypeI c => ToJSON (CIDeleted c) where + toJSON = J.toJSON . jsonCIDeleted + toEncoding = J.toEncoding . jsonCIDeleted + +$(JQ.deriveJSON defaultJSON ''CITimed) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "SSP") ''SndCIStatusProgress) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCIS") ''JSONCIStatus) + +instance MsgDirectionI d => FromJSON (CIStatus d) where + parseJSON v = (\(ACIStatus _ s) -> checkDirection s) . jsonACIStatus <$?> J.parseJSON v + +instance ToJSON (CIStatus d) where + toJSON = J.toJSON . jsonCIStatus + toEncoding = J.toEncoding . jsonCIStatus + +instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode + +instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +$(JQ.deriveJSON defaultJSON ''MemberDeliveryStatus) + +$(JQ.deriveJSON defaultJSON ''ChatItemVersion) + +$(JQ.deriveJSON defaultJSON ''ChatItemInfo) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIMeta c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIMeta) + +instance ChatTypeI c => ToJSON (CIMeta c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIMeta) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIMeta) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCIFS") ''JSONCIFileStatus) + +instance MsgDirectionI d => FromJSON (CIFileStatus d) where + parseJSON v = (\(AFS _ s) -> checkDirection s) . aciFileStatusJSON <$?> J.parseJSON v + +instance ToJSON (CIFileStatus d) where + toJSON = J.toJSON . jsonCIFileStatus + toEncoding = J.toEncoding . jsonCIFileStatus + +instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode + +instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +instance MsgDirectionI d => FromJSON (CIFile d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIFile) + +instance MsgDirectionI d => ToJSON (CIFile d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIFile) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIFile) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIDirection) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where + parseJSON v = (\(CCID _ x') -> checkDirection x') <$?> J.parseJSON v + +instance ToJSON (CIDirection c d) where + toJSON = J.toJSON . jsonCIDirection + toEncoding = J.toEncoding . jsonCIDirection + +instance ChatTypeI c => FromJSON (CCIDirection c) where + parseJSON v = (\(ACID _ d x) -> checkChatType (CCID d x)) <$?> J.parseJSON v + +instance FromJSON ACIDirection where + parseJSON v = jsonACIDirection <$> J.parseJSON v + +instance ChatTypeI c => FromJSON (CIQDirection c) where + parseJSON v = (\(ACIQDirection _ x) -> checkChatType x) . jsonACIQDirection <$?> J.parseJSON v + +instance ToJSON (CIQDirection c) where + toJSON = J.toJSON . jsonCIQDirection + toEncoding = J.toEncoding . jsonCIQDirection + +instance ChatTypeI c => FromJSON (CIQuote c) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIQuote) + +$(JQ.deriveToJSON defaultJSON ''CIQuote) + +$(JQ.deriveJSON defaultJSON ''CIReactionCount) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (ChatItem c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''ChatItem) + toEncoding = $(JQ.mkToEncoding defaultJSON ''ChatItem) + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (JSONAnyChatItem c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''JSONAnyChatItem) + toEncoding = $(JQ.mkToEncoding defaultJSON ''JSONAnyChatItem) + +instance FromJSON AChatItem where + parseJSON = J.withObject "AChatItem" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + CChatItem d chatItem <- o .: "chatItem" + pure $ AChatItem c d chatInfo chatItem + +instance ToJSON AChatItem where + toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item + toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item + +instance forall c. ChatTypeI c => FromJSON (CChatItem c) where + parseJSON v = J.withObject "CChatItem" parse v + where + parse o = do + CCID d (_ :: CIDirection c d) <- o .: "chatDir" + ci <- J.parseJSON @(ChatItem c d) v + pure $ CChatItem d ci + +instance ChatTypeI c => ToJSON (CChatItem c) where + toJSON (CChatItem _ ci) = J.toJSON ci + toEncoding (CChatItem _ ci) = J.toEncoding ci + +$(JQ.deriveJSON defaultJSON ''ChatStats) + +instance ChatTypeI c => ToJSON (Chat c) where + toJSON = $(JQ.mkToJSON defaultJSON ''Chat) + toEncoding = $(JQ.mkToEncoding defaultJSON ''Chat) + +instance FromJSON AChat where + parseJSON = J.withObject "AChat" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + chatItems <- o .: "chatItems" + chatStats <- o .: "chatStats" + pure $ AChat c Chat {chatInfo, chatItems, chatStats} + +instance ToJSON AChat where + toJSON (AChat _ c) = J.toJSON c + toEncoding (AChat _ c) = J.toEncoding c + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIReaction c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIReaction) + +instance ChatTypeI c => ToJSON (CIReaction c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIReaction) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIReaction) + +instance FromJSON AnyCIReaction where + parseJSON v = J.withObject "AnyCIReaction" parse v + where + parse o = do + ACID c d (_ :: CIDirection c d) <- o .: "chatDir" + ACIR c d <$> J.parseJSON @(CIReaction c d) v + +instance ChatTypeI c => ToJSON (JSONCIReaction c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''JSONCIReaction) + toEncoding = $(JQ.mkToEncoding defaultJSON ''JSONCIReaction) + +instance FromJSON ACIReaction where + parseJSON = J.withObject "ACIReaction" $ \o -> do + ACIR c d reaction <- o .: "chatReaction" + cInfo <- o .: "chatInfo" + pure $ ACIReaction c d cInfo reaction + +instance ToJSON ACIReaction where + toJSON (ACIReaction _ _ cInfo reaction) = J.toJSON $ JSONCIReaction cInfo reaction + toEncoding (ACIReaction _ _ cInfo reaction) = J.toEncoding $ JSONCIReaction cInfo reaction + +$(JQ.deriveJSON defaultJSON ''MsgMetaJSON) + +msgMetaJson :: MsgMeta -> Text +msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 7836e7232a..2ca9d4ca0c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -14,9 +13,9 @@ module Simplex.Chat.Messages.CIContent where -import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J -import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.TH as JQ +import Data.Aeson.Types as JT import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -24,25 +23,20 @@ import Data.Type.Equality import Data.Word (Word32) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) +import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol 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.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>)) data MsgDirection = MDRcv | MDSnd - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON MsgDirection where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MD" - -instance ToJSON MsgDirection where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MD" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MD" +$(JQ.deriveJSON (enumJSON $ dropPrefix "MD") ''MsgDirection) instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgDirection . msgDirectionIntP @@ -106,14 +100,9 @@ msgDirectionIntP = \case _ -> Nothing data CIDeleteMode = CIDMBroadcast | CIDMInternal - deriving (Show, Generic) + deriving (Show) -instance ToJSON CIDeleteMode where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CIDM" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CIDM" - -instance FromJSON CIDeleteMode where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CIDM" +$(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case @@ -163,14 +152,7 @@ ciMsgContent = \case _ -> Nothing data MsgDecryptError = MDERatchetHeader | MDETooManySkipped | MDERatchetEarlier | MDEOther - deriving (Eq, Show, Generic) - -instance ToJSON MsgDecryptError where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MDE" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MDE" - -instance FromJSON MsgDecryptError where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MDE" + deriving (Eq, Show) ciRequiresAttention :: forall d. MsgDirectionI d => CIContent d -> Bool ciRequiresAttention content = case msgDirection @d of @@ -204,135 +186,14 @@ ciRequiresAttention content = case msgDirection @d of CIRcvModerated -> True CIInvalidJSON _ -> False -data RcvGroupEvent - = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting - | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember - | RGEMemberLeft -- CRLeftMember - | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | RGEUserRole {role :: GroupMemberRole} - | RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember - | RGEUserDeleted -- CRDeletedMemberUser - | RGEGroupDeleted -- CRGroupDeleted - | RGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated - -- RGEInvitedViaGroupLink chat items are not received - they're created when sending group invitations, - -- but being RcvGroupEvent allows them to be assigned to the respective member (and so enable "send direct message") - -- and be created as unread without adding / working around new status for sent items - | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink - | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv - deriving (Show, Generic) - -instance FromJSON RcvGroupEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RGE" - -instance ToJSON RcvGroupEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RGE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RGE" - -newtype DBRcvGroupEvent = RGE RcvGroupEvent - -instance FromJSON DBRcvGroupEvent where - parseJSON v = RGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RGE") v - -instance ToJSON DBRcvGroupEvent where - toJSON (RGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RGE") v - toEncoding (RGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RGE") v - -data SndGroupEvent - = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | SGEUserRole {role :: GroupMemberRole} - | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember - | SGEUserLeft -- CRLeftMemberUser - | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated - deriving (Show, Generic) - -instance FromJSON SndGroupEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SGE" - -instance ToJSON SndGroupEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SGE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SGE" - -newtype DBSndGroupEvent = SGE SndGroupEvent - -instance FromJSON DBSndGroupEvent where - parseJSON v = SGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SGE") v - -instance ToJSON DBSndGroupEvent where - toJSON (SGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SGE") v - toEncoding (SGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SGE") v - -data RcvConnEvent - = RCESwitchQueue {phase :: SwitchPhase} - | RCERatchetSync {syncStatus :: RatchetSyncState} - | RCEVerificationCodeReset - deriving (Show, Generic) - -data SndConnEvent - = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} - | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} - deriving (Show, Generic) - -instance FromJSON RcvConnEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RCE" - -instance ToJSON RcvConnEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" - -newtype DBRcvConnEvent = RCE RcvConnEvent - -instance FromJSON DBRcvConnEvent where - parseJSON v = RCE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RCE") v - -instance ToJSON DBRcvConnEvent where - toJSON (RCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RCE") v - toEncoding (RCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RCE") v - -instance FromJSON SndConnEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SCE" - -instance ToJSON SndConnEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SCE" - -newtype DBSndConnEvent = SCE SndConnEvent - -instance FromJSON DBSndConnEvent where - parseJSON v = SCE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SCE") v - -instance ToJSON DBSndConnEvent where - toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v - toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v - -data RcvDirectEvent = - -- RDEProfileChanged {...} - RDEContactDeleted - deriving (Show, Generic) - -instance FromJSON RcvDirectEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RDE" - -instance ToJSON RcvDirectEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RDE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RDE" - -newtype DBRcvDirectEvent = RDE RcvDirectEvent - -instance FromJSON DBRcvDirectEvent where - parseJSON v = RDE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RDE") v - -instance ToJSON DBRcvDirectEvent where - toJSON (RDE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RDE") v - toEncoding (RDE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RDE") v - newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where - parseJSON v = DBME <$> J.genericParseJSON (singleFieldJSON fstToLower) v + parseJSON v = DBME <$> $(JQ.mkParseJSON (singleFieldJSON fstToLower) ''MsgErrorType) v instance ToJSON DBMsgErrorType where - toJSON (DBME v) = J.genericToJSON (singleFieldJSON fstToLower) v - toEncoding (DBME v) = J.genericToEncoding (singleFieldJSON fstToLower) v + toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v + toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v data CIGroupInvitation = CIGroupInvitation { groupId :: GroupId, @@ -341,25 +202,14 @@ data CIGroupInvitation = CIGroupInvitation groupProfile :: GroupProfile, status :: CIGroupInvitationStatus } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CIGroupInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CIGroupInvitationStatus = CIGISPending | CIGISAccepted | CIGISRejected | CIGISExpired - deriving (Eq, Show, Generic) - -instance FromJSON CIGroupInvitationStatus where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CIGIS" - -instance ToJSON CIGroupInvitationStatus where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CIGIS" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CIGIS" + deriving (Eq, Show) ciContentToText :: CIContent d -> Text ciContentToText = \case @@ -685,6 +535,12 @@ ciCallInfoText status duration = case status of CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" +$(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus) + +$(JQ.deriveJSON defaultJSON ''CIGroupInvitation) + $(JQ.deriveJSON (enumJSON $ dropPrefix "CISCall") ''CICallStatus) -- platform specific diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs new file mode 100644 index 0000000000..42a5add1d6 --- /dev/null +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Messages.CIContent.Events where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +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) + +data RcvGroupEvent + = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting + | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember + | RGEMemberLeft -- CRLeftMember + | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} + | RGEUserRole {role :: GroupMemberRole} + | RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember + | RGEUserDeleted -- CRDeletedMemberUser + | RGEGroupDeleted -- CRGroupDeleted + | RGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + -- RGEInvitedViaGroupLink chat items are not received - they're created when sending group invitations, + -- but being RcvGroupEvent allows them to be assigned to the respective member (and so enable "send direct message") + -- and be created as unread without adding / working around new status for sent items + | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink + | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv + deriving (Show) + +data SndGroupEvent + = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} + | SGEUserRole {role :: GroupMemberRole} + | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember + | SGEUserLeft -- CRLeftMemberUser + | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + deriving (Show) + +data RcvConnEvent + = RCESwitchQueue {phase :: SwitchPhase} + | RCERatchetSync {syncStatus :: RatchetSyncState} + | RCEVerificationCodeReset + deriving (Show) + +data SndConnEvent + = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} + | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} + deriving (Show) + +data RcvDirectEvent = + -- RDEProfileChanged {...} + RDEContactDeleted + deriving (Show) + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RGE") ''RcvGroupEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvGroupEvent = RGE RcvGroupEvent + +instance FromJSON DBRcvGroupEvent where + parseJSON v = RGE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + +instance ToJSON DBRcvGroupEvent where + toJSON (RGE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + toEncoding (RGE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SGE") ''SndGroupEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBSndGroupEvent = SGE SndGroupEvent + +instance FromJSON DBSndGroupEvent where + parseJSON v = SGE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + +instance ToJSON DBSndGroupEvent where + toJSON (SGE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + toEncoding (SGE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RcvConnEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvConnEvent = RCE RcvConnEvent + +instance FromJSON DBRcvConnEvent where + parseJSON v = RCE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + +instance ToJSON DBRcvConnEvent where + toJSON (RCE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + toEncoding (RCE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SCE") ''SndConnEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBSndConnEvent = SCE SndConnEvent + +instance FromJSON DBSndConnEvent where + parseJSON v = SCE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + +instance ToJSON DBSndConnEvent where + toJSON (SCE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + toEncoding (SCE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RDE") ''RcvDirectEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvDirectEvent = RDE RcvDirectEvent + +instance FromJSON DBRcvDirectEvent where + parseJSON v = RDE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v + +instance ToJSON DBRcvDirectEvent where + toJSON (RDE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v + toEncoding (RDE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6203d1218c..be079af8a5 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,8 +1,8 @@ -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fobject-code #-} @@ -13,8 +13,8 @@ import Control.Concurrent.STM import Control.Exception (catch, SomeException) import Control.Monad.Except import Control.Monad.Reader -import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) @@ -32,7 +32,6 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) -import GHC.Generics (Generic) import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) @@ -50,12 +49,26 @@ import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), Migrati import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..)) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) +data DBMigrationResult + = DBMOk + | DBMInvalidConfirmation + | DBMErrorNotADatabase {dbFile :: String} + | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} + | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} + deriving (Show) + +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "DBM") ''DBMigrationResult) + +data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} + +$(JQ.deriveToJSON defaultJSON ''APIResponse) + foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString @@ -189,18 +202,6 @@ defaultMobileConfig = getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers -data DBMigrationResult - = DBMOk - | DBMInvalidConfirmation - | DBMErrorNotADatabase {dbFile :: String} - | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} - | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} - deriving (Show, Generic) - -instance ToJSON DBMigrationResult where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DBM" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DBM" - chatMigrateInit :: String -> String -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm @@ -264,10 +265,3 @@ chatPasswordHash pwd salt = either (const "") passwordHash salt' where salt' = U.decode salt passwordHash = U.encode . C.sha512Hash . (pwd <>) - -data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} - deriving (Generic) - -instance ToJSON APIResponse where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 73978549ff..99860bbfa3 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -1,7 +1,7 @@ {-# LANGUAGE BangPatterns #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} module Simplex.Chat.Mobile.File @@ -19,8 +19,8 @@ where import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Data.Aeson (ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as LB @@ -32,7 +32,6 @@ import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr import Foreign.Storable (poke, pokeByteOff) -import GHC.Generics (Generic) import Simplex.Chat.Mobile.Shared import Simplex.Chat.Util (chunkSize, encryptFile) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) @@ -45,9 +44,8 @@ import UnliftIO (Handle, IOMode (..), withFile) data WriteFileResult = WFResult {cryptoArgs :: CryptoFileArgs} | WFError {writeError :: String} - deriving (Generic) -instance ToJSON WriteFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "WF" +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "WF") ''WriteFileResult) cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString cChatWriteFile cPath ptr len = do @@ -66,9 +64,6 @@ chatWriteFile path s = do data ReadFileResult = RFResult {fileSize :: Int} | RFError {readError :: String} - deriving (Generic) - -instance ToJSON ReadFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RF" cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) cChatReadFile cPath cKey cNonce = do @@ -141,3 +136,5 @@ chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runCatchExcept runCatchExceptT :: ExceptT String IO a -> IO (Either String a) runCatchExceptT action = runExceptT action `catchAll` (pure . Left . show) + +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 50d58b2a48..2687299350 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -11,6 +10,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -23,6 +23,7 @@ import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) @@ -40,13 +41,12 @@ import Data.Typeable (Typeable) import Data.Word (Word32) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) @@ -70,14 +70,9 @@ data ConnectionEntity | SndFileConnection {entityConnection :: Connection, sndFileTransfer :: SndFileTransfer} | RcvFileConnection {entityConnection :: Connection, rcvFileTransfer :: RcvFileTransfer} | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON ConnectionEntity where - parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower - -instance ToJSON ConnectionEntity where - toJSON = J.genericToJSON $ sumTypeJSON fstToLower - toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''ConnectionEntity) updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of @@ -104,8 +99,6 @@ instance MsgEncodingI 'Binary where encoding = SBinary instance MsgEncodingI 'Json where encoding = SJson -data ACMEventTag = forall e. MsgEncodingI e => ACMEventTag (SMsgEncoding e) (CMEventTag e) - instance TestEquality SMsgEncoding where testEquality SBinary SBinary = Just Refl testEquality SJson SJson = Just Refl @@ -127,7 +120,6 @@ data AppMessageJson = AppMessageJson event :: Text, params :: J.Object } - deriving (Generic, FromJSON) data AppMessageBinary = AppMessageBinary { msgId :: Maybe SharedMsgId, @@ -135,10 +127,6 @@ data AppMessageBinary = AppMessageBinary body :: ByteString } -instance ToJSON AppMessageJson where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - instance StrEncoding AppMessageBinary where strEncode AppMessageBinary {tag, msgId, body} = smpEncode (tag, msgId', Tail body) where @@ -167,20 +155,42 @@ instance ToJSON SharedMsgId where toJSON = strToJSON toEncoding = strToJEncoding +$(JQ.deriveJSON defaultJSON ''AppMessageJson) + data MsgRef = MsgRef { msgId :: Maybe SharedMsgId, sentAt :: UTCTime, sent :: Bool, memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received } - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON MsgRef where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} +$(JQ.deriveJSON defaultJSON ''MsgRef) -instance ToJSON MsgRef where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} + deriving (Eq, Show) + +data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} + deriving (Eq, Show) + +$(pure []) + +instance FromJSON LinkContent where + parseJSON v@(J.Object j) = + $(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + <|> LCUnknown <$> j .: "type" <*> pure j + parseJSON invalid = + JT.prependFailure "bad LinkContent, " (JT.typeMismatch "Object" invalid) + +instance ToJSON LinkContent where + toJSON = \case + LCUnknown _ j -> J.Object j + v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + toEncoding = \case + LCUnknown _ j -> JE.value $ J.Object j + v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + +$(JQ.deriveJSON defaultJSON ''LinkPreview) data ChatMessage e = ChatMessage { chatVRange :: VersionRange, @@ -191,19 +201,6 @@ data ChatMessage e = ChatMessage data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) -instance MsgEncodingI e => StrEncoding (ChatMessage e) where - strEncode msg = case chatToAppMessage msg of - AMJson m -> LB.toStrict $ J.encode m - AMBinary m -> strEncode m - strP = (\(ACMsg _ m) -> checkEncoding m) <$?> strP - -instance StrEncoding AChatMessage where - strEncode (ACMsg _ m) = strEncode m - strP = - A.peekChar' >>= \case - '{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString) - _ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP) - data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -329,11 +326,7 @@ instance Encoding InlineFileChunk where pure FileChunk {chunkNo = fromIntegral $ c2w c, chunkBytes} data QuotedMsg = QuotedMsg {msgRef :: MsgRef, content :: MsgContent} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON QuotedMsg where - toEncoding = J.genericToEncoding J.defaultOptions - toJSON = J.genericToJSON J.defaultOptions + deriving (Eq, Show) cmToQuotedMsg :: AChatMsgEvent -> Maybe QuotedMsg cmToQuotedMsg = \case @@ -386,34 +379,6 @@ isQuote = \case MCQuote {} -> True _ -> False -data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} - deriving (Eq, Show, Generic) - -data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} - deriving (Eq, Show, Generic) - -instance FromJSON LinkPreview where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON LinkPreview where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance FromJSON LinkContent where - parseJSON v@(J.Object j) = - J.genericParseJSON (taggedObjectJSON $ dropPrefix "LC") v - <|> LCUnknown <$> j .: "type" <*> pure j - parseJSON invalid = - JT.prependFailure "bad LinkContent, " (JT.typeMismatch "Object" invalid) - -instance ToJSON LinkContent where - toJSON = \case - LCUnknown _ j -> J.Object j - v -> J.genericToJSON (taggedObjectJSON $ dropPrefix "LC") v - toEncoding = \case - LCUnknown _ j -> JE.value $ J.Object j - v -> J.genericToEncoding (taggedObjectJSON $ dropPrefix "LC") v - data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} @@ -466,6 +431,21 @@ msgContentTag = \case data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} deriving (Eq, Show) +$(JQ.deriveJSON defaultJSON ''QuotedMsg) + +instance MsgEncodingI e => StrEncoding (ChatMessage e) where + strEncode msg = case chatToAppMessage msg of + AMJson m -> LB.toStrict $ J.encode m + AMBinary m -> strEncode m + strP = (\(ACMsg _ m) -> checkEncoding m) <$?> strP + +instance StrEncoding AChatMessage where + strEncode (ACMsg _ m) = strEncode m + strP = + A.peekChar' >>= \case + '{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString) + _ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP) + parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc @@ -545,6 +525,8 @@ instance ToField MsgContent where instance FromField MsgContent where fromField = fromTextField_ decodeJSON +data ACMEventTag = forall e. MsgEncodingI e => ACMEventTag (SMsgEncoding e) (CMEventTag e) + data CMEventTag (e :: MsgEncoding) where XMsgNew_ :: CMEventTag 'Json XMsgFileDescr_ :: CMEventTag 'Json diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 65a851f718..aa4ebe5952 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -1,5 +1,4 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index de54813a4d..d16955199e 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,6 +1,5 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE TemplateHaskell #-} @@ -12,7 +11,7 @@ import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import UnliftIO data RemoteHostClient = RemoteHostClient @@ -116,10 +115,10 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) $(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) -$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) +$(J.deriveJSON defaultJSON ''RemoteCtrlOOB) -$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) +$(J.deriveJSON defaultJSON ''RemoteHostInfo) -$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) +$(J.deriveJSON defaultJSON ''RemoteCtrl) -$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrlInfo) +$(J.deriveJSON defaultJSON ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index d4ca3193b3..99689b29d1 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,10 +1,10 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} @@ -61,8 +61,7 @@ where import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import Data.Functor (($>)) import Data.Int (Int64) import qualified Data.List.NonEmpty as L @@ -73,7 +72,6 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) -import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Protocol @@ -86,6 +84,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 Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (safeDecodeUtf8) @@ -400,17 +399,17 @@ data UserContactLink = UserContactLink { connReqContact :: ConnReqContact, autoAccept :: Maybe AutoAccept } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AutoAccept = AutoAccept { acceptIncognito :: IncognitoEnabled, autoReply :: Maybe MsgContent } - deriving (Show, Generic, FromJSON) + deriving (Show) -instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions +$(J.deriveJSON defaultJSON ''AutoAccept) + +$(J.deriveJSON defaultJSON ''UserContactLink) toUserContactLink :: (ConnReqContact, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index d00dce718e..7c1f07191d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -1,11 +1,11 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Shared where @@ -16,8 +16,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG, randomBytesGenerate) -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -28,7 +27,6 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query, SQLError, (:.) (..)) import qualified Database.SQLite.Simple as SQL import Database.SQLite.Simple.QQ (sql) -import GHC.Generics (Generic) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types @@ -103,14 +101,9 @@ data StoreError | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} | SERemoteHostNotFound {remoteHostId :: RemoteHostId} | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} - deriving (Show, Exception, Generic) + deriving (Show, Exception) -instance FromJSON StoreError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SE" - -instance ToJSON StoreError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b3c4ea09b2..057067d85a 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -2,7 +2,6 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -45,14 +44,13 @@ import Database.SQLite.Simple.FromField (returnError, FromField(..)) import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) 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.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version @@ -264,9 +262,7 @@ data UserContact = UserContact connReqContact :: ConnReqContact, groupId :: Maybe GroupId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON UserContact where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) userContactGroupId :: UserContact -> Maybe GroupId userContactGroupId UserContact {groupId} = groupId @@ -284,10 +280,7 @@ data UserContactRequest = UserContactRequest updatedAt :: UTCTime, xContactId :: Maybe XContactId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON UserContactRequest where - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) newtype XContactId = XContactId ByteString deriving (Eq, Show) @@ -341,9 +334,7 @@ optionalFullName displayName fullName | otherwise = " (" <> fullName <> ")" data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Group where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) type GroupId = Int64 @@ -359,9 +350,7 @@ data GroupInfo = GroupInfo updatedAt :: UTCTime, chatTs :: Maybe UTCTime } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g @@ -369,9 +358,7 @@ groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary { currentMembers :: Int } - deriving (Show, Generic, FromJSON) - -instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ContactOrGroup = CGContact Contact | CGGroup Group @@ -386,9 +373,7 @@ data ChatSettings = ChatSettings sendRcpts :: Maybe Bool, favorite :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) defaultChatSettings :: ChatSettings defaultChatSettings = @@ -402,18 +387,7 @@ chatHasNtfs :: ChatSettings -> Bool chatHasNtfs ChatSettings {enableNtfs} = enableNtfs /= MFNone data MsgFilter = MFNone | MFAll | MFMentions - deriving (Eq, Show, Generic) - -instance FromJSON MsgFilter where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MF" - -instance ToJSON MsgFilter where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MF" - -instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP - -instance ToField MsgFilter where toField = toField . msgFilterInt + deriving (Eq, Show) msgFilterInt :: MsgFilter -> Int msgFilterInt = \case @@ -496,11 +470,7 @@ data Profile = Profile -- - incognito -- - local_alias } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Profile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool @@ -522,11 +492,7 @@ data LocalProfile = LocalProfile preferences :: Maybe Preferences, localAlias :: LocalAlias } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON LocalProfile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile{profileId} = profileId @@ -546,11 +512,7 @@ data GroupProfile = GroupProfile image :: Maybe ImageData, groupPreferences :: Maybe GroupPreferences } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupProfile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) newtype ImageData = ImageData Text deriving (Eq, Show) @@ -567,14 +529,6 @@ instance ToField ImageData where toField (ImageData t) = toField t instance FromField ImageData where fromField = fmap ImageData . fromField data CReqClientData = CRDataGroup {groupLinkId :: GroupLinkId} - deriving (Generic) - -instance ToJSON CReqClientData where - toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "CRData" - toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "CRData" - -instance FromJSON CReqClientData where - parseJSON = J.genericParseJSON . taggedObjectJSON $ dropPrefix "CRData" newtype GroupLinkId = GroupLinkId {unGroupLinkId :: ByteString} -- used to identify invitation via group link deriving (Eq, Show) @@ -602,29 +556,19 @@ data GroupInvitation = GroupInvitation groupProfile :: GroupProfile, groupLinkId :: Maybe GroupLinkId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data IntroInvitation = IntroInvitation { groupConnReq :: ConnReqInvitation, directConnReq :: Maybe ConnReqInvitation } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON IntroInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data MemberInfo = MemberInfo { memberId :: MemberId, @@ -632,11 +576,7 @@ data MemberInfo = MemberInfo v :: Maybe ChatVersionRange, profile :: Profile } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberInfo where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = @@ -675,16 +615,10 @@ data GroupMember = GroupMember memberContactProfileId :: ProfileId, activeConn :: Maybe Connection } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMember where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMemberRef where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) groupMemberRef :: GroupMember -> GroupMemberRef groupMemberRef GroupMember {groupMemberId, memberProfile = p} = @@ -744,14 +678,7 @@ instance ToJSON MemberId where toEncoding = strToJEncoding data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown - deriving (Eq, Show, Generic) - -instance FromJSON InvitedBy where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" - -instance ToJSON InvitedBy where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" + deriving (Eq, Show) toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy toInvitedBy userCtId (Just ctId) @@ -803,9 +730,7 @@ instance ToJSON GroupMemberRole where data GroupMemberSettings = GroupMemberSettings { showMessages :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMemberSettings where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) defaultMemberSettings :: GroupMemberSettings defaultMemberSettings = GroupMemberSettings {showMessages = True} @@ -986,9 +911,7 @@ data SndFileTransfer = SndFileTransfer fileDescrId :: Maybe Int64, fileInline :: Maybe InlineFileMode } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON SndFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) sndFileTransferConnId :: SndFileTransfer -> ConnId sndFileTransferConnId SndFileTransfer {agentConnId = AgentConnId acId} = acId @@ -1003,24 +926,10 @@ data FileInvitation = FileInvitation fileInline :: Maybe InlineFileMode, fileDescr :: Maybe FileDescr } - deriving (Eq, Show, Generic) - -instance ToJSON FileInvitation where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - -instance FromJSON FileInvitation where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data FileDescr = FileDescr {fileDescrText :: Text, fileDescrPartNo :: Int, fileDescrComplete :: Bool} - deriving (Eq, Show, Generic) - -instance ToJSON FileDescr where - toEncoding = J.genericToEncoding J.defaultOptions - toJSON = J.genericToJSON J.defaultOptions - -instance FromJSON FileDescr where - parseJSON = J.genericParseJSON J.defaultOptions + deriving (Eq, Show) xftpFileInvitation :: FilePath -> Integer -> FileDescr -> FileInvitation xftpFileInvitation fileName fileSize fileDescr = @@ -1036,7 +945,7 @@ xftpFileInvitation fileName fileSize fileDescr = data InlineFileMode = IFMOffer -- file will be sent inline once accepted | IFMSent -- file is sent inline without acceptance - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance TextEncoding InlineFileMode where textEncode = \case @@ -1072,18 +981,14 @@ data RcvFileTransfer = RcvFileTransfer -- SMP files are encrypted after all chunks are received cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data XFTPRcvFile = XFTPRcvFile { rcvFileDescription :: RcvFileDescr, agentRcvFileId :: Maybe AgentRcvFileId, agentRcvFileDeleted :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON XFTPRcvFile where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data RcvFileDescr = RcvFileDescr { fileDescrId :: Int64, @@ -1091,9 +996,7 @@ data RcvFileDescr = RcvFileDescr fileDescrPartNo :: Int, fileDescrComplete :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RcvFileDescr where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data RcvFileStatus = RFSNew @@ -1101,14 +1004,7 @@ data RcvFileStatus | RFSConnected RcvFileInfo | RFSComplete RcvFileInfo | RFSCancelled (Maybe RcvFileInfo) - deriving (Eq, Show, Generic) - -instance FromJSON RcvFileStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" - -instance ToJSON RcvFileStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" + deriving (Eq, Show) rcvFileComplete :: RcvFileStatus -> Bool rcvFileComplete = \case @@ -1123,9 +1019,7 @@ data RcvFileInfo = RcvFileInfo connId :: Maybe Int64, agentConnId :: Maybe AgentConnId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) liveRcvFileTransferInfo :: RcvFileTransfer -> Maybe RcvFileInfo liveRcvFileTransferInfo RcvFileTransfer {fileStatus} = case fileStatus of @@ -1226,14 +1120,7 @@ data FileTransfer sndFileTransfers :: [SndFileTransfer] } | FTRcv {rcvFileTransfer :: RcvFileTransfer} - deriving (Show, Generic) - -instance FromJSON FileTransfer where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "FT" - -instance ToJSON FileTransfer where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" + deriving (Show) data FileTransferMeta = FileTransferMeta { fileId :: FileTransferId, @@ -1245,9 +1132,7 @@ data FileTransferMeta = FileTransferMeta chunkSize :: Integer, cancelled :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data XFTPSndFile = XFTPSndFile { agentSndFileId :: AgentSndFileId, @@ -1255,9 +1140,7 @@ data XFTPSndFile = XFTPSndFile agentSndFileDeleted :: Bool, cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON XFTPSndFile where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) fileTransferCancelled :: FileTransfer -> Bool fileTransferCancelled (FTSnd FileTransferMeta {cancelled} _) = cancelled @@ -1318,7 +1201,7 @@ data Connection = Connection authErrCounter :: Int, createdAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show) connReady :: Connection -> Bool connReady Connection {connStatus} = connStatus == ConnReady || connStatus == ConnSndReady @@ -1330,9 +1213,7 @@ connDisabled :: Connection -> Bool connDisabled Connection {authErrCounter} = authErrCounter >= authErrDisableCount data SecurityCode = SecurityCode {securityCode :: Text, verifiedAt :: UTCTime} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON SecurityCode where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) verificationCode :: ByteString -> Text verificationCode = T.pack . unwords . chunks 5 . show . os2ip @@ -1351,13 +1232,6 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId -instance FromJSON Connection where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON Connection where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - data PendingContactConnection = PendingContactConnection { pccConnId :: Int64, pccAgentConnId :: AgentConnId, @@ -1371,13 +1245,11 @@ data PendingContactConnection = PendingContactConnection createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) aConnId' :: PendingContactConnection -> ConnId aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId -instance ToJSON PendingContactConnection where toEncoding = J.genericToEncoding J.defaultOptions - data ConnStatus = -- | connection is created by initiating party with agent NEW command (createConnection) ConnNew @@ -1512,7 +1384,7 @@ data NetworkStatus | NSConnected | NSDisconnected | NSError {connectionError :: String} - deriving (Eq, Ord, Show, Generic) + deriving (Eq, Ord, Show) netStatusStr :: NetworkStatus -> String netStatusStr = \case @@ -1521,20 +1393,11 @@ netStatusStr = \case NSDisconnected -> "disconnected" NSError e -> "error: " <> e -instance FromJSON NetworkStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS" - -instance ToJSON NetworkStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS" - data ConnNetworkStatus = ConnNetworkStatus { agentConnId :: AgentConnId, networkStatus :: NetworkStatus } - deriving (Show, Generic, FromJSON) - -instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) type CommandId = Int64 @@ -1548,7 +1411,7 @@ data CommandStatus = CSCreated | CSCompleted -- unused - was replaced with deleteCommand | CSError -- internal command error, e.g. not matching connection id or unexpected response, not related to agent message ERR - deriving (Show, Generic) + deriving (Show) instance FromField CommandStatus where fromField = fromTextField_ textDecode @@ -1575,7 +1438,7 @@ data CommandFunction | CFAcceptContact | CFAckMessage | CFDeleteConn -- not used - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField CommandFunction where fromField = fromTextField_ textDecode @@ -1641,14 +1504,7 @@ data ServerCfg p = ServerCfg tested :: Maybe Bool, enabled :: Bool } - deriving (Show, Generic) - -instance ProtocolTypeI p => ToJSON (ServerCfg p) where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - -instance ProtocolTypeI p => FromJSON (ServerCfg p) where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Show) newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) @@ -1674,14 +1530,95 @@ 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 defOpts ''UserPwdHash) +$(JQ.deriveJSON defaultJSON ''UserContact) -$(JQ.deriveJSON defOpts ''User) +$(JQ.deriveJSON defaultJSON ''Profile) -$(JQ.deriveJSON defOpts ''NewUser) +$(JQ.deriveJSON defaultJSON ''LocalProfile) -$(JQ.deriveJSON defOpts ''UserInfo) +$(JQ.deriveJSON defaultJSON ''UserContactRequest) -$(JQ.deriveJSON defOpts ''Contact) +$(JQ.deriveJSON defaultJSON ''GroupProfile) -$(JQ.deriveJSON defOpts ''ContactRef) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "IB") ''InvitedBy) + +$(JQ.deriveJSON defaultJSON ''GroupMemberSettings) + +$(JQ.deriveJSON defaultJSON ''SecurityCode) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NS") ''NetworkStatus) + +$(JQ.deriveJSON defaultJSON ''ConnNetworkStatus) + +$(JQ.deriveJSON defaultJSON ''Connection) + +$(JQ.deriveJSON defaultJSON ''PendingContactConnection) + +$(JQ.deriveJSON defaultJSON ''GroupMember) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) + +$(JQ.deriveJSON defaultJSON ''ChatSettings) + +$(JQ.deriveJSON defaultJSON ''GroupInfo) + +$(JQ.deriveJSON defaultJSON ''Group) + +$(JQ.deriveJSON defaultJSON ''GroupSummary) + +instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP + +instance ToField MsgFilter where toField = toField . msgFilterInt + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "CRData") ''CReqClientData) + +$(JQ.deriveJSON defaultJSON ''MemberIdRole) + +$(JQ.deriveJSON defaultJSON ''GroupInvitation) + +$(JQ.deriveJSON defaultJSON ''IntroInvitation) + +$(JQ.deriveJSON defaultJSON ''MemberInfo) + +$(JQ.deriveJSON defaultJSON ''GroupMemberRef) + +$(JQ.deriveJSON defaultJSON ''FileDescr) + +$(JQ.deriveJSON defaultJSON ''FileInvitation) + +$(JQ.deriveJSON defaultJSON ''SndFileTransfer) + +$(JQ.deriveJSON defaultJSON ''RcvFileDescr) + +$(JQ.deriveJSON defaultJSON ''XFTPRcvFile) + +$(JQ.deriveJSON defaultJSON ''RcvFileInfo) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RFS") ''RcvFileStatus) + +$(JQ.deriveJSON defaultJSON ''RcvFileTransfer) + +$(JQ.deriveJSON defaultJSON ''XFTPSndFile) + +$(JQ.deriveJSON defaultJSON ''FileTransferMeta) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FT") ''FileTransfer) + +$(JQ.deriveJSON defaultJSON ''UserPwdHash) + +$(JQ.deriveJSON defaultJSON ''User) + +$(JQ.deriveJSON defaultJSON ''NewUser) + +$(JQ.deriveJSON defaultJSON ''UserInfo) + +$(JQ.deriveJSON defaultJSON ''Contact) + +$(JQ.deriveJSON defaultJSON ''ContactRef) + +instance ProtocolTypeI p => ToJSON (ServerCfg p) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerCfg) + toJSON = $(JQ.mkToJSON defaultJSON ''ServerCfg) + +instance ProtocolTypeI p => FromJSON (ServerCfg p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ServerCfg) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index c7555e18a8..da13da742f 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -12,6 +11,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilyDependencies #-} @@ -24,7 +24,7 @@ module Simplex.Chat.Types.Preferences where import Control.Applicative ((<|>)) import Data.Aeson (FromJSON (..), ToJSON (..)) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust) @@ -32,11 +32,10 @@ import Data.Text (Text) import qualified Data.Text as T import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import GHC.Records.Compat import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) data ChatFeature @@ -45,7 +44,7 @@ data ChatFeature | CFReactions | CFVoice | CFCalls - deriving (Show, Generic) + deriving (Show) data SChatFeature (f :: ChatFeature) where SCFTimedMessages :: SChatFeature 'CFTimedMessages @@ -71,13 +70,6 @@ chatFeatureNameText = \case chatFeatureNameText' :: SChatFeature f -> Text chatFeatureNameText' = chatFeatureNameText . chatFeature -instance ToJSON ChatFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CF" - -instance FromJSON ChatFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CF" - allChatFeatures :: [AChatFeature] allChatFeatures = [ ACF SCFTimedMessages, @@ -149,17 +141,7 @@ data Preferences = Preferences voice :: Maybe VoicePreference, calls :: Maybe CallsPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Preferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField Preferences where - toField = toField . encodeJSON - -instance FromField Preferences where - fromField = fromTextField_ decodeJSON + deriving (Eq, Show) data GroupFeature = GFTimedMessages @@ -168,7 +150,7 @@ data GroupFeature | GFReactions | GFVoice | GFFiles - deriving (Show, Generic) + deriving (Show) data SGroupFeature (f :: GroupFeature) where SGFTimedMessages :: SGroupFeature 'GFTimedMessages @@ -200,13 +182,6 @@ groupFeatureAllowed' :: GroupFeatureI f => SGroupFeature f -> FullGroupPreferenc groupFeatureAllowed' feature prefs = getField @"enable" (getGroupPreference feature prefs) == FEOn -instance ToJSON GroupFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "GF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "GF" - -instance FromJSON GroupFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "GF" - allGroupFeatures :: [AGroupFeature] allGroupFeatures = [ AGF SGFTimedMessages, @@ -263,17 +238,7 @@ data GroupPreferences = GroupPreferences voice :: Maybe VoiceGroupPreference, files :: Maybe FilesGroupPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField GroupPreferences where - toField = toField . encodeJSON - -instance FromField GroupPreferences where - fromField = fromTextField_ decodeJSON + deriving (Eq, Show) setGroupPreference :: forall f. GroupFeatureI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupPreferences -> GroupPreferences setGroupPreference f enable prefs_ = setGroupPreference_ f pref prefs @@ -312,9 +277,7 @@ data FullPreferences = FullPreferences voice :: VoicePreference, calls :: CallsPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) -- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing -- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here. @@ -326,9 +289,7 @@ data FullGroupPreferences = FullGroupPreferences voice :: VoiceGroupPreference, files :: FilesGroupPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) -- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences data ContactUserPreferences = ContactUserPreferences @@ -338,30 +299,17 @@ data ContactUserPreferences = ContactUserPreferences voice :: ContactUserPreference VoicePreference, calls :: ContactUserPreference CallsPreference } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data ContactUserPreference p = ContactUserPreference { enabled :: PrefEnabled, userPreference :: ContactUserPref p, contactPreference :: p } - deriving (Eq, Show, Generic) + deriving (Eq, Show) data ContactUserPref p = CUPContact {preference :: p} | CUPUser {preference :: p} - deriving (Eq, Show, Generic) - -instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions - -instance FromJSON p => FromJSON (ContactUserPreference p) where parseJSON = J.genericParseJSON J.defaultOptions - -instance ToJSON p => ToJSON (ContactUserPreference p) where toEncoding = J.genericToEncoding J.defaultOptions - -instance FromJSON p => FromJSON (ContactUserPref p) where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CUP" - -instance ToJSON p => ToJSON (ContactUserPref p) where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" + deriving (Eq, Show) toChatPrefs :: FullPreferences -> Preferences toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, calls} = @@ -404,31 +352,19 @@ data TimedMessagesPreference = TimedMessagesPreference { allow :: FeatureAllowed, ttl :: Maybe Int } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON TimedMessagesPreference where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data FullDeletePreference = FullDeletePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullDeletePreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ReactionsPreference = ReactionsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ReactionsPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data VoicePreference = VoicePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON VoicePreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallsPreference = CallsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallsPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) class (Eq (FeaturePreference f), HasField "allow" (FeaturePreference f) FeatureAllowed) => FeatureI f where type FeaturePreference (f :: ChatFeature) = p | p -> f @@ -477,47 +413,33 @@ instance FeatureI 'CFCalls where data GroupPreference = GroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data TimedMessagesGroupPreference = TimedMessagesGroupPreference { enable :: GroupFeatureEnabled, ttl :: Maybe Int } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data DirectMessagesGroupPreference = DirectMessagesGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data FullDeleteGroupPreference = FullDeleteGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data ReactionsGroupPreference = ReactionsGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data VoiceGroupPreference = VoiceGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data FilesGroupPreference = FilesGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON TimedMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON DirectMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON ReactionsGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FullDeleteGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON VoiceGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FilesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where type GroupFeaturePreference (f :: GroupFeature) = p | p -> f @@ -619,7 +541,7 @@ data FeatureAllowed = FAAlways -- allow unconditionally | FAYes -- allow, if peer allows it | FANo -- do not allow - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode @@ -645,7 +567,7 @@ instance ToJSON FeatureAllowed where toEncoding = strToJEncoding data GroupFeatureEnabled = FEOn | FEOff - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode @@ -718,11 +640,7 @@ toGroupPreferences groupPreferences = pref f = Just $ getGroupPreference f groupPreferences data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON PrefEnabled where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) prefEnabled :: FeatureI f => Bool -> FeaturePreference f -> FeaturePreference f -> PrefEnabled prefEnabled asymmetric user contact = case (getField @"allow" user, getField @"allow" contact) of @@ -784,3 +702,69 @@ getContactUserPreference f ps = case f of SCFReactions -> ps.reactions SCFVoice -> ps.voice SCFCalls -> ps.calls + +$(J.deriveJSON (enumJSON $ dropPrefix "CF") ''ChatFeature) + +$(J.deriveJSON (enumJSON $ dropPrefix "GF") ''GroupFeature) + +$(J.deriveJSON defaultJSON ''TimedMessagesPreference) + +$(J.deriveJSON defaultJSON ''FullDeletePreference) + +$(J.deriveJSON defaultJSON ''ReactionsPreference) + +$(J.deriveJSON defaultJSON ''VoicePreference) + +$(J.deriveJSON defaultJSON ''CallsPreference) + +$(J.deriveJSON defaultJSON ''Preferences) + +instance ToField Preferences where + toField = toField . encodeJSON + +instance FromField Preferences where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''GroupPreference) + +$(J.deriveJSON defaultJSON ''TimedMessagesGroupPreference) + +$(J.deriveJSON defaultJSON ''DirectMessagesGroupPreference) + +$(J.deriveJSON defaultJSON ''ReactionsGroupPreference) + +$(J.deriveJSON defaultJSON ''FullDeleteGroupPreference) + +$(J.deriveJSON defaultJSON ''VoiceGroupPreference) + +$(J.deriveJSON defaultJSON ''FilesGroupPreference) + +$(J.deriveJSON defaultJSON ''GroupPreferences) + +instance ToField GroupPreferences where + toField = toField . encodeJSON + +instance FromField GroupPreferences where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''FullPreferences) + +$(J.deriveJSON defaultJSON ''FullGroupPreferences) + +$(J.deriveJSON defaultJSON ''PrefEnabled) + +instance FromJSON p => FromJSON (ContactUserPref p) where + parseJSON = $(J.mkParseJSON (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + +instance ToJSON p => ToJSON (ContactUserPref p) where + toJSON = $(J.mkToJSON (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + toEncoding = $(J.mkToEncoding (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + +instance FromJSON p => FromJSON (ContactUserPreference p) where + parseJSON = $(J.mkParseJSON defaultJSON ''ContactUserPreference) + +instance ToJSON p => ToJSON (ContactUserPreference p) where + toJSON = $(J.mkToJSON defaultJSON ''ContactUserPreference) + toEncoding = $(J.mkToEncoding defaultJSON ''ContactUserPreference) + +$(J.deriveJSON defaultJSON ''ContactUserPreferences) diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs index 8681e99086..fffdd24b9e 100644 --- a/src/Simplex/Chat/Types/Util.hs +++ b/src/Simplex/Chat/Types/Util.hs @@ -28,6 +28,3 @@ fromBlobField_ p = \case Right k -> Ok k Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) f -> returnError ConversionFailed f "expecting SQLBlob column type" - -defOpts :: J.Options -defOpts = J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 501e232a60..2a3b74da37 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,5 +1,4 @@ {-# LANGUAGE DataKinds #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} @@ -7,12 +6,13 @@ {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.View where -import Data.Aeson (ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) @@ -31,7 +31,6 @@ import Data.Time (LocalTime (..), TimeOfDay (..), TimeZone (..), utcToLocalTime) import Data.Time.Calendar (addDays) import Data.Time.Clock (UTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) -import GHC.Generics (Generic) import qualified Network.HTTP.Types as Q import Numeric (showFFloat) import Simplex.Chat (defaultChatConfig, maxImageSize) @@ -66,6 +65,13 @@ import System.Console.ANSI.Types type CurrentTime = UTCTime +data WCallCommand + = WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} + | WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} + | WCCallAnswer {answer :: Text, iceCandidates :: Text} + +$(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "WCCall") ''WCallCommand) + serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> String serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ @@ -1633,16 +1639,6 @@ supporedBrowsers callType | encryptedCall callType = " (only Chrome and Safari support e2e encryption for WebRTC, Safari may require enabling WebRTC insertable streams)" | otherwise = "" -data WCallCommand - = WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} - | WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} - | WCCallAnswer {answer :: Text, iceCandidates :: Text} - deriving (Generic) - -instance ToJSON WCallCommand where - toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "WCCall" - toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "WCCall" - viewVersionInfo :: ChatLogLevel -> CoreVersionInfo -> [StyledString] viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCommit} = map plain $ diff --git a/stack.yaml b/stack.yaml index f0fcbab1de..58a921303b 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + commit: 511d793b927b1e2f12999e0829718671b3a8f0cb - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index aa36b397a3..d8e98513c7 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,6 +1,7 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -fno-warn-orphans #-} @@ -9,8 +10,9 @@ module MobileTests where import ChatTests.Utils import Control.Monad.Except import Crypto.Random (getRandomBytes) -import Data.Aeson (FromJSON (..)) +import Data.Aeson (FromJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS @@ -256,9 +258,11 @@ testMediaCApi _ = do (f cKeyStr ptr cLen >>= peekCAString) `shouldReturn` "" getByteString ptr cLen -instance FromJSON WriteFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "WF" +instance FromJSON WriteFileResult where + parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "WF") ''WriteFileResult) -instance FromJSON ReadFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RF" +instance FromJSON ReadFileResult where + parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) testFileCApi :: FilePath -> FilePath -> IO () testFileCApi fileName tmp = do From d90da57f1283e0c2f3ec18f9e9dfa77a8b1d4074 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 29 Oct 2023 19:06:32 +0000 Subject: [PATCH 024/174] core: store/get remote files (#3289) * core: store remote files (wip) * fix/test store remote file * get remote file * get file * validate remote file metadata before sending to controller * CLI commands, test * update store method --- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 84 +++++----- src/Simplex/Chat/Archive.hs | 1 + src/Simplex/Chat/Controller.hs | 30 ++-- src/Simplex/Chat/Files.hs | 27 ++++ src/Simplex/Chat/Messages.hs | 5 +- src/Simplex/Chat/Mobile/File.hs | 2 +- src/Simplex/Chat/Remote.hs | 137 ++++++++++++---- src/Simplex/Chat/Remote/Protocol.hs | 107 ++++++------- src/Simplex/Chat/Remote/Transport.hs | 27 ++++ src/Simplex/Chat/Remote/Types.hs | 22 ++- src/Simplex/Chat/Store/Files.hs | 53 ++++-- src/Simplex/Chat/View.hs | 43 +++-- tests/RemoteTests.hs | 230 ++++++++++++++++++++------- 14 files changed, 543 insertions(+), 227 deletions(-) create mode 100644 src/Simplex/Chat/Files.hs create mode 100644 src/Simplex/Chat/Remote/Transport.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e9036ea604..f831bf540c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -32,6 +32,7 @@ library Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Core + Simplex.Chat.Files Simplex.Chat.Help Simplex.Chat.Markdown Simplex.Chat.Messages @@ -131,6 +132,7 @@ library Simplex.Chat.Remote.Discovery Simplex.Chat.Remote.Multicast Simplex.Chat.Remote.Protocol + Simplex.Chat.Remote.Transport Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e8049dcbb5..e46a426d8c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -55,6 +55,7 @@ import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call import Simplex.Chat.Controller +import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent @@ -104,7 +105,7 @@ import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version import System.Exit (exitFailure, exitSuccess) -import System.FilePath (combine, splitExtensions, takeFileName, ()) +import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) import System.Random (randomRIO) import Text.Read (readMaybe) @@ -213,6 +214,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls <- atomically TM.empty localDeviceName <- newTVarIO "" -- TODO set in config remoteHostSessions <- atomically TM.empty + remoteHostsFolder <- newTVarIO Nothing remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder chatStoreChanged <- newTVarIO False @@ -246,6 +248,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls, localDeviceName, remoteHostSessions, + remoteHostsFolder, remoteCtrlSession, config, filesFolder, @@ -394,7 +397,7 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId s + Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId cmd s _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse @@ -403,8 +406,8 @@ execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` c execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd -execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ByteString -> m ChatResponse -execRemoteCommand u rhId s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh s +execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ChatCommand -> ByteString -> m ChatResponse +execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh cmd s handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError)) @@ -542,6 +545,10 @@ processChatCommand = \case createDirectoryIfMissing True ff asks filesFolder >>= atomically . (`writeTVar` Just ff) ok_ + SetRemoteHostsFolder rf -> do + createDirectoryIfMissing True rf + chatWriteVar remoteHostsFolder $ Just rf + ok_ APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ @@ -1795,15 +1802,15 @@ processChatCommand = \case asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "") - SendImage chatName f -> withUser $ \user -> do + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "") + SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName - filePath <- toFSFilePath f - unless (any (`isSuffixOf` map toLower f) imageExtensions) $ throwChatError CEFileImageType {filePath} + filePath <- toFSFilePath fPath + unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCImage "" fixedImagePreview) + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" @@ -1905,19 +1912,21 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - SetLocalDeviceName name -> withUser $ \_ -> chatWriteVar localDeviceName name >> ok_ + SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_ CreateRemoteHost -> CRRemoteHostCreated <$> createRemoteHost ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts StartRemoteHost rh -> startRemoteHost rh >> ok_ StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ - StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ - RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) - AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ - RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ - StopRemoteCtrl -> stopRemoteCtrl >> ok_ - ListRemoteCtrls -> CRRemoteCtrlList <$> listRemoteCtrls - DeleteRemoteCtrl rc -> deleteRemoteCtrl rc >> ok_ + StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath + GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_ + StartRemoteCtrl -> withUser_ $ startRemoteCtrl (execChatCommand Nothing) >> ok_ + RegisterRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) + AcceptRemoteCtrl rc -> withUser_ $ acceptRemoteCtrl rc >> ok_ + RejectRemoteCtrl rc -> withUser_ $ rejectRemoteCtrl rc >> ok_ + StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ + ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls + DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -2173,14 +2182,14 @@ processChatCommand = \case withServerProtocol p action = case userProtocol p of Just Dict -> action _ -> throwChatError $ CEServerProtocol $ AProtocolType p - forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse + forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> m ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case - FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> forward filePath - FTSnd {fileTransferMeta = FileTransferMeta {filePath}} -> forward filePath + FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}, cryptoArgs} -> forward filePath cryptoArgs + FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \f -> f.cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where - forward = processChatCommand . sendCommand chatName + forward path cfArgs = processChatCommand . sendCommand chatName $ CryptoFile path cfArgs getGroupAndMemberId :: User -> GroupName -> ContactName -> m (GroupId, GroupMemberId) getGroupAndMemberId user gName groupMemberName = withStore $ \db -> do @@ -2575,10 +2584,9 @@ startReceivingFile user fileId = do getRcvFilePath :: forall m. ChatMonad m => FileTransferId -> Maybe FilePath -> String -> Bool -> m FilePath getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of Nothing -> - asks filesFolder >>= readTVarIO >>= \case - Nothing -> do - dir <- (`combine` "Downloads") <$> getHomeDirectory - ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory + chatReadVar filesFolder >>= \case + Nothing -> + getDefaultFilesFolder >>= (`uniqueCombine` fn) >>= createEmptyFile Just filesFolder -> @@ -2607,18 +2615,6 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of getTmpHandle :: FilePath -> m Handle getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) -uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath -uniqueCombine filePath fileName = tryCombine (0 :: Int) - where - tryCombine n = - let (name, ext) = splitExtensions fileName - suffix = if n == 0 then "" else "_" <> show n - f = filePath `combine` (name <> suffix <> ext) - in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) - -getChatTempDirectory :: ChatMonad m => m FilePath -getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure - acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do subMode <- chatReadVar subscriptionMode @@ -5575,6 +5571,9 @@ withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> ifM chatStarted (action user) (throwChatError CEChatNotStarted) +withUser_ :: ChatMonad m => m ChatResponse -> m ChatResponse +withUser_ = withUser . const + withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse withUserId userId action = withUser $ \user -> do checkSameUser userId user @@ -5635,6 +5634,7 @@ chatCommandP = "/_resubscribe all" $> ResubscribeAllConnections, "/_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), @@ -5809,8 +5809,8 @@ chatCommandP = "/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)), "/show " *> (ShowChatItem . Just <$> A.decimal), "/item info " *> (ShowChatItemInfo <$> chatNameP <* A.space <*> msgTextP), - ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath), - ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath), + ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> cryptoFileP), + ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> cryptoFileP), ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), @@ -5858,6 +5858,8 @@ chatCommandP = "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), + "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), + "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), "/start remote ctrl" $> StartRemoteCtrl, "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)), "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), @@ -5932,6 +5934,10 @@ chatCommandP = msgTextP = jsonP <|> textP stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePath = stringP + cryptoFileP = do + cfArgs <- optional $ CFArgs <$> (" key=" *> strP <* A.space) <*> (" nonce=" *> strP) + path <- filePath + pure $ CryptoFile path cfArgs memberRole = A.choice [ " owner" $> GROwner, diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index e0de971bda..dd098e016d 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, + archiveFilesFolder, ) where diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 66ab513a0d..bc4cfaaf89 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -178,6 +178,7 @@ data ChatController = ChatController currentCalls :: TMap ContactId Call, localDeviceName :: TVar Text, remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts + remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, @@ -224,6 +225,7 @@ data ChatCommand | ResubscribeAllConnections | SetTempFolder FilePath | SetFilesFolder FilePath + | SetRemoteHostsFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool @@ -393,8 +395,8 @@ data ChatCommand | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowChatItemInfo ChatName Text | ShowLiveItems Bool - | SendFile ChatName FilePath - | SendImage ChatName FilePath + | SendFile ChatName CryptoFile + | SendImage ChatName CryptoFile | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath @@ -419,6 +421,8 @@ data ChatCommand -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data + | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} + | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | ListRemoteCtrls @@ -440,22 +444,27 @@ allowRemoteCommand = \case StartChat {} -> False APIStopChat -> False APIActivateChat -> False - APISuspendChat {} -> False - SetTempFolder {} -> False + APISuspendChat _ -> False + SetTempFolder _ -> False QuitChat -> False CreateRemoteHost -> False ListRemoteHosts -> False - StartRemoteHost {} -> False + StartRemoteHost _ -> False -- SwitchRemoteHost {} -> False - StopRemoteHost {} -> False - DeleteRemoteHost {} -> False + StoreRemoteFile {} -> False + GetRemoteFile {} -> False + StopRemoteHost _ -> False + DeleteRemoteHost _ -> False RegisterRemoteCtrl {} -> False StartRemoteCtrl -> False ListRemoteCtrls -> False - AcceptRemoteCtrl {} -> False - RejectRemoteCtrl {} -> False + AcceptRemoteCtrl _ -> False + RejectRemoteCtrl _ -> False StopRemoteCtrl -> False - DeleteRemoteCtrl {} -> False + DeleteRemoteCtrl _ -> False + ExecChatStoreSQL _ -> False + ExecAgentStoreSQL _ -> False + SlowSQLQueries -> False _ -> True data ChatResponse @@ -627,6 +636,7 @@ data ChatResponse | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} + | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation diff --git a/src/Simplex/Chat/Files.hs b/src/Simplex/Chat/Files.hs new file mode 100644 index 0000000000..845b237cdf --- /dev/null +++ b/src/Simplex/Chat/Files.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} + +module Simplex.Chat.Files where + +import Control.Monad.IO.Class +import Simplex.Chat.Controller +import Simplex.Messaging.Util (ifM) +import System.FilePath (splitExtensions, combine) +import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, getHomeDirectory, doesDirectoryExist) + +uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath +uniqueCombine fPath fName = tryCombine (0 :: Int) + where + tryCombine n = + let (name, ext) = splitExtensions fName + suffix = if n == 0 then "" else "_" <> show n + f = fPath `combine` (name <> suffix <> ext) + in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) + +getChatTempDirectory :: ChatMonad m => m FilePath +getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure + +getDefaultFilesFolder :: ChatMonad m => m FilePath +getDefaultFilesFolder = do + dir <- (`combine` "Downloads") <$> getHomeDirectory + ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 2718b088ba..8ea33e0abb 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -24,6 +24,7 @@ import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Char (isSpace) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) @@ -53,7 +54,7 @@ data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection data ChatName = ChatName {chatType :: ChatType, chatName :: Text} deriving (Show) -chatTypeStr :: ChatType -> String +chatTypeStr :: ChatType -> Text chatTypeStr = \case CTDirect -> "@" CTGroup -> "#" @@ -61,7 +62,7 @@ chatTypeStr = \case CTContactConnection -> ":" chatNameStr :: ChatName -> String -chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name +chatNameStr (ChatName cType name) = T.unpack $ chatTypeStr cType <> if T.any isSpace name then "'" <> name <> "'" else name data ChatRef = ChatRef ChatType Int64 deriving (Eq, Show, Ord) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 99860bbfa3..1da64a3044 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -99,7 +99,7 @@ chatEncryptFile fromPath toPath = either WFError WFResult <$> runCatchExceptT encrypt where encrypt = do - cfArgs <- liftIO $ CF.randomArgs + cfArgs <- liftIO CF.randomArgs encryptFile fromPath toPath cfArgs pure cfArgs diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index c195b4631d..5344c4bea6 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -16,7 +16,7 @@ import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Control.Monad.Reader (asks) +import Control.Monad.Reader import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J @@ -34,21 +34,35 @@ import Data.Word (Word32) import Network.HTTP2.Server (responseStreaming) import qualified Network.HTTP.Types as N import Network.Socket (SockAddr (..), hostAddressToTuple) +import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller +import Simplex.Chat.Files +import Simplex.Chat.Messages (chatNameStr) import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Protocol +import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Files import Simplex.Chat.Store.Remote +import Simplex.Chat.Store.Shared +import Simplex.Chat.Types (User (..)) +import Simplex.Chat.Util (encryptFile) +import Simplex.FileTransfer.Description (FileDigest (..)) 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.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) -import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) -import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=)) -import System.FilePath (()) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>)) +import System.FilePath ((), takeFileName) import UnliftIO +import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) +import Data.Functor (($>)) +import Control.Applicative ((<|>)) -- * Desktop side @@ -110,7 +124,7 @@ startRemoteHost rhId = do toView $ CRRemoteHostConnected RemoteHostInfo { remoteHostId = rhId, storePath = storePath, - displayName = remoteDeviceName remoteHostClient, + displayName = hostDeviceName remoteHostClient, remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName}, sessionActive = True } @@ -178,9 +192,57 @@ deleteRemoteHost rhId = do Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) -processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ByteString -> m ChatResponse -processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} s = liftRH remoteHostId $ remoteSend rhc s -processRemoteCommand _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started" +storeRemoteFile :: forall m. ChatMonad m => RemoteHostId -> Maybe Bool -> FilePath -> m CryptoFile +storeRemoteFile rhId encrypted_ localPath = do + RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId + case remoteHostClient of + Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing + Just c@RemoteHostClient {encryptHostFiles} -> do + let encrypt = fromMaybe encryptHostFiles encrypted_ + cf@CryptoFile {filePath} <- if encrypt then encryptLocalFile else pure $ CF.plain localPath + filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath) + hf_ <- chatReadVar remoteHostsFolder + forM_ hf_ $ \hf -> do + let rhf = hf storePath archiveFilesFolder + hPath = rhf takeFileName filePath' + createDirectoryIfMissing True rhf + (if encrypt then renameFile else copyFile) filePath hPath + pure (cf :: CryptoFile) {filePath = filePath'} + where + encryptLocalFile :: m CryptoFile + encryptLocalFile = do + tmpDir <- getChatTempDirectory + createDirectoryIfMissing True tmpDir + tmpFile <- tmpDir `uniqueCombine` takeFileName localPath + cfArgs <- liftIO CF.randomArgs + liftError (ChatError . CEFileWrite tmpFile) $ encryptFile localPath tmpFile cfArgs + pure $ CryptoFile tmpFile $ Just cfArgs + +getRemoteFile :: ChatMonad m => RemoteHostId -> RemoteFile -> m () +getRemoteFile rhId rf = do + RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId + case remoteHostClient of + Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing + Just c -> do + dir <- ( storePath archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar remoteHostsFolder) + createDirectoryIfMissing True dir + liftRH rhId $ remoteGetFile c dir rf + +processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ChatCommand -> ByteString -> m ChatResponse +processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} cmd s = case cmd of + SendFile chatName f -> sendFile "/f" chatName f + SendImage chatName f -> sendFile "/img" chatName f + _ -> liftRH remoteHostId $ remoteSend rhc s + where + sendFile cmdName chatName (CryptoFile path cfArgs) = do + -- don't encrypt in host if already encrypted locally + CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path + let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption + liftRH remoteHostId $ remoteSend rhc $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + cryptoFileStr CryptoFile {filePath, cryptoArgs} = + maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs + <> encodeUtf8 (T.pack filePath) +processRemoteCommand _ _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started" liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError) @@ -218,20 +280,24 @@ handleRemoteCommand :: forall m . ChatMonad m => (ByteString -> m ChatResponse) handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftRC (tryRemoteError parseRequest) >>= \case - Right (getNext, rc) -> processCommand getNext rc `catchAny` (reply . RRProtocolError . RPEException . tshow) + Right (getNext, rc) -> do + chatReadVar currentUser >>= \case + Nothing -> replyError $ ChatError CENoActiveUser + Just user -> processCommand user getNext rc `catchChatError` replyError Left e -> reply $ RRProtocolError e where parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand) parseRequest = do (header, getNext) <- parseHTTP2Body request reqBody (getNext,) <$> liftEitherWith (RPEInvalidJSON . T.pack) (J.eitherDecodeStrict' header) - processCommand :: GetChunk -> RemoteCommand -> m () - processCommand getNext = \case + replyError = reply . RRChatResponse . CRChatCmdError Nothing + processCommand :: User -> GetChunk -> RemoteCommand -> m () + processCommand user getNext = \case RCHello {deviceName = desktopName} -> handleHello desktopName >>= reply RCSend {command} -> handleSend execChatCommand command >>= reply RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply - RCStoreFile {fileSize, encrypt} -> handleStoreFile fileSize encrypt getNext >>= reply - RCGetFile {filePath} -> handleGetFile filePath replyWith + RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile fileName fileSize fileDigest getNext >>= reply + RCGetFile {file} -> handleGetFile user file replyWith reply :: RemoteResponse -> m () reply = (`replyWith` \_ -> pure ()) replyWith :: Respond m @@ -258,7 +324,8 @@ handleHello :: ChatMonad m => Text -> m RemoteResponse handleHello desktopName = do logInfo $ "Hello from " <> tshow desktopName mobileName <- chatReadVar localDeviceName - pure RRHello {encoding = localEncoding, deviceName = mobileName} + encryptFiles <- chatReadVar encryptLocalFiles + pure RRHello {encoding = localEncoding, deviceName = mobileName, encryptFiles} handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse handleSend execChatCommand command = do @@ -272,20 +339,36 @@ handleRecv time events = do logDebug $ "Recv: " <> tshow time RRChatEvent <$> (timeout time . atomically $ readTBQueue events) -handleStoreFile :: ChatMonad m => Word32 -> Maybe Bool -> GetChunk -> m RemoteResponse -handleStoreFile _fileSize _encrypt _getNext = error "TODO" <$ logError "TODO: handleStoreFile" +-- TODO this command could remember stored files and return IDs to allow removing files that are not needed. +-- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files). +handleStoreFile :: forall m. ChatMonad m => FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse +handleStoreFile fileName fileSize fileDigest getChunk = + either RRProtocolError RRFileStored <$> (chatReadVar filesFolder >>= storeFile) + where + storeFile :: Maybe FilePath -> m (Either RemoteProtocolError FilePath) + storeFile = \case + Just ff -> takeFileName <$$> storeFileTo ff + Nothing -> storeFileTo =<< getDefaultFilesFolder + storeFileTo :: FilePath -> m (Either RemoteProtocolError FilePath) + storeFileTo dir = liftRC . tryRemoteError $ do + filePath <- dir `uniqueCombine` fileName + receiveRemoteFile getChunk fileSize fileDigest filePath + pure filePath -handleGetFile :: ChatMonad m => FilePath -> Respond m -> m () -handleGetFile path reply = do - logDebug $ "GetFile: " <> tshow path - withFile path ReadMode $ \h -> do - fileSize' <- hFileSize h - when (fileSize' > toInteger (maxBound :: Word32)) $ throwIO RPEFileTooLarge - let fileSize = fromInteger fileSize' - reply RRFile {fileSize} $ \send -> hSendFile h send fileSize +handleGetFile :: ChatMonad m => User -> RemoteFile -> Respond m -> m () +handleGetFile User {userId} RemoteFile{userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do + logDebug $ "GetFile: " <> tshow filePath + unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId} + path <- maybe filePath ( filePath) <$> chatReadVar filesFolder + withStore $ \db -> do + cf <- getLocalCryptoFile db commandUserId fileId sent + unless (cf == cf') $ throwError $ SEFileNotFound fileId + liftRC (tryRemoteError $ getFileInfo path) >>= \case + Left e -> reply (RRProtocolError e) $ \_ -> pure () + Right (fileSize, fileDigest) -> + withFile path ReadMode $ \h -> + reply RRFile {fileSize, fileDigest} $ \send -> hSendFile h send fileSize --- TODO the problem with this code was that it wasn't clear where the recursion can happen, --- by splitting receiving and processing to two functions it becomes clear discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process where diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index aa4ebe5952..2deb177775 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -20,7 +20,7 @@ import Data.Aeson.TH (deriveJSON) import qualified Data.Aeson.Types as JT import Data.ByteString (ByteString) import Data.ByteString.Builder (Builder, word32BE, lazyByteString) -import qualified Data.ByteString.Lazy as BL +import qualified Data.ByteString.Lazy as LB import Data.String (fromString) import Data.Text (Text) import Data.Text.Encoding (decodeUtf8) @@ -28,34 +28,35 @@ import Data.Word (Word32) import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H import Network.Transport.Internal (decodeWord32) -import Simplex.Chat.Controller (ChatResponse) +import Simplex.Chat.Controller +import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types -import Simplex.Messaging.Crypto.File (CryptoFile) +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import Simplex.Messaging.Transport.Buffer (getBuffered) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) -import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile, hSendFile) -import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow, whenM) -import System.FilePath (()) +import Simplex.Messaging.Transport.HTTP2.File (hSendFile) +import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow) +import System.FilePath ((), takeFileName) import UnliftIO -import UnliftIO.Directory (doesFileExist, getFileSize) data RemoteCommand = RCHello {deviceName :: Text} | RCSend {command :: Text} -- TODO maybe ChatCommand here? | RCRecv {wait :: Int} -- this wait should be less than HTTP timeout | -- local file encryption is determined by the host, but can be overridden for videos - RCStoreFile {fileSize :: Word32, encrypt :: Maybe Bool} -- requires attachment - | RCGetFile {filePath :: FilePath} + RCStoreFile {fileName :: String, fileSize :: Word32, fileDigest :: FileDigest} -- requires attachment + | RCGetFile {file :: RemoteFile} deriving (Show) data RemoteResponse - = RRHello {encoding :: PlatformEncoding, deviceName :: Text} + = RRHello {encoding :: PlatformEncoding, deviceName :: Text, encryptFiles :: Bool} | RRChatResponse {chatResponse :: ChatResponse} | RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout - | RRFileStored {fileSource :: CryptoFile} - | RRFile {fileSize :: Word32} -- provides attachment + | RRFileStored {filePath :: String} + | RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side deriving (Show) @@ -67,14 +68,13 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient createRemoteHostClient httpClient desktopName = do - logInfo "Sending initial hello" - (_getNext, rr) <- sendRemoteCommand httpClient localEncoding Nothing RCHello {deviceName = desktopName} - case rr of - rrh@RRHello {encoding, deviceName = mobileName} -> do - logInfo $ "Got initial hello: " <> tshow rrh + logDebug "Sending initial hello" + sendRemoteCommand' httpClient localEncoding Nothing RCHello {deviceName = desktopName} >>= \case + RRHello {encoding, deviceName = mobileName, encryptFiles} -> do + logDebug "Got initial hello" when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding - pure RemoteHostClient {remoteEncoding = encoding, remoteDeviceName = mobileName, httpClient} - _ -> throwError $ RPEUnexpectedResponse $ tshow rr + pure RemoteHostClient {hostEncoding = encoding, hostDeviceName = mobileName, httpClient, encryptHostFiles = encryptFiles} + r -> badResponse r closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient @@ -82,48 +82,37 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client -- ** Commands remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse -remoteSend RemoteHostClient {httpClient, remoteEncoding} cmd = do - (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCSend {command = decodeUtf8 cmd} - case rr of +remoteSend RemoteHostClient {httpClient, hostEncoding} cmd = + sendRemoteCommand' httpClient hostEncoding Nothing RCSend {command = decodeUtf8 cmd} >>= \case RRChatResponse cr -> pure cr - _ -> throwError $ RPEUnexpectedResponse $ tshow rr + r -> badResponse r remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) -remoteRecv RemoteHostClient {httpClient, remoteEncoding} ms = do - (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCRecv {wait=ms} - case rr of +remoteRecv RemoteHostClient {httpClient, hostEncoding} ms = + sendRemoteCommand' httpClient hostEncoding Nothing RCRecv {wait = ms} >>= \case RRChatEvent cr_ -> pure cr_ - _ -> throwError $ RPEUnexpectedResponse $ tshow rr + r -> badResponse r -remoteStoreFile :: RemoteHostClient -> FilePath -> Maybe Bool -> ExceptT RemoteProtocolError IO CryptoFile -remoteStoreFile RemoteHostClient {httpClient, remoteEncoding} localPath encrypt = do - (_getNext, rr) <- withFile localPath ReadMode $ \h -> do - fileSize' <- hFileSize h - when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileTooLarge - let fileSize = fromInteger fileSize' - sendRemoteCommand httpClient remoteEncoding (Just (h, fileSize)) RCStoreFile {encrypt, fileSize} - case rr of - RRFileStored {fileSource} -> pure fileSource - _ -> throwError $ RPEUnexpectedResponse $ tshow rr +remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath +remoteStoreFile RemoteHostClient {httpClient, hostEncoding} localPath fileName = do + (fileSize, fileDigest) <- getFileInfo localPath + let send h = sendRemoteCommand' httpClient hostEncoding (Just (h, fileSize)) RCStoreFile {fileName, fileSize, fileDigest} + withFile localPath ReadMode send >>= \case + RRFileStored {filePath = filePath'} -> pure filePath' + r -> badResponse r --- TODO this should work differently for CLI and UI clients --- CLI - potentially, create new unique names and report them as created --- UI - always use the same names and report error if file already exists --- alternatively, CLI should also use a fixed folder for remote session --- Possibly, path in the database should be optional and CLI commands should allow configuring it per session or use temp or download folder -remoteGetFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath -remoteGetFile RemoteHostClient {httpClient, remoteEncoding} baseDir filePath = do - (getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCGetFile {filePath} - expectedSize <- case rr of - RRFile {fileSize} -> pure fileSize - _ -> throwError $ RPEUnexpectedResponse $ tshow rr - whenM (liftIO $ doesFileExist localFile) $ throwError RPEStoredFileExists - rc <- liftIO $ withFile localFile WriteMode $ \h -> hReceiveFile getNext h expectedSize - when (rc /= 0) $ throwError RPEInvalidSize - whenM ((== expectedSize) . fromIntegral <$> getFileSize localFile) $ throwError RPEInvalidSize - pure localFile - where - localFile = baseDir filePath +remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () +remoteGetFile RemoteHostClient {httpClient, hostEncoding} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = + sendRemoteCommand httpClient hostEncoding Nothing RCGetFile {file = rf} >>= \case + (getChunk, RRFile {fileSize, fileDigest}) -> do + -- TODO we could optimize by checking size and hash before receiving the file + let localPath = destDir takeFileName filePath + receiveRemoteFile getChunk fileSize fileDigest localPath + (_, r) -> badResponse r + +-- TODO validate there is no attachment +sendRemoteCommand' :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse +sendRemoteCommand' http remoteEncoding attachment_ rc = snd <$> sendRemoteCommand http remoteEncoding attachment_ rc sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) sendRemoteCommand http remoteEncoding attachment_ rc = do @@ -139,6 +128,12 @@ sendRemoteCommand http remoteEncoding attachment_ rc = do Just (h, sz) -> hSendFile h send sz flush +badResponse :: RemoteResponse -> ExceptT RemoteProtocolError IO a +badResponse = \case + RRProtocolError e -> throwError e + -- TODO handle chat errors? + r -> throwError $ RPEUnexpectedResponse $ tshow r + -- * Transport-level wrappers convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value @@ -183,7 +178,7 @@ pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) -- | Convert a command or a response into 'Builder'. sizePrefixedEncode :: J.ToJSON a => a -> Builder -sizePrefixedEncode value = word32BE (fromIntegral $ BL.length json) <> lazyByteString json +sizePrefixedEncode value = word32BE (fromIntegral $ LB.length json) <> lazyByteString json where json = J.encode value diff --git a/src/Simplex/Chat/Remote/Transport.hs b/src/Simplex/Chat/Remote/Transport.hs new file mode 100644 index 0000000000..bf798444c0 --- /dev/null +++ b/src/Simplex/Chat/Remote/Transport.hs @@ -0,0 +1,27 @@ +module Simplex.Chat.Remote.Transport where + +import Control.Monad +import Control.Monad.Except +import Data.ByteString (ByteString) +import qualified Data.ByteString.Lazy as LB +import Data.Word (Word32) +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Chat.Remote.Types +import qualified Simplex.Messaging.Crypto.Lazy as LC +import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile) +import UnliftIO +import UnliftIO.Directory (getFileSize) + +receiveRemoteFile :: (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO () +receiveRemoteFile getChunk fileSize fileDigest toPath = do + diff <- liftIO $ withFile toPath WriteMode $ \h -> hReceiveFile getChunk h fileSize + unless (diff == 0) $ throwError RPEFileSize + digest <- liftIO $ LC.sha512Hash <$> LB.readFile toPath + unless (FileDigest digest == fileDigest) $ throwError RPEFileDigest + +getFileInfo :: FilePath -> ExceptT RemoteProtocolError IO (Word32, FileDigest) +getFileInfo filePath = do + fileDigest <- liftIO $ FileDigest . LC.sha512Hash <$> LB.readFile filePath + fileSize' <- getFileSize filePath + when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileSize + pure (fromInteger fileSize', fileDigest) diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index d16955199e..6611d04471 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -10,14 +10,16 @@ import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import UnliftIO data RemoteHostClient = RemoteHostClient - { remoteEncoding :: PlatformEncoding, - remoteDeviceName :: Text, - httpClient :: HTTP2Client + { hostEncoding :: PlatformEncoding, + hostDeviceName :: Text, + httpClient :: HTTP2Client, + encryptHostFiles :: Bool } data RemoteHostSession = RemoteHostSession @@ -32,7 +34,8 @@ data RemoteProtocolError | RPEIncompatibleEncoding | RPEUnexpectedFile | RPENoFile - | RPEFileTooLarge + | RPEFileSize + | RPEFileDigest | RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent | RPEStoredFileExists -- ^ A file already exists in the destination position | RPEHTTP2 {http2Error :: Text} @@ -87,7 +90,14 @@ data RemoteCtrlInfo = RemoteCtrlInfo } deriving (Show) --- TODO: put into a proper place +data RemoteFile = RemoteFile + { userId :: Int64, + fileId :: Int64, + sent :: Bool, + fileSource :: CryptoFile + } + deriving (Show) + data PlatformEncoding = PESwift | PEKotlin @@ -122,3 +132,5 @@ $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrl) $(J.deriveJSON defaultJSON ''RemoteCtrlInfo) + +$(J.deriveJSON defaultJSON ''RemoteFile) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index a710696dad..95e586919d 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -2,6 +2,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -71,6 +72,7 @@ module Simplex.Chat.Store.Files getSndFileTransfer, getSndFileTransfers, getContactFileInfo, + getLocalCryptoFile, updateDirectCIFileStatus, ) where @@ -602,7 +604,10 @@ getRcvFileTransferById db fileId = do (user,) <$> getRcvFileTransfer db user fileId getRcvFileTransfer :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer -getRcvFileTransfer db User {userId} fileId = do +getRcvFileTransfer db User {userId} = getRcvFileTransfer_ db userId + +getRcvFileTransfer_ :: DB.Connection -> UserId -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer +getRcvFileTransfer_ db userId fileId = do rftRow <- ExceptT . firstRow id (SERcvFileNotFound fileId) $ DB.query @@ -808,25 +813,26 @@ getFileTransferProgress db user fileId = do getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer getFileTransfer db user@User {userId} fileId = - fileTransfer =<< liftIO getFileTransferRow + fileTransfer =<< liftIO (getFileTransferRow_ db userId fileId) where fileTransfer :: [(Maybe Int64, Maybe Int64)] -> ExceptT StoreError IO FileTransfer fileTransfer [(Nothing, Just _)] = FTRcv <$> getRcvFileTransfer db user fileId fileTransfer _ = do (ftm, fts) <- getSndFileTransfer db user fileId pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts} - getFileTransferRow :: IO [(Maybe Int64, Maybe Int64)] - getFileTransferRow = - DB.query - db - [sql| - SELECT s.file_id, r.file_id - FROM files f - LEFT JOIN snd_files s ON s.file_id = f.file_id - LEFT JOIN rcv_files r ON r.file_id = f.file_id - WHERE user_id = ? AND f.file_id = ? - |] - (userId, fileId) + +getFileTransferRow_ :: DB.Connection -> UserId -> Int64 -> IO [(Maybe Int64, Maybe Int64)] +getFileTransferRow_ db userId fileId = + DB.query + db + [sql| + SELECT s.file_id, r.file_id + FROM files f + LEFT JOIN snd_files s ON s.file_id = f.file_id + LEFT JOIN rcv_files r ON r.file_id = f.file_id + WHERE user_id = ? AND f.file_id = ? + |] + (userId, fileId) getSndFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (FileTransferMeta, [SndFileTransfer]) getSndFileTransfer db user fileId = do @@ -861,7 +867,10 @@ getSndFileTransfers_ db userId fileId = Nothing -> Left $ SESndFileInvalid fileId getFileTransferMeta :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransferMeta -getFileTransferMeta db User {userId} fileId = +getFileTransferMeta db User {userId} = getFileTransferMeta_ db userId + +getFileTransferMeta_ :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO FileTransferMeta +getFileTransferMeta_ db userId fileId = ExceptT . firstRow fileTransferMeta (SEFileNotFound fileId) $ DB.query db @@ -883,6 +892,20 @@ getContactFileInfo db User {userId} Contact {contactId} = map toFileInfo <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ?") (userId, contactId) +getLocalCryptoFile :: DB.Connection -> UserId -> Int64 -> Bool -> ExceptT StoreError IO CryptoFile +getLocalCryptoFile db userId fileId sent = + liftIO (getFileTransferRow_ db userId fileId) >>= \case + [(Nothing, Just _)] -> do + when sent $ throwError $ SEFileNotFound fileId + RcvFileTransfer {fileStatus, cryptoArgs} <- getRcvFileTransfer_ db userId fileId + case fileStatus of + RFSComplete RcvFileInfo {filePath} -> pure $ CryptoFile filePath cryptoArgs + _ -> throwError $ SEFileNotFound fileId + _ -> do + unless sent $ throwError $ SEFileNotFound fileId + FileTransferMeta {filePath, xftpSndFile} <- getFileTransferMeta_ db userId fileId + pure $ CryptoFile filePath $ xftpSndFile >>= \f -> f.cryptoArgs + updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db user fileId fileStatus = do aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2a3b74da37..9ae00159b7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -14,6 +14,7 @@ module Simplex.Chat.View where import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) @@ -76,7 +77,7 @@ serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> Time serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] -responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case +responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] @@ -185,10 +186,10 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e] + CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileStartXFTP {} -> [] @@ -272,6 +273,9 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] + CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> + [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] + <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] @@ -1493,18 +1497,25 @@ savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource ["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath] savingFile' _ = ["saving file"] -- shouldn't happen -receivingFile_' :: Bool -> String -> AChatItem -> [StyledString] -receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) = - [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ +receivingFile_' :: (Maybe RemoteHostId, Maybe User) -> Bool -> String -> AChatItem -> [StyledString] +receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just f@(CryptoFile _ cfArgs_)}, chatDir}) = + [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ <> getRemoteFileStr where - cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"] - where - s = - if testView - then LB.toStrict $ J.encode cfArgs - else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce + cfArgsStr (Just cfArgs) = [plain (cryptoFileArgsStr testView cfArgs) | status == "completed"] cfArgsStr _ = [] -receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen + getRemoteFileStr = case hu of + (Just rhId, Just User {userId}) | status == "completed" -> + [ "File received to connected remote host " <> sShow rhId, + "To download to this device use:", + highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f})) + ] + _ -> [] +receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen + +cryptoFileArgsStr :: Bool -> CryptoFileArgs -> ByteString +cryptoFileArgsStr testView cfArgs@(CFArgs key nonce) + | testView = LB.toStrict $ J.encode cfArgs + | otherwise = "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce fileFrom :: ChatInfo c -> CIDirection c d -> StyledString fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct @@ -1818,8 +1829,8 @@ viewChatError logLevel = \case Nothing -> "" cId :: Connection -> StyledString cId conn = sShow conn.connId - ChatErrorRemoteCtrl todo'rc -> [sShow todo'rc] - ChatErrorRemoteHost remoteHostId todo'rh -> [sShow remoteHostId, sShow todo'rh] + ChatErrorRemoteCtrl e -> [plain $ "remote controller error: " <> show e] + ChatErrorRemoteHost rhId e -> [plain $ "remote host " <> show rhId <> " error: " <> show e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] sqliteError' = \case diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 452f9ca21d..be1d3c1a2e 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -9,7 +9,9 @@ import ChatClient import ChatTests.Utils import Control.Logger.Simple import Control.Monad +import qualified Data.Aeson as J import qualified Data.ByteString as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Network.HTTP.Types (ok200) @@ -17,10 +19,14 @@ import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S import qualified Network.Socket as N import qualified Network.TLS as TLS +import Simplex.Chat.Archive (archiveFilesFolder) +import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import qualified Simplex.Chat.Controller as Controller +import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types import qualified Simplex.Chat.Remote.Discovery as Discovery import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..)) @@ -28,7 +34,7 @@ import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util -import System.FilePath (makeRelative, ()) +import System.FilePath (()) import Test.Hspec import UnliftIO import UnliftIO.Concurrent @@ -41,7 +47,9 @@ remoteTests = describe "Remote" $ do it "performs protocol handshake" remoteHandshakeTest it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check it "sends messages" remoteMessageTest - xit "sends files" remoteFileTest + describe "remote files" $ do + it "store/get/send/receive files" remoteStoreFileTest + it "should sends files from CLI wihtout /store" remoteCLIFileTest -- * Low-level TLS with ephemeral credentials @@ -159,32 +167,158 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob threadDelay 1000000 logNote "done" -remoteFileTest :: (HasCallStack) => FilePath -> IO () -remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do +remoteStoreFileTest :: HasCallStack => FilePath -> IO () +remoteStoreFileTest = + testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> + withXFTPServer $ do + let mobileFiles = "./tests/tmp/mobile_files" + mobile ##> ("/_files_folder " <> mobileFiles) + mobile <## "ok" + let desktopFiles = "./tests/tmp/desktop_files" + desktop ##> ("/_files_folder " <> desktopFiles) + desktop <## "ok" + let desktopHostFiles = "./tests/tmp/remote_hosts_data" + desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) + desktop <## "ok" + let bobFiles = "./tests/tmp/bob_files" + bob ##> ("/_files_folder " <> bobFiles) + bob <## "ok" + startRemote mobile desktop + contactBob desktop bob + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopHostStore <- case M.lookup 1 rhs of + Just RemoteHostSession {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + _ -> fail "Host session 1 should be started" + desktop ##> "/store remote file 1 tests/fixtures/test.pdf" + desktop <## "file test.pdf stored on remote host 1" + src <- B.readFile "tests/fixtures/test.pdf" + B.readFile (mobileFiles "test.pdf") `shouldReturn` src + B.readFile (desktopHostStore "test.pdf") `shouldReturn` src + desktop ##> "/store remote file 1 tests/fixtures/test.pdf" + desktop <## "file test_1.pdf stored on remote host 1" + B.readFile (mobileFiles "test_1.pdf") `shouldReturn` src + B.readFile (desktopHostStore "test_1.pdf") `shouldReturn` src + desktop ##> "/store remote file 1 encrypt=on tests/fixtures/test.pdf" + desktop <## "file test_2.pdf stored on remote host 1" + Just cfArgs@(CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine desktop + chatReadFile (mobileFiles "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) + chatReadFile (desktopHostStore "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) + + removeFile (desktopHostStore "test_1.pdf") + removeFile (desktopHostStore "test_2.pdf") + + -- cannot get file before it is used + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "SEFileNotFound" + -- send file not encrypted locally on mobile host + desktop ##> "/_send @2 json {\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}" + desktop <# "@bob sending a file" + desktop <# "/f @bob test_1.pdf" + desktop <## "use /fc 1 to cancel sending" + bob <# "alice> sending a file" + bob <# "alice> sends file test_1.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + concurrentlyN_ + [ do + desktop <## "completed uploading file 1 (test_1.pdf) for bob", + do + bob <## "saving file 1 from alice to test_1.pdf" + bob <## "started receiving file 1 (test_1.pdf) from alice" + bob <## "completed receiving file 1 (test_1.pdf) from alice" + ] + B.readFile (bobFiles "test_1.pdf") `shouldReturn` src + -- returns error for inactive user + desktop ##> "/get remote file 1 {\"userId\": 2, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "CEDifferentActiveUser" + -- returns error with incorrect file ID + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 2, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "SEFileNotFound" + -- gets file + doesFileExist (desktopHostStore "test_1.pdf") `shouldReturn` False + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + desktop <## "ok" + B.readFile (desktopHostStore "test_1.pdf") `shouldReturn` src + + -- send file encrypted locally on mobile host + desktop ##> ("/_send @2 json {\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}") + desktop <# "/f @bob test_2.pdf" + desktop <## "use /fc 2 to cancel sending" + bob <# "alice> sends file test_2.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> "/fr 2" + concurrentlyN_ + [ do + desktop <## "completed uploading file 2 (test_2.pdf) for bob", + do + bob <## "saving file 2 from alice to test_2.pdf" + bob <## "started receiving file 2 (test_2.pdf) from alice" + bob <## "completed receiving file 2 (test_2.pdf) from alice" + ] + B.readFile (bobFiles "test_2.pdf") `shouldReturn` src + + -- receive file via remote host + copyFile "./tests/fixtures/test.jpg" (bobFiles "test.jpg") + bob #> "/f @alice test.jpg" + bob <## "use /fc 3 to cancel sending" + desktop <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + desktop <## "use /fr 3 [/ | ] to receive it" + desktop ##> "/fr 3 encrypt=on" + concurrentlyN_ + [ do + bob <## "completed uploading file 3 (test.jpg) for alice", + do + desktop <## "saving file 3 from bob to test.jpg" + desktop <## "started receiving file 3 (test.jpg) from bob" + desktop <## "completed receiving file 3 (test.jpg) from bob" + ] + Just cfArgs'@(CFArgs key' nonce') <- J.decode . LB.pack <$> getTermLine desktop + desktop <## "File received to connected remote host 1" + desktop <## "To download to this device use:" + getCmd <- getTermLine desktop + getCmd `shouldBe` ("/get remote file 1 {\"userId\":1,\"fileId\":3,\"sent\":false,\"fileSource\":{\"filePath\":\"test.jpg\",\"cryptoArgs\":" <> LB.unpack (J.encode cfArgs') <> "}}") + src' <- B.readFile (bobFiles "test.jpg") + chatReadFile (mobileFiles "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src') + doesFileExist (desktopHostStore "test.jpg") `shouldReturn` False + -- returns error with incorrect key + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 3, \"sent\": false, \"fileSource\": {\"filePath\": \"test.jpg\", \"cryptoArgs\": null}}" + hostError desktop "SEFileNotFound" + doesFileExist (desktopHostStore "test.jpg") `shouldReturn` False + desktop ##> getCmd + desktop <## "ok" + chatReadFile (desktopHostStore "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src') + + 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/" let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" - let desktopFiles = "./tests/tmp/desktop_files" - desktop ##> ("/_files_folder " <> desktopFiles) + let bobFiles = "./tests/tmp/bob_files/" + createDirectoryIfMissing True bobFiles + let desktopHostFiles = "./tests/tmp/remote_hosts_data" + desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) desktop <## "ok" - let bobFiles = "./tests/tmp/bob_files" - bob ##> ("/_files_folder " <> bobFiles) - bob <## "ok" startRemote mobile desktop contactBob desktop bob rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) - desktopStore <- case M.lookup 1 rhs of - Just RemoteHostSession {storePath} -> pure storePath + desktopHostStore <- case M.lookup 1 rhs of + Just RemoteHostSession {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" - doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False - doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False mobileName <- userName mobile - bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" - bob #> ("/f @" <> mobileName <> " " <> bobsFile) + bob #> ("/f @" <> mobileName <> " " <> "tests/fixtures/test.pdf") bob <## "use /fc 1 to cancel sending" desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" @@ -192,63 +326,47 @@ remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop ##> "/fr 1" concurrentlyN_ [ do - bob <## "started sending file 1 (test.pdf) to alice" - bob <## "completed sending file 1 (test.pdf) to alice", + bob <## "completed uploading file 1 (test.pdf) for alice", do desktop <## "saving file 1 from bob to test.pdf" desktop <## "started receiving file 1 (test.pdf) from bob" + desktop <## "completed receiving file 1 (test.pdf) from bob" ] - let desktopReceived = desktopFiles desktopStore "test.pdf" - -- desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") - desktop <## "completed receiving file 1 (test.pdf) from bob" - bobsFileSize <- getFileSize bobsFile - -- getFileSize desktopReceived `shouldReturn` bobsFileSize - bobsFileBytes <- B.readFile bobsFile - -- B.readFile desktopReceived `shouldReturn` bobsFileBytes - -- test file transit on mobile - mobile ##> "/fs 1" - mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" - getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize - B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes + desktop <## "File received to connected remote host 1" + desktop <## "To download to this device use:" + getCmd <- getTermLine desktop + src <- B.readFile "tests/fixtures/test.pdf" + B.readFile (mobileFiles "test.pdf") `shouldReturn` src + doesFileExist (desktopHostStore "test.pdf") `shouldReturn` False + desktop ##> getCmd + desktop <## "ok" + B.readFile (desktopHostStore "test.pdf") `shouldReturn` src - logNote "file received" - - desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f - logNote $ "sending " <> tshow desktopFile - doesFileExist (bobFiles "logo.jpg") `shouldReturn` False - doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False - desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" - desktop <# "@bob hi, sending a file" - desktop <# "/f @bob logo.jpg" + desktop `send` "/f @bob tests/fixtures/test.jpg" + desktop <# "/f @bob test.jpg" desktop <## "use /fc 2 to cancel sending" - bob <# "alice> hi, sending a file" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 2 [/ | ] to receive it" - bob ##> "/fr 2" + bob ##> ("/fr 2 " <> bobFiles) concurrentlyN_ [ do - bob <## "saving file 2 from alice to logo.jpg" - bob <## "started receiving file 2 (logo.jpg) from alice" - bob <## "completed receiving file 2 (logo.jpg) from alice" - bob ##> "/fs 2" - bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg", + desktop <## "completed uploading file 2 (test.jpg) for bob", do - desktop <## "started sending file 2 (logo.jpg) to bob" - desktop <## "completed sending file 2 (logo.jpg) to bob" + bob <## "saving file 2 from alice to ./tests/tmp/bob_files/test.jpg" + bob <## "started receiving file 2 (test.jpg) from alice" + bob <## "completed receiving file 2 (test.jpg) from alice" ] - desktopFileSize <- getFileSize desktopFile - getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize - getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize - desktopFileBytes <- B.readFile desktopFile - B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes - B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes - - logNote "file sent" + src' <- B.readFile "tests/fixtures/test.jpg" + B.readFile (mobileFiles "test.jpg") `shouldReturn` src' + B.readFile (desktopHostStore "test.jpg") `shouldReturn` src' + B.readFile (bobFiles "test.jpg") `shouldReturn` src' stopMobile mobile desktop + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} -- * Utils From be44632b0bc71df56d034d16ffdce0b41dedd171 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 30 Oct 2023 16:00:54 +0200 Subject: [PATCH 025/174] implement some of the robust discovery rfc (#3283) * implement robust discovery * remove qualified --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 6 +- src/Simplex/Chat/Controller.hs | 11 +- src/Simplex/Chat/Remote.hs | 88 ++++++------ src/Simplex/Chat/Remote/Discovery.hs | 196 +++++++++++++++++--------- src/Simplex/Chat/Remote/Multicast.hsc | 9 +- src/Simplex/Chat/Remote/Protocol.hs | 4 +- src/Simplex/Chat/Remote/Types.hs | 181 +++++++++++++++++++++++- src/Simplex/Chat/Store/Remote.hs | 10 +- src/Simplex/Chat/View.hs | 9 +- tests/RemoteTests.hs | 66 ++++++--- 10 files changed, 430 insertions(+), 150 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e46a426d8c..383e045f8b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -213,6 +213,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty localDeviceName <- newTVarIO "" -- TODO set in config + multicastSubscribers <- newTMVarIO 0 remoteHostSessions <- atomically TM.empty remoteHostsFolder <- newTVarIO Nothing remoteCtrlSession <- newTVarIO Nothing @@ -247,6 +248,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen rcvFiles, currentCalls, localDeviceName, + multicastSubscribers, remoteHostSessions, remoteHostsFolder, remoteCtrlSession, @@ -5861,8 +5863,8 @@ chatCommandP = "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), "/start remote ctrl" $> StartRemoteCtrl, - "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)), - "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), + "/register remote ctrl " *> (RegisterRemoteCtrl <$> strP), + -- "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), "/list remote ctrls" $> ListRemoteCtrls, "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bc4cfaaf89..19e8dc34dc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -25,7 +25,7 @@ import Control.Monad.Reader import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?)) import qualified Data.Aeson as J -import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) @@ -40,6 +40,7 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) +import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -177,6 +178,7 @@ data ChatController = ChatController rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, localDeviceName :: TVar Text, + multicastSubscribers :: TMVar Int, remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers @@ -424,12 +426,12 @@ data ChatCommand | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers - | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake + | RegisterRemoteCtrl SignedOOB -- ^ Register OOB data for remote controller discovery and handshake | ListRemoteCtrls | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session - | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session + | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session | QuitChat | ShowVersion | DebugLocks @@ -634,6 +636,7 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} + | CRRemoteHostStarted {remoteHost :: RemoteHostInfo, sessionOOB :: Text} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} @@ -1069,7 +1072,7 @@ data RemoteCtrlSession = RemoteCtrlSession discoverer :: Async (), supervisor :: Async (), hostServer :: Maybe (Async ()), - discovered :: TMap C.KeyHash TransportHost, + discovered :: TMap C.KeyHash (TransportHost, Word16), accepted :: TMVar RemoteCtrlId, remoteOutputQ :: TBQueue ChatResponse } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 5344c4bea6..d6ccd25961 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Remote where +import Control.Applicative ((<|>)) import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -24,15 +25,16 @@ import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B +import Data.Functor (($>)) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Data.Word (Word32) -import Network.HTTP2.Server (responseStreaming) +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N +import Network.HTTP2.Server (responseStreaming) import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller @@ -51,18 +53,17 @@ import Simplex.FileTransfer.Description (FileDigest (..)) 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.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) -import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>)) -import System.FilePath ((), takeFileName) +import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) -import Data.Functor (($>)) -import Control.Applicative ((<|>)) -- * Desktop side @@ -108,11 +109,14 @@ startRemoteHost rhId = do toView (CRRemoteHostStopped rhId) -- only signal "stopped" when the session is unregistered cleanly -- block until some client is connected or an error happens logInfo $ "Remote host session connecting for " <> tshow rhId - httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks fingerprint credentials cleanupIO - logInfo $ "Remote host session connected for " <> tshow rhId rcName <- chatReadVar localDeviceName + localAddr <- asks multicastSubscribers >>= Discovery.getLocalAddress >>= maybe (throwError . ChatError $ CEInternalError "unable to get local address") pure + (dhKey, sigKey, ann, oob) <- Discovery.startSession (if rcName == "" then Nothing else Just rcName) (localAddr, read Discovery.DISCOVERY_PORT) fingerprint + toView CRRemoteHostStarted {remoteHost = remoteHostInfo rh True, sessionOOB = decodeUtf8 $ strEncode oob} + httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials cleanupIO + logInfo $ "Remote host session connected for " <> tshow rhId -- test connection and establish a protocol layer - remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient rcName + remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient dhKey rcName -- set up message polling oq <- asks outputQ asyncRegistered tasks . forever $ do @@ -125,7 +129,6 @@ startRemoteHost rhId = do { remoteHostId = rhId, storePath = storePath, displayName = hostDeviceName remoteHostClient, - remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName}, sessionActive = True } @@ -159,10 +162,9 @@ createRemoteHost = do ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) "Host" storePath <- liftIO randomStorePath let remoteName = "" -- will be passed from remote host in hello - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath remoteName caKey caCert - localName <- chatReadVar localDeviceName - let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = localName} - pure RemoteHostInfo {remoteHostId, storePath, displayName = remoteName, remoteCtrlOOB, sessionActive = False} + rhId <- withStore' $ \db -> insertRemoteHost db storePath remoteName caKey caCert + rh <- withStore $ \db -> getRemoteHost db rhId + pure $ remoteHostInfo rh False -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -171,16 +173,14 @@ randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 listRemoteHosts :: ChatMonad m => m [RemoteHostInfo] listRemoteHosts = do active <- chatReadVar remoteHostSessions - rcName <- chatReadVar localDeviceName - map (rhInfo active rcName) <$> withStore' getRemoteHosts + map (rhInfo active) <$> withStore' getRemoteHosts where - rhInfo active rcName rh@RemoteHost {remoteHostId} = - remoteHostInfo rh (M.member remoteHostId active) rcName + rhInfo active rh@RemoteHost {remoteHostId} = + remoteHostInfo rh (M.member remoteHostId active) -remoteHostInfo :: RemoteHost -> Bool -> Text -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, displayName, caCert} sessionActive rcName = - let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} - in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} +remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo +remoteHostInfo RemoteHost {remoteHostId, storePath, displayName} sessionActive = + RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do @@ -231,7 +231,7 @@ getRemoteFile rhId rf = do processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ChatCommand -> ByteString -> m ChatResponse processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f - SendImage chatName f -> sendFile "/img" chatName f + SendImage chatName f -> sendFile "/img" chatName f _ -> liftRH remoteHostId $ remoteSend rhc s where sendFile cmdName chatName (CryptoFile path cfArgs) = do @@ -262,14 +262,14 @@ startRemoteCtrl execChatCommand = do chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} -- | Track remote host lifecycle in controller session state and signal UI on its progress -runHost :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () +runHost :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () runHost discovered accepted handleHttp = do remoteCtrlId <- atomically (readTMVar accepted) -- wait for ??? rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) - source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint + serviceAddress <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint handleHttp -- spawn server for remote protocol commands + server <- async $ Discovery.connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server -- wait for the server to finish @@ -369,34 +369,38 @@ handleGetFile User {userId} RemoteFile{userId = commandUserId, fileId, sent, fil withFile path ReadMode $ \h -> reply RRFile {fileSize, fileDigest} $ \send -> hSendFile h send fileSize -discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m () -discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process +discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> m () +discoverRemoteCtrls discovered = do + subscribers <- asks multicastSubscribers + Discovery.withListener subscribers run where - -- TODO how would it receive more than one fingerprint? + run sock = receive sock >>= process sock + receive sock = Discovery.recvAnnounce sock >>= \case - (SockAddrInet _sockPort sockAddr, invite) -> case strDecode invite of - -- TODO it is probably better to report errors to view here - Left _ -> receive sock - Right fingerprint -> pure (sockAddr, fingerprint) + (SockAddrInet _sockPort sockAddr, sigAnnBytes) -> case smpDecode sigAnnBytes of + Right (SignedAnnounce ann _sig) -> pure (sockAddr, ann) + Left _ -> receive sock -- TODO it is probably better to report errors to view here _nonV4 -> receive sock - process (sockAddr, fingerprint) = do + + process sock (sockAddr, Announce {caFingerprint, serviceAddress=(annAddr, port)}) = do + unless (annAddr == sockAddr) $ logError "Announced address doesn't match socket address" let addr = THIPv4 (hostAddressToTuple sockAddr) ifM - (atomically $ TM.member fingerprint discovered) - (logDebug $ "Fingerprint already known: " <> tshow (addr, fingerprint)) + (atomically $ TM.member caFingerprint discovered) + (logDebug $ "Fingerprint already known: " <> tshow (addr, caFingerprint)) ( do - logInfo $ "New fingerprint announced: " <> tshow (addr, fingerprint) - atomically $ TM.insert fingerprint addr discovered + logInfo $ "New fingerprint announced: " <> tshow (addr, caFingerprint) + atomically $ TM.insert caFingerprint (addr, port) discovered ) -- TODO we check fingerprint for duplicate where id doesn't matter - to prevent re-insert - and don't check to prevent duplicate events, -- so UI now will have to check for duplicates again - withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required + withStore' (`getRemoteCtrlByFingerprint` caFingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce caFingerprint -- unknown controller, ui "register" action required -- TODO Maybe Bool is very confusing - the intent is very unclear here Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required - Just False -> pure () -- skipping a rejected item + Just False -> run sock -- restart, skipping a rejected item Just True -> chatReadVar remoteCtrlSession >>= \case Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index babc65e6a8..1ede108b07 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -1,37 +1,33 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} -module Simplex.Chat.Remote.Discovery - ( -- * Announce - announceRevHTTP2, - runAnnouncer, - startTLSServer, - runHTTP2Client, - - -- * Discovery - connectRevHTTP2, - withListener, - openListener, - recvAnnounce, - connectTLSClient, - attachHTTP2Server, - ) -where +module Simplex.Chat.Remote.Discovery where import Control.Logger.Simple import Control.Monad +import Crypto.Random (getRandomBytes) import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64.URL as B64U import Data.Default (def) import Data.String (IsString) +import Data.Text (Text) +import Data.Text.Encoding (decodeUtf8) +import Data.Time.Clock.System (getSystemTime) +import Data.Word (Word16) import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP import Simplex.Chat.Remote.Multicast (setMembership) -import Simplex.Chat.Remote.Types (Tasks, registerAsync) +import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding (Encoding (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport @@ -39,8 +35,9 @@ import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportC import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) -import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) -import Simplex.Messaging.Util (ifM, tshow, whenM) +import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServerSocket, startTCPServer) +import Simplex.Messaging.Util (ifM, tshow) +import Simplex.Messaging.Version (mkVersionRange) import UnliftIO import UnliftIO.Concurrent @@ -52,54 +49,107 @@ pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a pattern ANY_ADDR_V4 = "0.0.0.0" pattern DISCOVERY_PORT :: (IsString a, Eq a) => a -pattern DISCOVERY_PORT = "5226" +pattern DISCOVERY_PORT = "5227" + +startSession :: MonadIO m => Maybe Text -> (N.HostAddress, Word16) -> C.KeyHash -> m ((C.APublicDhKey, C.APrivateDhKey), C.PrivateKeyEd25519, Announce, SignedOOB) +startSession deviceName serviceAddress caFingerprint = liftIO $ do + sessionStart <- getSystemTime + dh@(C.APublicDhKey C.SX25519 sessionDH, _) <- C.generateDhKeyPair C.SX25519 + (C.APublicVerifyKey C.SEd25519 sigPubKey, C.APrivateSignKey C.SEd25519 sigSecretKey) <- C.generateSignatureKeyPair C.SEd25519 + let + announce = + Announce + { versionRange = announceVersionRange, + sessionStart, + announceCounter = 0, + serviceAddress, + caFingerprint, + sessionDH, + announceKey = sigPubKey + } + authToken <- decodeUtf8 . B64U.encode <$> getRandomBytes 12 + let + oob = + OOB + { caFingerprint, + authToken, + host = decodeUtf8 . strEncode $ THIPv4 . N.hostAddressToTuple $ fst serviceAddress, + port = snd serviceAddress, + version = mkVersionRange 1 1, + appName = "simplex-chat", + sigPubKey, + deviceName + } + pure (dh, sigSecretKey, announce, signOOB sigSecretKey oob) + +getLocalAddress :: MonadIO m => TMVar Int -> m (Maybe N.HostAddress) +getLocalAddress subscribers = liftIO $ do + probe <- mkIpProbe + let bytes = smpEncode probe + withListener subscribers $ \receiver -> + withSender $ \sender -> do + UDP.send sender bytes + let expect = do + UDP.recvFrom receiver >>= \case + (p, _) | p /= bytes -> expect + (_, UDP.ClientSockAddr (N.SockAddrInet _port host) _cmsg) -> pure host + (_, UDP.ClientSockAddr _badAddr _) -> error "receiving from IPv4 socket" + timeout 1000000 expect + +mkIpProbe :: MonadIO m => m IpProbe +mkIpProbe = do + randomNonce <- liftIO $ getRandomBytes 32 + pure IpProbe {versionRange = ipProbeVersionRange, randomNonce} -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: StrEncoding a => Tasks -> a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 tasks invite credentials finishAction = do +announceRevHTTP2 :: Tasks -> (C.PrivateKeyEd25519, Announce) -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 tasks (sigKey, announce@Announce {caFingerprint, serviceAddress=(host, _port)}) credentials finishAction = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar _ <- forkIO $ readMVar finished >> finishAction -- attach external cleanup action to session lock - announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ do - logInfo $ "Starting announcer for " <> tshow (strEncode invite) - runAnnouncer (strEncode invite) + announcer <- async . liftIO $ atomically (takeTMVar started) >>= \case + Nothing -> pure () -- TLS server failed to start, skipping announcer + Just givenPort -> do + logInfo $ "Starting announcer for " <> ident <> " at " <> tshow (host, givenPort) + runAnnouncer (sigKey, announce {serviceAddress = (host, fromIntegral givenPort)}) tasks `registerAsync` announcer tlsServer <- startTLSServer started credentials $ \tls -> do - logInfo $ "Incoming connection for " <> tshow (strEncode invite) + logInfo $ "Incoming connection for " <> ident cancel announcer runHTTP2Client finished httpClient tls `catchAny` (logError . tshow) - logInfo $ "Client finished for " <> tshow (strEncode invite) - -- BUG: this should be handled in HTTP2Client wrapper - _ <- forkIO $ do - waitCatch tlsServer >>= \case - Left err | fromException err == Just AsyncCancelled -> logDebug "tlsServer cancelled" - Left err -> do - logError $ "tlsServer failed to start: " <> tshow err - void $ tryPutMVar httpClient $ Left HCNetworkError - void . atomically $ tryPutTMVar started False - Right () -> pure () - void $ tryPutMVar finished () + logInfo $ "Client finished for " <> ident + -- BUG: this should be handled in HTTP2Client wrapper, partially handled in startTLSServer + _ <- forkIO $ waitCatch tlsServer >> void (tryPutMVar finished ()) tasks `registerAsync` tlsServer - logInfo $ "Waiting for client for " <> tshow (strEncode invite) + logInfo $ "Waiting for client for " <> ident readMVar httpClient + where + ident = decodeUtf8 $ strEncode caFingerprint --- | Broadcast invite with link-local datagrams -runAnnouncer :: ByteString -> IO () -runAnnouncer inviteBS = do - bracket (UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) UDP.close $ \sock -> do - let raw = UDP.udpSocket sock - N.setSocketOption raw N.Broadcast 1 - N.setSocketOption raw N.ReuseAddr 1 - forever $ do - UDP.send sock inviteBS +-- | Send replay-proof announce datagrams +runAnnouncer :: (C.PrivateKeyEd25519, Announce) -> IO () +runAnnouncer (announceKey, initialAnnounce) = withSender $ loop initialAnnounce + where + loop announce sock = do + UDP.send sock $ smpEncode (signAnnounce announceKey announce) threadDelay 1000000 + loop announce {announceCounter = announceCounter announce + 1} sock --- XXX: Do we need to start multiple TLS servers for different mobile hosts? -startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) -startTLSServer started credentials = async . liftIO . runTransportServer started DISCOVERY_PORT serverParams defaultTransportServerConfig +startTLSServer :: (MonadUnliftIO m) => TMVar (Maybe N.PortNumber) -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) +startTLSServer started credentials server = async . liftIO $ do + startedOk <- newEmptyTMVarIO + bracketOnError (startTCPServer startedOk "0") (\_e -> void . atomically $ tryPutTMVar started Nothing) $ \socket -> + ifM + (atomically $ readTMVar startedOk) + do + port <- N.socketPort socket + logInfo $ "System-assigned port: " <> tshow port + atomically $ putTMVar started (Just port) + runTransportServerSocket startedOk (pure socket) "RCP TLS" serverParams defaultTransportServerConfig server + (void . atomically $ tryPutTMVar started Nothing) where serverParams = def @@ -123,22 +173,40 @@ runHTTP2Client finishedVar clientVar tls = -- TODO connection timeout config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} -withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a -withListener = bracket openListener closeListener +withSender :: MonadUnliftIO m => (UDP.UDPSocket -> m a) -> m a +withSender = bracket (liftIO $ UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) (liftIO . UDP.close) -openListener :: (MonadIO m) => m UDP.ListenSocket -openListener = liftIO $ do +withListener :: MonadUnliftIO m => TMVar Int -> (UDP.ListenSocket -> m a) -> m a +withListener subscribers = bracket (openListener subscribers) (closeListener subscribers) + +openListener :: MonadIO m => TMVar Int -> m UDP.ListenSocket +openListener subscribers = liftIO $ do sock <- UDP.serverSocket (MULTICAST_ADDR_V4, read DISCOVERY_PORT) logDebug $ "Discovery listener socket: " <> tshow sock let raw = UDP.listenSocket sock - N.setSocketOption raw N.Broadcast 1 - void $ setMembership raw (listenerHostAddr4 sock) True + -- N.setSocketOption raw N.Broadcast 1 + joinMulticast subscribers raw (listenerHostAddr4 sock) pure sock -closeListener :: MonadIO m => UDP.ListenSocket -> m () -closeListener sock = liftIO $ do - UDP.stop sock - void $ setMembership (UDP.listenSocket sock) (listenerHostAddr4 sock) False +closeListener :: MonadIO m => TMVar Int -> UDP.ListenSocket -> m () +closeListener subscribers sock = liftIO $ + partMulticast subscribers (UDP.listenSocket sock) (listenerHostAddr4 sock) `finally` UDP.stop sock + +joinMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () +joinMulticast subscribers sock group = do + now <- atomically $ takeTMVar subscribers + when (now == 0) $ do + setMembership sock group True >>= \case + Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) + Right () -> atomically $ putTMVar subscribers (now + 1) + +partMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () +partMulticast subscribers sock group = do + now <- atomically $ takeTMVar subscribers + when (now == 1) $ + setMembership sock group False >>= \case + Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) + Right () -> atomically $ putTMVar subscribers (now - 1) listenerHostAddr4 :: UDP.ListenSocket -> N.HostAddress listenerHostAddr4 sock = case UDP.mySockAddr sock of @@ -150,11 +218,11 @@ recvAnnounce sock = liftIO $ do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock pure (source, invite) -connectRevHTTP2 :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (HTTP2Request -> m ()) -> m () -connectRevHTTP2 host fingerprint = connectTLSClient host fingerprint . attachHTTP2Server +connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () +connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server -connectTLSClient :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host DISCOVERY_PORT (Just caFingerprint) +connectTLSClient :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (Transport.TLS -> m a) -> m a +connectTLSClient (host, port) caFingerprint = runTransportClient defaultTransportClientConfig Nothing host (show port) (Just caFingerprint) attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () attachHTTP2Server processRequest tls = do diff --git a/src/Simplex/Chat/Remote/Multicast.hsc b/src/Simplex/Chat/Remote/Multicast.hsc index ea015c18e3..3919b4423f 100644 --- a/src/Simplex/Chat/Remote/Multicast.hsc +++ b/src/Simplex/Chat/Remote/Multicast.hsc @@ -10,12 +10,15 @@ import Network.Socket NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups. -} -setMembership :: Socket -> HostAddress -> Bool -> IO Bool +setMembership :: Socket -> HostAddress -> Bool -> IO (Either CInt ()) setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do #{poke struct ip_mreq, imr_multiaddr} mReqPtr group #{poke struct ip_mreq, imr_interface} mReqPtr (0 :: HostAddress) -- attempt to contact the group on ANY interface - withFdSocket sock $ \fd -> - (/= 0) <$> c_setsockopt fd c_IPPROTO_IP flag (castPtr mReqPtr) (#{size struct ip_mreq}) + withFdSocket sock $ \fd -> do + rc <- c_setsockopt fd c_IPPROTO_IP flag (castPtr mReqPtr) (#{size struct ip_mreq}) + if rc == 0 + then pure $ Right () + else pure $ Left rc where flag = if membership then c_IP_ADD_MEMBERSHIP else c_IP_DROP_MEMBERSHIP diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 2deb177775..45bea60663 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -66,8 +66,8 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) -- * Client side / desktop -createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient -createRemoteHostClient httpClient desktopName = do +createRemoteHostClient :: HTTP2Client -> dh -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient +createRemoteHostClient httpClient todo'dhKey desktopName = do logDebug "Sending initial hello" sendRemoteCommand' httpClient localEncoding Nothing RCHello {deviceName = desktopName} >>= \case RRHello {encoding, deviceName = mobileName, encryptFiles} -> do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 6611d04471..4507c3de79 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,18 +1,39 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE GADTs #-} module Simplex.Chat.Remote.Types where import Control.Exception +import Control.Monad +import Crypto.Error (eitherCryptoError) +import qualified Crypto.PubKey.Ed25519 as Ed25519 import qualified Data.Aeson.TH as J +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteArray (convert) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Foldable (toList) import Data.Int (Int64) import Data.Text (Text) +import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8) +import Data.Time.Clock.System (SystemTime) +import Data.Word (Word16) +import Network.HTTP.Types (parseSimpleQuery) +import Network.HTTP.Types.URI (renderSimpleQuery, urlDecode, urlEncode) +import qualified Network.Socket as N import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Encoding (Encoding (..)) +import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Version (VersionRange, mkVersionRange) import UnliftIO data RemoteHostClient = RemoteHostClient @@ -66,7 +87,6 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, storePath :: FilePath, displayName :: Text, - remoteCtrlOOB :: RemoteCtrlOOB, sessionActive :: Bool } deriving (Show) @@ -98,6 +118,161 @@ data RemoteFile = RemoteFile } deriving (Show) +ipProbeVersionRange :: VersionRange +ipProbeVersionRange = mkVersionRange 1 1 + +data IpProbe = IpProbe + { versionRange :: VersionRange, + randomNonce :: ByteString + } deriving (Show) + +instance Encoding IpProbe where + smpEncode IpProbe {versionRange, randomNonce} = smpEncode (versionRange, 'I', randomNonce) + + smpP = IpProbe <$> (smpP <* "I") *> smpP + +announceVersionRange :: VersionRange +announceVersionRange = mkVersionRange 1 1 + +data Announce = Announce + { versionRange :: VersionRange, + sessionStart :: SystemTime, + announceCounter :: Word16, + serviceAddress :: (N.HostAddress, Word16), + caFingerprint :: C.KeyHash, + sessionDH :: C.PublicKeyX25519, + announceKey :: C.PublicKeyEd25519 + } deriving (Show) + +instance Encoding Announce where + smpEncode Announce {versionRange, sessionStart, announceCounter, serviceAddress, caFingerprint, sessionDH, announceKey} = + smpEncode (versionRange, 'A', sessionStart, announceCounter, serviceAddress) + <> smpEncode (caFingerprint, sessionDH, announceKey) + + smpP = Announce <$> (smpP <* "A") <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP + +data SignedAnnounce = SignedAnnounce Announce (C.Signature 'C.Ed25519) + +instance Encoding SignedAnnounce where + smpEncode (SignedAnnounce ann (C.SignatureEd25519 sig)) = smpEncode (ann, convert sig :: ByteString) + + smpP = do + sa <- SignedAnnounce <$> smpP <*> signatureP + unless (verifySignedAnnounce sa) $ fail "bad announce signature" + pure sa + where + signatureP = do + bs <- smpP :: A.Parser ByteString + case eitherCryptoError (Ed25519.signature bs) of + Left ce -> fail $ show ce + Right ok -> pure $ C.SignatureEd25519 ok + +signAnnounce :: C.PrivateKey C.Ed25519 -> Announce -> SignedAnnounce +signAnnounce announceSecret ann = SignedAnnounce ann sig + where + sig = + case C.sign (C.APrivateSignKey C.SEd25519 announceSecret) (smpEncode ann) of + C.ASignature C.SEd25519 s -> s + _ -> error "signing with ed25519" + +verifySignedAnnounce :: SignedAnnounce -> Bool +verifySignedAnnounce (SignedAnnounce ann@Announce {announceKey} sig) = C.verify aKey aSig (smpEncode ann) + where + aKey = C.APublicVerifyKey C.SEd25519 announceKey + aSig = C.ASignature C.SEd25519 sig + +data OOB = OOB + { -- authority part + caFingerprint :: C.KeyHash, + authToken :: Text, + host :: Text, + port :: Word16, + -- query part + version :: VersionRange, -- v= + appName :: Text, -- app= + sigPubKey :: C.PublicKeyEd25519, -- key= + deviceName :: Maybe Text -- device= + } + deriving (Eq, Show) + +instance StrEncoding OOB where + strEncode OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} = + schema <> "://" <> authority <> "#/?" <> renderSimpleQuery False query + where + schema = "xrcp" + authority = + mconcat + [ strEncode caFingerprint, + ":", + encodeUtf8 authToken, + "@", + encodeUtf8 host, + ":", + strEncode port + ] + query = + [ ("v", strEncode version), + ("app", encodeUtf8 appName), + ("key", strEncode $ C.encodePubKey sigPubKey) + ] + ++ [("device", urlEncode True $ encodeUtf8 name) | name <- toList deviceName] + + strP = do + _ <- A.string "xrcp://" + caFingerprint <- strP + _ <- A.char ':' + authToken <- decodeUtf8Lenient <$> A.takeWhile (/= '@') + _ <- A.char '@' + host <- decodeUtf8Lenient <$> A.takeWhile (/= ':') + _ <- A.char ':' + port <- strP + + _ <- A.string "#/?" + q <- parseSimpleQuery <$> A.takeByteString + version <- maybe (fail "missing version") (either fail pure . strDecode) (lookup "v" q) + appName <- maybe (fail "missing appName") (pure . decodeUtf8Lenient) (lookup "app" q) + sigPubKeyB64 <- maybe (fail "missing key") pure (lookup "key" q) + sigPubKey <- either fail pure $ strDecode sigPubKeyB64 >>= C.decodePubKey + let deviceName = fmap (decodeUtf8Lenient . urlDecode True) (lookup "device" q) + pure OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} + +data SignedOOB = SignedOOB OOB (C.Signature 'C.Ed25519) + deriving (Eq, Show) + +instance StrEncoding SignedOOB where + strEncode (SignedOOB oob sig) = strEncode oob <> "&sig=" <> strEncode (C.signatureBytes sig) + + strDecode s = do + unless (B.length sig == sigLen) $ Left "bad size" + unless ("&sig=" `B.isPrefixOf` sig) $ Left "bad signature prefix" + signedOOB <- SignedOOB <$> strDecode oob <*> (strDecode (B.drop 5 sig) >>= C.decodeSignature) + unless (verifySignedOOB signedOOB) $ Left "bad signature" + pure signedOOB + where + l = B.length s + (oob, sig) = B.splitAt (l - sigLen) s + sigLen = 93 -- &sig= + ed25519 sig size in base64 (88) + + -- XXX: strP is used in chat command parser, but default strP assumes bas64url-encoded bytestring, where OOB is an URL-like + strP = A.takeWhile (/= ' ') >>= either fail pure . strDecode + +signOOB :: C.PrivateKey C.Ed25519 -> OOB -> SignedOOB +signOOB key oob = SignedOOB oob sig + where + sig = + case C.sign (C.APrivateSignKey C.SEd25519 key) (strEncode oob) of + C.ASignature C.SEd25519 s -> s + _ -> error "signing with ed25519" + +verifySignedOOB :: SignedOOB -> Bool +verifySignedOOB (SignedOOB oob@OOB {sigPubKey} sig) = C.verify aKey aSig (strEncode oob) + where + aKey = C.APublicVerifyKey C.SEd25519 sigPubKey + aSig = C.ASignature C.SEd25519 sig + +decodeOOBLink :: Text -> Either String OOB +decodeOOBLink = fmap (\(SignedOOB oob _verified) -> oob) . strDecode . encodeUtf8 + data PlatformEncoding = PESwift | PEKotlin @@ -125,8 +300,6 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) $(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) -$(J.deriveJSON defaultJSON ''RemoteCtrlOOB) - $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrl) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index a4c2ef85e1..0dfe665b2f 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -6,13 +6,14 @@ module Simplex.Chat.Store.Remote where import Control.Monad.Except import Data.Int (Int64) +import Data.Maybe (fromMaybe) import Data.Text (Text) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Chat.Store.Shared import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Shared 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 insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId @@ -39,8 +40,9 @@ toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) -insertRemoteCtrl :: DB.Connection -> RemoteCtrlOOB -> IO RemoteCtrlInfo -insertRemoteCtrl db RemoteCtrlOOB {fingerprint, displayName} = do +insertRemoteCtrl :: DB.Connection -> SignedOOB -> IO RemoteCtrlInfo +insertRemoteCtrl db (SignedOOB OOB {deviceName, caFingerprint = fingerprint} _) = do + let displayName = fromMaybe "" deviceName DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint) remoteCtrlId <- insertedRowId db pure RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted = Nothing, sessionActive = False} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9ae00159b7..1810b62189 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -269,8 +269,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] - CRRemoteHostCreated RemoteHostInfo {remoteHostId, remoteCtrlOOB} -> ("remote host " <> sShow remoteHostId <> " created") : viewRemoteCtrlOOBData remoteCtrlOOB + CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] CRRemoteHostList hs -> viewRemoteHosts hs + CRRemoteHostStarted {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionOOB} -> ["remote host " <> sShow rhId <> " started", "connection code:", plain sessionOOB] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> @@ -447,7 +448,7 @@ viewGroupSubscribed :: GroupInfo -> [StyledString] viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String -showSMPServer = B.unpack . strEncode . host +showSMPServer srv = B.unpack $ strEncode srv.host viewHostEvent :: AProtocolType -> TransportHost -> String viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) @@ -1659,10 +1660,6 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo where parens s = " (" <> s <> ")" -viewRemoteCtrlOOBData :: RemoteCtrlOOB -> [StyledString] -viewRemoteCtrlOOBData RemoteCtrlOOB {fingerprint} = - ["connection code:", plain $ strEncode fingerprint] - viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case [] -> ["No remote hosts"] diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index be1d3c1a2e..ccbd543e9a 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -8,12 +8,12 @@ module RemoteTests where import ChatClient import ChatTests.Utils import Control.Logger.Simple -import Control.Monad import qualified Data.Aeson as J import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M +import Data.String (fromString) import Network.HTTP.Types (ok200) import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S @@ -23,11 +23,12 @@ import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File -import Simplex.Chat.Remote.Types import qualified Simplex.Chat.Remote.Discovery as Discovery +import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) -import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Encoding (smpDecode) +import Simplex.Messaging.Encoding.String (strDecode, strEncode) import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) @@ -44,6 +45,7 @@ remoteTests :: SpecWith FilePath remoteTests = describe "Remote" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test + it "OOB encoding, decoding, and signatures are correct" oobCodecTest it "performs protocol handshake" remoteHandshakeTest it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check it "sends messages" remoteMessageTest @@ -59,8 +61,9 @@ genCredentialsTest _tmp = do started <- newEmptyTMVarIO bracket (Discovery.startTLSServer started credentials serverHandler) cancel $ \_server -> do ok <- atomically (readTMVar started) - unless ok $ error "TLS server failed to start" - Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler + port <- maybe (error "TLS server failed to start") pure ok + logNote $ "Assigned port: " <> tshow port + Discovery.connectTLSClient ("127.0.0.1", fromIntegral port) fingerprint clientHandler where serverHandler serverTls = do logNote "Sending from server" @@ -75,15 +78,28 @@ genCredentialsTest _tmp = do -- * UDP discovery and rever HTTP2 +oobCodecTest :: (HasCallStack) => FilePath -> IO () +oobCodecTest _tmp = do + subscribers <- newTMVarIO 0 + localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure + (fingerprint, _credentials) <- genTestCredentials + (_dhKey, _sigKey, _ann, signedOOB@(SignedOOB oob _sig)) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint + verifySignedOOB signedOOB `shouldBe` True + strDecode (strEncode oob) `shouldBe` Right oob + strDecode (strEncode signedOOB) `shouldBe` Right signedOOB + announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do + subscribers <- newTMVarIO 0 + localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure (fingerprint, credentials) <- genTestCredentials + (_dhKey, sigKey, ann, _oob) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint tasks <- newTVarIO [] finished <- newEmptyMVar controller <- async $ do logNote "Controller: starting" bracket - (Discovery.announceRevHTTP2 tasks fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) + (Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do logNote "Controller: got client" @@ -94,11 +110,14 @@ announceDiscoverHttp2Test _tmp = do Right HTTP2Response {} -> logNote "Controller: got response" ) - host <- async $ Discovery.withListener $ \sock -> do - (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock - strDecode invite `shouldBe` Right fingerprint - logNote "Host: connecting" - server <- async $ Discovery.connectTLSClient (THIPv4 $ N.hostAddressToTuple addr) fingerprint $ \tls -> do + host <- async $ Discovery.withListener subscribers $ \sock -> do + (N.SockAddrInet _port addr, sigAnn) <- Discovery.recvAnnounce sock + SignedAnnounce Announce {caFingerprint, serviceAddress=(hostAddr, port)} _sig <- either fail pure $ smpDecode sigAnn + caFingerprint `shouldBe` fingerprint + addr `shouldBe` hostAddr + let service = (THIPv4 $ N.hostAddressToTuple hostAddr, port) + logNote $ "Host: connecting to " <> tshow service + server <- async $ Discovery.connectTLSClient service fingerprint $ \tls -> do logNote "Host: got tls" flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do logNote "Host: got request" @@ -213,7 +232,7 @@ remoteStoreFileTest = -- send file not encrypted locally on mobile host desktop ##> "/_send @2 json {\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}" desktop <# "@bob sending a file" - desktop <# "/f @bob test_1.pdf" + desktop <# "/f @bob test_1.pdf" desktop <## "use /fc 1 to cancel sending" bob <# "alice> sending a file" bob <# "alice> sends file test_1.pdf (266.0 KiB / 272376 bytes)" @@ -242,7 +261,7 @@ remoteStoreFileTest = -- send file encrypted locally on mobile host desktop ##> ("/_send @2 json {\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}") - desktop <# "/f @bob test_2.pdf" + desktop <# "/f @bob test_2.pdf" desktop <## "use /fc 2 to cancel sending" bob <# "alice> sends file test_2.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 2 [/ | ] to receive it" @@ -372,21 +391,30 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile startRemote :: TestCC -> TestCC -> IO () startRemote mobile desktop = do + desktop ##> "/set device name My desktop" + desktop <## "ok" desktop ##> "/create remote host" desktop <## "remote host 1 created" - desktop <## "connection code:" - fingerprint <- getTermLine desktop - + -- A new host is started [automatically] by UI desktop ##> "/start remote host 1" desktop <## "ok" + desktop <## "remote host 1 started" + desktop <## "connection code:" + oobLink <- getTermLine desktop + OOB {caFingerprint = oobFingerprint} <- either (fail . mappend "OOB link failed: ") pure $ decodeOOBLink (fromString oobLink) + -- Desktop displays OOB QR code + mobile ##> "/set device name Mobile" + mobile <## "ok" mobile ##> "/start remote ctrl" mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" - fingerprint' <- getTermLine mobile - fingerprint' `shouldBe` fingerprint - mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") + annFingerprint <- getTermLine mobile + -- The user scans OOB QR code and confirms it matches the announced stuff + fromString annFingerprint `shouldBe` strEncode oobFingerprint + + mobile ##> ("/register remote ctrl " <> oobLink) mobile <## "remote controller 1 registered" mobile ##> "/accept remote ctrl 1" mobile <## "ok" -- alternative scenario: accepted before controller start From 02c0cd5619e1683e6cbfc40c1a7ac99f5c6e88de Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 1 Nov 2023 12:48:58 +0200 Subject: [PATCH 026/174] Cut at attaching http server/client (#3299) * Cut at attaching http server/client * switch to xrcp branch --- cabal.project | 2 +- package.yaml | 1 - simplex-chat.cabal | 11 +- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Remote.hs | 8 +- src/Simplex/Chat/Remote/Discovery.hs | 236 --------------------------- src/Simplex/Chat/Remote/RevHTTP.hs | 54 ++++++ src/Simplex/Chat/Remote/Types.hs | 219 +++---------------------- src/Simplex/Chat/Store/Remote.hs | 1 + tests/RemoteTests.hs | 72 ++++---- 10 files changed, 121 insertions(+), 484 deletions(-) delete mode 100644 src/Simplex/Chat/Remote/Discovery.hs create mode 100644 src/Simplex/Chat/Remote/RevHTTP.hs diff --git a/cabal.project b/cabal.project index 9522fe2337..b693f6c88f 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 + tag: db1b2f77cd1c172fab26b68c507cdd2c1b7b0e63 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 095d2c1342..2bfb36d18d 100644 --- a/package.yaml +++ b/package.yaml @@ -36,7 +36,6 @@ dependencies: - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 - network-transport == 0.5.6 - - network-udp >= 0.0 && < 0.1 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - random >= 1.1 && < 1.3 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index db03249312..2dc77c3c22 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.35.2. +-- This file has been generated from package.yaml by hpack version 0.36.0. -- -- see: https://github.com/sol/hpack @@ -130,9 +130,9 @@ library Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote - Simplex.Chat.Remote.Discovery Simplex.Chat.Remote.Multicast Simplex.Chat.Remote.Protocol + Simplex.Chat.Remote.RevHTTP Simplex.Chat.Remote.Transport Simplex.Chat.Remote.Types Simplex.Chat.Store @@ -184,7 +184,6 @@ library , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -237,7 +236,6 @@ executable simplex-bot , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -291,7 +289,6 @@ executable simplex-bot-advanced , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -347,7 +344,6 @@ executable simplex-broadcast-bot , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -402,7 +398,6 @@ executable simplex-chat , mtl ==2.3.* , network ==3.1.* , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -461,7 +456,6 @@ executable simplex-directory-service , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -546,7 +540,6 @@ test-suite simplex-chat-test , mtl ==2.3.* , network ==3.1.* , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a54c6320e8..25fe9294c6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -73,6 +73,7 @@ import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version +import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d6ccd25961..cc3eb9f199 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -40,8 +40,8 @@ import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Messages (chatNameStr) -import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Protocol +import Simplex.Chat.Remote.RevHTTP (announceRevHTTP2, connectRevHTTP2) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Files @@ -61,6 +61,8 @@ import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>)) +import qualified Simplex.RemoteControl.Discovery as Discovery +import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) @@ -113,7 +115,7 @@ startRemoteHost rhId = do localAddr <- asks multicastSubscribers >>= Discovery.getLocalAddress >>= maybe (throwError . ChatError $ CEInternalError "unable to get local address") pure (dhKey, sigKey, ann, oob) <- Discovery.startSession (if rcName == "" then Nothing else Just rcName) (localAddr, read Discovery.DISCOVERY_PORT) fingerprint toView CRRemoteHostStarted {remoteHost = remoteHostInfo rh True, sessionOOB = decodeUtf8 $ strEncode oob} - httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials cleanupIO + httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ announceRevHTTP2 tasks (sigKey, ann) credentials cleanupIO logInfo $ "Remote host session connected for " <> tshow rhId -- test connection and establish a protocol layer remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient dhKey rcName @@ -269,7 +271,7 @@ runHost discovered accepted handleHttp = do serviceAddress <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands + server <- async $ connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server -- wait for the server to finish diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs deleted file mode 100644 index 1ede108b07..0000000000 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ /dev/null @@ -1,236 +0,0 @@ -{-# LANGUAGE BlockArguments #-} -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE GADTs #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE PatternSynonyms #-} - -module Simplex.Chat.Remote.Discovery where - -import Control.Logger.Simple -import Control.Monad -import Crypto.Random (getRandomBytes) -import Data.ByteString (ByteString) -import qualified Data.ByteString.Base64.URL as B64U -import Data.Default (def) -import Data.String (IsString) -import Data.Text (Text) -import Data.Text.Encoding (decodeUtf8) -import Data.Time.Clock.System (getSystemTime) -import Data.Word (Word16) -import qualified Network.Socket as N -import qualified Network.TLS as TLS -import qualified Network.UDP as UDP -import Simplex.Chat.Remote.Multicast (setMembership) -import Simplex.Chat.Remote.Types -import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Encoding (Encoding (..)) -import Simplex.Messaging.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Transport (supportedParameters) -import qualified Simplex.Messaging.Transport as Transport -import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) -import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) -import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) -import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServerSocket, startTCPServer) -import Simplex.Messaging.Util (ifM, tshow) -import Simplex.Messaging.Version (mkVersionRange) -import UnliftIO -import UnliftIO.Concurrent - --- | mDNS multicast group -pattern MULTICAST_ADDR_V4 :: (IsString a, Eq a) => a -pattern MULTICAST_ADDR_V4 = "224.0.0.251" - -pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a -pattern ANY_ADDR_V4 = "0.0.0.0" - -pattern DISCOVERY_PORT :: (IsString a, Eq a) => a -pattern DISCOVERY_PORT = "5227" - -startSession :: MonadIO m => Maybe Text -> (N.HostAddress, Word16) -> C.KeyHash -> m ((C.APublicDhKey, C.APrivateDhKey), C.PrivateKeyEd25519, Announce, SignedOOB) -startSession deviceName serviceAddress caFingerprint = liftIO $ do - sessionStart <- getSystemTime - dh@(C.APublicDhKey C.SX25519 sessionDH, _) <- C.generateDhKeyPair C.SX25519 - (C.APublicVerifyKey C.SEd25519 sigPubKey, C.APrivateSignKey C.SEd25519 sigSecretKey) <- C.generateSignatureKeyPair C.SEd25519 - let - announce = - Announce - { versionRange = announceVersionRange, - sessionStart, - announceCounter = 0, - serviceAddress, - caFingerprint, - sessionDH, - announceKey = sigPubKey - } - authToken <- decodeUtf8 . B64U.encode <$> getRandomBytes 12 - let - oob = - OOB - { caFingerprint, - authToken, - host = decodeUtf8 . strEncode $ THIPv4 . N.hostAddressToTuple $ fst serviceAddress, - port = snd serviceAddress, - version = mkVersionRange 1 1, - appName = "simplex-chat", - sigPubKey, - deviceName - } - pure (dh, sigSecretKey, announce, signOOB sigSecretKey oob) - -getLocalAddress :: MonadIO m => TMVar Int -> m (Maybe N.HostAddress) -getLocalAddress subscribers = liftIO $ do - probe <- mkIpProbe - let bytes = smpEncode probe - withListener subscribers $ \receiver -> - withSender $ \sender -> do - UDP.send sender bytes - let expect = do - UDP.recvFrom receiver >>= \case - (p, _) | p /= bytes -> expect - (_, UDP.ClientSockAddr (N.SockAddrInet _port host) _cmsg) -> pure host - (_, UDP.ClientSockAddr _badAddr _) -> error "receiving from IPv4 socket" - timeout 1000000 expect - -mkIpProbe :: MonadIO m => m IpProbe -mkIpProbe = do - randomNonce <- liftIO $ getRandomBytes 32 - pure IpProbe {versionRange = ipProbeVersionRange, randomNonce} - --- | Announce tls server, wait for connection and attach http2 client to it. --- --- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: Tasks -> (C.PrivateKeyEd25519, Announce) -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 tasks (sigKey, announce@Announce {caFingerprint, serviceAddress=(host, _port)}) credentials finishAction = do - httpClient <- newEmptyMVar - started <- newEmptyTMVarIO - finished <- newEmptyMVar - _ <- forkIO $ readMVar finished >> finishAction -- attach external cleanup action to session lock - announcer <- async . liftIO $ atomically (takeTMVar started) >>= \case - Nothing -> pure () -- TLS server failed to start, skipping announcer - Just givenPort -> do - logInfo $ "Starting announcer for " <> ident <> " at " <> tshow (host, givenPort) - runAnnouncer (sigKey, announce {serviceAddress = (host, fromIntegral givenPort)}) - tasks `registerAsync` announcer - tlsServer <- startTLSServer started credentials $ \tls -> do - logInfo $ "Incoming connection for " <> ident - cancel announcer - runHTTP2Client finished httpClient tls `catchAny` (logError . tshow) - logInfo $ "Client finished for " <> ident - -- BUG: this should be handled in HTTP2Client wrapper, partially handled in startTLSServer - _ <- forkIO $ waitCatch tlsServer >> void (tryPutMVar finished ()) - tasks `registerAsync` tlsServer - logInfo $ "Waiting for client for " <> ident - readMVar httpClient - where - ident = decodeUtf8 $ strEncode caFingerprint - --- | Send replay-proof announce datagrams -runAnnouncer :: (C.PrivateKeyEd25519, Announce) -> IO () -runAnnouncer (announceKey, initialAnnounce) = withSender $ loop initialAnnounce - where - loop announce sock = do - UDP.send sock $ smpEncode (signAnnounce announceKey announce) - threadDelay 1000000 - loop announce {announceCounter = announceCounter announce + 1} sock - -startTLSServer :: (MonadUnliftIO m) => TMVar (Maybe N.PortNumber) -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) -startTLSServer started credentials server = async . liftIO $ do - startedOk <- newEmptyTMVarIO - bracketOnError (startTCPServer startedOk "0") (\_e -> void . atomically $ tryPutTMVar started Nothing) $ \socket -> - ifM - (atomically $ readTMVar startedOk) - do - port <- N.socketPort socket - logInfo $ "System-assigned port: " <> tshow port - atomically $ putTMVar started (Just port) - runTransportServerSocket startedOk (pure socket) "RCP TLS" serverParams defaultTransportServerConfig server - (void . atomically $ tryPutTMVar started Nothing) - where - serverParams = - def - { TLS.serverWantClientCert = False, - TLS.serverShared = def {TLS.sharedCredentials = credentials}, - TLS.serverHooks = def, - TLS.serverSupported = supportedParameters - } - --- | Attach HTTP2 client and hold the TLS until the attached client finishes. -runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () -runHTTP2Client finishedVar clientVar tls = - ifM (isEmptyMVar clientVar) - attachClient - (logError "HTTP2 session already started on this listener") - where - attachClient = do - client <- attachHTTP2Client config ANY_ADDR_V4 DISCOVERY_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls - putMVar clientVar client - readMVar finishedVar - -- TODO connection timeout - config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} - -withSender :: MonadUnliftIO m => (UDP.UDPSocket -> m a) -> m a -withSender = bracket (liftIO $ UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) (liftIO . UDP.close) - -withListener :: MonadUnliftIO m => TMVar Int -> (UDP.ListenSocket -> m a) -> m a -withListener subscribers = bracket (openListener subscribers) (closeListener subscribers) - -openListener :: MonadIO m => TMVar Int -> m UDP.ListenSocket -openListener subscribers = liftIO $ do - sock <- UDP.serverSocket (MULTICAST_ADDR_V4, read DISCOVERY_PORT) - logDebug $ "Discovery listener socket: " <> tshow sock - let raw = UDP.listenSocket sock - -- N.setSocketOption raw N.Broadcast 1 - joinMulticast subscribers raw (listenerHostAddr4 sock) - pure sock - -closeListener :: MonadIO m => TMVar Int -> UDP.ListenSocket -> m () -closeListener subscribers sock = liftIO $ - partMulticast subscribers (UDP.listenSocket sock) (listenerHostAddr4 sock) `finally` UDP.stop sock - -joinMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () -joinMulticast subscribers sock group = do - now <- atomically $ takeTMVar subscribers - when (now == 0) $ do - setMembership sock group True >>= \case - Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) - Right () -> atomically $ putTMVar subscribers (now + 1) - -partMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () -partMulticast subscribers sock group = do - now <- atomically $ takeTMVar subscribers - when (now == 1) $ - setMembership sock group False >>= \case - Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) - Right () -> atomically $ putTMVar subscribers (now - 1) - -listenerHostAddr4 :: UDP.ListenSocket -> N.HostAddress -listenerHostAddr4 sock = case UDP.mySockAddr sock of - N.SockAddrInet _port host -> host - _ -> error "MULTICAST_ADDR_V4 is V4" - -recvAnnounce :: (MonadIO m) => UDP.ListenSocket -> m (N.SockAddr, ByteString) -recvAnnounce sock = liftIO $ do - (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock - pure (source, invite) - -connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () -connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server - -connectTLSClient :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectTLSClient (host, port) caFingerprint = runTransportClient defaultTransportClientConfig Nothing host (show port) (Just caFingerprint) - -attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () -attachHTTP2Server processRequest tls = do - withRunInIO $ \unlift -> - runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r doNotPrefetchHead - unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} - --- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks -doNotPrefetchHead :: Int -doNotPrefetchHead = 0 diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs new file mode 100644 index 0000000000..c6c777596a --- /dev/null +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -0,0 +1,54 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Remote.RevHTTP where + +import Simplex.RemoteControl.Discovery +import Simplex.RemoteControl.Types +import Control.Logger.Simple +import Data.Word (Word16) +import qualified Network.TLS as TLS +import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Transport as Transport +import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) +import Simplex.Messaging.Util (ifM) +import UnliftIO + +announceRevHTTP2 :: MonadUnliftIO m => Tasks -> (C.PrivateKeyEd25519, Announce) -> TLS.Credentials -> m () -> m (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 = announceCtrl runHTTP2Client + +-- | Attach HTTP2 client and hold the TLS until the attached client finishes. +runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () +runHTTP2Client finishedVar clientVar tls = + ifM (isEmptyMVar clientVar) + attachClient + (logError "HTTP2 session already started on this listener") + where + attachClient = do + client <- attachHTTP2Client config ANY_ADDR_V4 DISCOVERY_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls + putMVar clientVar client + readMVar finishedVar + -- TODO connection timeout + config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} + +connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () +connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server + +attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () +attachHTTP2Server processRequest tls = do + withRunInIO $ \unlift -> + runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r doNotPrefetchHead + unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + +-- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks +doNotPrefetchHead :: Int +doNotPrefetchHead = 0 diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 4507c3de79..dcf70ab714 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -2,39 +2,22 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE GADTs #-} module Simplex.Chat.Remote.Types where -import Control.Exception -import Control.Monad -import Crypto.Error (eitherCryptoError) -import qualified Crypto.PubKey.Ed25519 as Ed25519 +import Control.Exception (Exception) import qualified Data.Aeson.TH as J -import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.ByteArray (convert) -import Data.ByteString (ByteString) -import qualified Data.ByteString.Char8 as B -import Data.Foldable (toList) import Data.Int (Int64) import Data.Text (Text) -import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8) -import Data.Time.Clock.System (SystemTime) -import Data.Word (Word16) -import Network.HTTP.Types (parseSimpleQuery) -import Network.HTTP.Types.URI (renderSimpleQuery, urlDecode, urlEncode) -import qualified Network.Socket as N import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.File (CryptoFile) -import Simplex.Messaging.Encoding (Encoding (..)) -import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) -import Simplex.Messaging.Version (VersionRange, mkVersionRange) -import UnliftIO +import Simplex.RemoteControl.Types (Tasks) +import Simplex.Messaging.Crypto.File (CryptoFile) data RemoteHostClient = RemoteHostClient { hostEncoding :: PlatformEncoding, @@ -50,15 +33,19 @@ data RemoteHostSession = RemoteHostSession } data RemoteProtocolError - = RPEInvalidSize -- ^ size prefix is malformed - | RPEInvalidJSON {invalidJSON :: Text} -- ^ failed to parse RemoteCommand or RemoteResponse + = -- | size prefix is malformed + RPEInvalidSize + | -- | failed to parse RemoteCommand or RemoteResponse + RPEInvalidJSON {invalidJSON :: Text} | RPEIncompatibleEncoding | RPEUnexpectedFile | RPENoFile | RPEFileSize | RPEFileDigest - | RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent - | RPEStoredFileExists -- ^ A file already exists in the destination position + | -- | Wrong response received for the command sent + RPEUnexpectedResponse {response :: Text} + | -- | A file already exists in the destination position + RPEStoredFileExists | RPEHTTP2 {http2Error :: Text} | RPEException {someException :: Text} deriving (Show, Exception) @@ -110,169 +97,6 @@ data RemoteCtrlInfo = RemoteCtrlInfo } deriving (Show) -data RemoteFile = RemoteFile - { userId :: Int64, - fileId :: Int64, - sent :: Bool, - fileSource :: CryptoFile - } - deriving (Show) - -ipProbeVersionRange :: VersionRange -ipProbeVersionRange = mkVersionRange 1 1 - -data IpProbe = IpProbe - { versionRange :: VersionRange, - randomNonce :: ByteString - } deriving (Show) - -instance Encoding IpProbe where - smpEncode IpProbe {versionRange, randomNonce} = smpEncode (versionRange, 'I', randomNonce) - - smpP = IpProbe <$> (smpP <* "I") *> smpP - -announceVersionRange :: VersionRange -announceVersionRange = mkVersionRange 1 1 - -data Announce = Announce - { versionRange :: VersionRange, - sessionStart :: SystemTime, - announceCounter :: Word16, - serviceAddress :: (N.HostAddress, Word16), - caFingerprint :: C.KeyHash, - sessionDH :: C.PublicKeyX25519, - announceKey :: C.PublicKeyEd25519 - } deriving (Show) - -instance Encoding Announce where - smpEncode Announce {versionRange, sessionStart, announceCounter, serviceAddress, caFingerprint, sessionDH, announceKey} = - smpEncode (versionRange, 'A', sessionStart, announceCounter, serviceAddress) - <> smpEncode (caFingerprint, sessionDH, announceKey) - - smpP = Announce <$> (smpP <* "A") <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP - -data SignedAnnounce = SignedAnnounce Announce (C.Signature 'C.Ed25519) - -instance Encoding SignedAnnounce where - smpEncode (SignedAnnounce ann (C.SignatureEd25519 sig)) = smpEncode (ann, convert sig :: ByteString) - - smpP = do - sa <- SignedAnnounce <$> smpP <*> signatureP - unless (verifySignedAnnounce sa) $ fail "bad announce signature" - pure sa - where - signatureP = do - bs <- smpP :: A.Parser ByteString - case eitherCryptoError (Ed25519.signature bs) of - Left ce -> fail $ show ce - Right ok -> pure $ C.SignatureEd25519 ok - -signAnnounce :: C.PrivateKey C.Ed25519 -> Announce -> SignedAnnounce -signAnnounce announceSecret ann = SignedAnnounce ann sig - where - sig = - case C.sign (C.APrivateSignKey C.SEd25519 announceSecret) (smpEncode ann) of - C.ASignature C.SEd25519 s -> s - _ -> error "signing with ed25519" - -verifySignedAnnounce :: SignedAnnounce -> Bool -verifySignedAnnounce (SignedAnnounce ann@Announce {announceKey} sig) = C.verify aKey aSig (smpEncode ann) - where - aKey = C.APublicVerifyKey C.SEd25519 announceKey - aSig = C.ASignature C.SEd25519 sig - -data OOB = OOB - { -- authority part - caFingerprint :: C.KeyHash, - authToken :: Text, - host :: Text, - port :: Word16, - -- query part - version :: VersionRange, -- v= - appName :: Text, -- app= - sigPubKey :: C.PublicKeyEd25519, -- key= - deviceName :: Maybe Text -- device= - } - deriving (Eq, Show) - -instance StrEncoding OOB where - strEncode OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} = - schema <> "://" <> authority <> "#/?" <> renderSimpleQuery False query - where - schema = "xrcp" - authority = - mconcat - [ strEncode caFingerprint, - ":", - encodeUtf8 authToken, - "@", - encodeUtf8 host, - ":", - strEncode port - ] - query = - [ ("v", strEncode version), - ("app", encodeUtf8 appName), - ("key", strEncode $ C.encodePubKey sigPubKey) - ] - ++ [("device", urlEncode True $ encodeUtf8 name) | name <- toList deviceName] - - strP = do - _ <- A.string "xrcp://" - caFingerprint <- strP - _ <- A.char ':' - authToken <- decodeUtf8Lenient <$> A.takeWhile (/= '@') - _ <- A.char '@' - host <- decodeUtf8Lenient <$> A.takeWhile (/= ':') - _ <- A.char ':' - port <- strP - - _ <- A.string "#/?" - q <- parseSimpleQuery <$> A.takeByteString - version <- maybe (fail "missing version") (either fail pure . strDecode) (lookup "v" q) - appName <- maybe (fail "missing appName") (pure . decodeUtf8Lenient) (lookup "app" q) - sigPubKeyB64 <- maybe (fail "missing key") pure (lookup "key" q) - sigPubKey <- either fail pure $ strDecode sigPubKeyB64 >>= C.decodePubKey - let deviceName = fmap (decodeUtf8Lenient . urlDecode True) (lookup "device" q) - pure OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} - -data SignedOOB = SignedOOB OOB (C.Signature 'C.Ed25519) - deriving (Eq, Show) - -instance StrEncoding SignedOOB where - strEncode (SignedOOB oob sig) = strEncode oob <> "&sig=" <> strEncode (C.signatureBytes sig) - - strDecode s = do - unless (B.length sig == sigLen) $ Left "bad size" - unless ("&sig=" `B.isPrefixOf` sig) $ Left "bad signature prefix" - signedOOB <- SignedOOB <$> strDecode oob <*> (strDecode (B.drop 5 sig) >>= C.decodeSignature) - unless (verifySignedOOB signedOOB) $ Left "bad signature" - pure signedOOB - where - l = B.length s - (oob, sig) = B.splitAt (l - sigLen) s - sigLen = 93 -- &sig= + ed25519 sig size in base64 (88) - - -- XXX: strP is used in chat command parser, but default strP assumes bas64url-encoded bytestring, where OOB is an URL-like - strP = A.takeWhile (/= ' ') >>= either fail pure . strDecode - -signOOB :: C.PrivateKey C.Ed25519 -> OOB -> SignedOOB -signOOB key oob = SignedOOB oob sig - where - sig = - case C.sign (C.APrivateSignKey C.SEd25519 key) (strEncode oob) of - C.ASignature C.SEd25519 s -> s - _ -> error "signing with ed25519" - -verifySignedOOB :: SignedOOB -> Bool -verifySignedOOB (SignedOOB oob@OOB {sigPubKey} sig) = C.verify aKey aSig (strEncode oob) - where - aKey = C.APublicVerifyKey C.SEd25519 sigPubKey - aSig = C.ASignature C.SEd25519 sig - -decodeOOBLink :: Text -> Either String OOB -decodeOOBLink = fmap (\(SignedOOB oob _verified) -> oob) . strDecode . encodeUtf8 - data PlatformEncoding = PESwift | PEKotlin @@ -285,16 +109,15 @@ localEncoding = PESwift localEncoding = PEKotlin #endif -type Tasks = TVar [Async ()] +data RemoteFile = RemoteFile + { userId :: Int64, + fileId :: Int64, + sent :: Bool, + fileSource :: CryptoFile + } + deriving (Show) -asyncRegistered :: MonadUnliftIO m => Tasks -> m () -> m () -asyncRegistered tasks action = async action >>= registerAsync tasks - -registerAsync :: MonadIO m => Tasks -> Async () -> m () -registerAsync tasks = atomically . modifyTVar tasks . (:) - -cancelTasks :: (MonadIO m) => Tasks -> m () -cancelTasks tasks = readTVarIO tasks >>= mapM_ cancel +$(J.deriveJSON defaultJSON ''RemoteFile) $(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) @@ -305,5 +128,3 @@ $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrl) $(J.deriveJSON defaultJSON ''RemoteCtrlInfo) - -$(J.deriveJSON defaultJSON ''RemoteFile) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 0dfe665b2f..df7ccd499b 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -15,6 +15,7 @@ import Simplex.Chat.Store.Shared 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.RemoteControl.Types insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId insertRemoteHost db storePath displayName caKey caCert = do diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index ccbd543e9a..7c62333d66 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -5,6 +5,9 @@ module RemoteTests where +import Simplex.Chat.Remote.RevHTTP +import qualified Simplex.RemoteControl.Discovery as Discovery +import Simplex.RemoteControl.Types import ChatClient import ChatTests.Utils import Control.Logger.Simple @@ -23,13 +26,11 @@ import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File -import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) @@ -43,9 +44,9 @@ import UnliftIO.Directory remoteTests :: SpecWith FilePath remoteTests = describe "Remote" $ do - it "generates usable credentials" genCredentialsTest + -- it "generates usable credentials" genCredentialsTest + -- it "OOB encoding, decoding, and signatures are correct" oobCodecTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - it "OOB encoding, decoding, and signatures are correct" oobCodecTest it "performs protocol handshake" remoteHandshakeTest it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check it "sends messages" remoteMessageTest @@ -55,38 +56,39 @@ remoteTests = describe "Remote" $ do -- * Low-level TLS with ephemeral credentials -genCredentialsTest :: (HasCallStack) => FilePath -> IO () -genCredentialsTest _tmp = do - (fingerprint, credentials) <- genTestCredentials - started <- newEmptyTMVarIO - bracket (Discovery.startTLSServer started credentials serverHandler) cancel $ \_server -> do - ok <- atomically (readTMVar started) - port <- maybe (error "TLS server failed to start") pure ok - logNote $ "Assigned port: " <> tshow port - Discovery.connectTLSClient ("127.0.0.1", fromIntegral port) fingerprint clientHandler - where - serverHandler serverTls = do - logNote "Sending from server" - Transport.putLn serverTls "hi client" - logNote "Reading from server" - Transport.getLn serverTls `shouldReturn` "hi server" - clientHandler clientTls = do - logNote "Sending from client" - Transport.putLn clientTls "hi server" - logNote "Reading from client" - Transport.getLn clientTls `shouldReturn` "hi client" +-- -- XXX: extract +-- genCredentialsTest :: (HasCallStack) => FilePath -> IO () +-- genCredentialsTest _tmp = do +-- (fingerprint, credentials) <- genTestCredentials +-- started <- newEmptyTMVarIO +-- bracket (startTLSServer started credentials serverHandler) cancel $ \_server -> do +-- ok <- atomically (readTMVar started) +-- port <- maybe (error "TLS server failed to start") pure ok +-- logNote $ "Assigned port: " <> tshow port +-- connectTLSClient ("127.0.0.1", fromIntegral port) fingerprint clientHandler +-- where +-- serverHandler serverTls = do +-- logNote "Sending from server" +-- Transport.putLn serverTls "hi client" +-- logNote "Reading from server" +-- Transport.getLn serverTls `shouldReturn` "hi server" +-- clientHandler clientTls = do +-- logNote "Sending from client" +-- Transport.putLn clientTls "hi server" +-- logNote "Reading from client" +-- Transport.getLn clientTls `shouldReturn` "hi client" -- * UDP discovery and rever HTTP2 -oobCodecTest :: (HasCallStack) => FilePath -> IO () -oobCodecTest _tmp = do - subscribers <- newTMVarIO 0 - localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure - (fingerprint, _credentials) <- genTestCredentials - (_dhKey, _sigKey, _ann, signedOOB@(SignedOOB oob _sig)) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint - verifySignedOOB signedOOB `shouldBe` True - strDecode (strEncode oob) `shouldBe` Right oob - strDecode (strEncode signedOOB) `shouldBe` Right signedOOB +-- oobCodecTest :: (HasCallStack) => FilePath -> IO () +-- oobCodecTest _tmp = do +-- subscribers <- newTMVarIO 0 +-- localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure +-- (fingerprint, _credentials) <- genTestCredentials +-- (_dhKey, _sigKey, _ann, signedOOB@(SignedOOB oob _sig)) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint +-- verifySignedOOB signedOOB `shouldBe` True +-- strDecode (strEncode oob) `shouldBe` Right oob +-- strDecode (strEncode signedOOB) `shouldBe` Right signedOOB announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do @@ -99,7 +101,7 @@ announceDiscoverHttp2Test _tmp = do controller <- async $ do logNote "Controller: starting" bracket - (Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials (putMVar finished ()) >>= either (fail . show) pure) + (announceRevHTTP2 tasks (sigKey, ann) credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do logNote "Controller: got client" @@ -119,7 +121,7 @@ announceDiscoverHttp2Test _tmp = do logNote $ "Host: connecting to " <> tshow service server <- async $ Discovery.connectTLSClient service fingerprint $ \tls -> do logNote "Host: got tls" - flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do + flip attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do logNote "Host: got request" sendResponse $ S.responseNoBody ok200 [] logNote "Host: sent response" From 8482dbfd99810edc98cae6e0e13c3b613ff9206d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 1 Nov 2023 19:08:36 +0000 Subject: [PATCH 027/174] core: update remote API commands/events (#3295) * core: update remote API * Add session verification event between tls and http2 * roll back char_ '@' parsers * use more specific parser for verification codes * cabal.project.local for mac --------- Co-authored-by: IC Rainbow --- cabal.project | 2 +- scripts/cabal.project.local.mac | 5 ++ src/Simplex/Chat.hs | 28 ++++---- src/Simplex/Chat/Controller.hs | 41 ++++++------ src/Simplex/Chat/Remote.hs | 100 +++++++++++++++++------------ src/Simplex/Chat/Remote/RevHTTP.hs | 5 -- src/Simplex/Chat/View.hs | 19 ++++-- tests/RemoteTests.hs | 21 ++++-- 8 files changed, 130 insertions(+), 91 deletions(-) diff --git a/cabal.project b/cabal.project index b693f6c88f..7fe664f4e1 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: db1b2f77cd1c172fab26b68c507cdd2c1b7b0e63 + tag: a5fed340e2814a226180ce1abe606ac79366fe5b source-repository-package type: git diff --git a/scripts/cabal.project.local.mac b/scripts/cabal.project.local.mac index 35c10db756..dd62f1a391 100644 --- a/scripts/cabal.project.local.mac +++ b/scripts/cabal.project.local.mac @@ -1,6 +1,11 @@ ignore-project: False -- amend to point to the actual openssl location + +package simplexmq + extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include + extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib + package direct-sqlcipher extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 157d7cea26..28b4c51120 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1922,10 +1922,10 @@ processChatCommand = \case DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_ - StartRemoteCtrl -> withUser_ $ startRemoteCtrl (execChatCommand Nothing) >> ok_ - RegisterRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) - AcceptRemoteCtrl rc -> withUser_ $ acceptRemoteCtrl rc >> ok_ - RejectRemoteCtrl rc -> withUser_ $ rejectRemoteCtrl rc >> ok_ + ConnectRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) + FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl (execChatCommand Nothing) >> ok_ + ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ + VerifyRemoteCtrlSession rc sessId -> withUser_ $ verifyRemoteCtrlSession rc sessId >> ok_ StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ @@ -5798,14 +5798,14 @@ chatCommandP = "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayName <*> (" force=on" $> True <|> pure False)), "/_get code @" *> (APIGetContactCode <$> A.decimal), "/_get code #" *> (APIGetGroupMemberCode <$> A.decimal <* A.space <*> A.decimal), - "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> textP)), - "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> textP)), + "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> verifyCodeP)), + "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> verifyCodeP)), "/_enable @" *> (APIEnableContact <$> A.decimal), "/_enable #" *> (APIEnableGroupMember <$> A.decimal <* A.space <*> A.decimal), "/code " *> char_ '@' *> (GetContactCode <$> displayName), "/code #" *> (GetGroupMemberCode <$> displayName <* A.space <* char_ '@' <*> displayName), - "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> textP)), - "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> textP)), + "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> verifyCodeP)), + "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> verifyCodeP)), "/enable " *> char_ '@' *> (EnableContact <$> displayName), "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, @@ -5856,7 +5856,7 @@ chatCommandP = "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), - ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), SendMessage <$> chatNameP <* A.space <*> msgTextP, "@#" *> (SendMemberContactMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> msgTextP), @@ -5926,12 +5926,11 @@ chatCommandP = "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), - "/start remote ctrl" $> StartRemoteCtrl, - "/register remote ctrl " *> (RegisterRemoteCtrl <$> strP), - -- "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), + "/connect remote ctrl " *> (ConnectRemoteCtrl <$> strP), + "/find remote ctrl" $> FindKnownRemoteCtrl, + "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), + "/verify remote ctrl " *> (VerifyRemoteCtrlSession <$> A.decimal <* A.space <*> textP), "/list remote ctrls" $> ListRemoteCtrls, - "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), - "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), "/stop remote ctrl" $> StopRemoteCtrl, "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, @@ -5997,6 +5996,7 @@ chatCommandP = fullNameP = A.space *> textP <|> pure "" textP = safeDecodeUtf8 <$> A.takeByteString pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) + verifyCodeP = safeDecodeUtf8 <$> A.takeWhile (\c -> isDigit c || c == ' ') msgTextP = jsonP <|> textP stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePath = stringP diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 25fe9294c6..77976da05c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -426,11 +426,11 @@ data ChatCommand | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} - | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers - | RegisterRemoteCtrl SignedOOB -- ^ Register OOB data for remote controller discovery and handshake + | ConnectRemoteCtrl SignedOOB -- ^ Connect new or existing controller via OOB data + | FindKnownRemoteCtrl -- ^ Start listening for announcements from all existing controllers + | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm the connection with found controller + | VerifyRemoteCtrlSession RemoteCtrlId Text -- ^ Verify remote controller session | ListRemoteCtrls - | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation - | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session | QuitChat @@ -458,11 +458,11 @@ allowRemoteCommand = \case GetRemoteFile {} -> False StopRemoteHost _ -> False DeleteRemoteHost _ -> False - RegisterRemoteCtrl {} -> False - StartRemoteCtrl -> False + ConnectRemoteCtrl {} -> False + FindKnownRemoteCtrl -> False + ConfirmRemoteCtrl _ -> False + VerifyRemoteCtrlSession {} -> False ListRemoteCtrls -> False - AcceptRemoteCtrl _ -> False - RejectRemoteCtrl _ -> False StopRemoteCtrl -> False DeleteRemoteCtrl _ -> False ExecChatStoreSQL _ -> False @@ -641,14 +641,16 @@ data ChatResponse | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRRemoteHostStarted {remoteHost :: RemoteHostInfo, sessionOOB :: Text} + | CRRemoteHostSessionCode {remoteHost :: RemoteHostInfo, sessionCode :: Text} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} - | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation + | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} -- TODO remove + | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- TODO remove, unregistered fingerprint, needs confirmation -- TODO is it needed? | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} + | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} -- TODO is remove + | CRRemoteCtrlSessionCode {remoteCtrl :: RemoteCtrlInfo, sessionCode :: Text, newCtrl :: Bool} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped | CRSQLResult {rows :: [Text]} @@ -679,8 +681,9 @@ allowRemoteEvent = \case CRRemoteCtrlAnnounce {} -> False CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False + CRRemoteCtrlSessionCode {} -> False CRRemoteCtrlConnected {} -> False - CRRemoteCtrlStopped {} -> False + CRRemoteCtrlStopped -> False _ -> True logResponseToFile :: ChatResponse -> Bool @@ -1060,6 +1063,7 @@ data RemoteCtrlError | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection | RCEBadFingerprint -- ^ Bad fingerprint data provided in OOB + | RCEBadVerificationCode -- ^ The code submitted doesn't match session TLSunique | RCEHTTP2Error {http2Error :: String} | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove | RCEInvalidResponse {responseError :: String} @@ -1071,13 +1075,14 @@ data ArchiveError | AEImportFile {file :: String, chatError :: ChatError} deriving (Show, Exception) +-- | Host (mobile) side of transport to process remote commands and forward notifications data RemoteCtrlSession = RemoteCtrlSession - { -- | Host (mobile) side of transport to process remote commands and forward notifications - discoverer :: Async (), - supervisor :: Async (), - hostServer :: Maybe (Async ()), - discovered :: TMap C.KeyHash (TransportHost, Word16), - accepted :: TMVar RemoteCtrlId, + { discoverer :: Async (), -- multicast listener + supervisor :: Async (), -- session state/subprocess supervisor + hostServer :: Maybe (Async ()), -- a running session + discovered :: TMap C.KeyHash (TransportHost, Word16), -- multicast-announced services + confirmed :: TMVar RemoteCtrlId, -- connection fingerprint found/stored in DB + verified :: TMVar (RemoteCtrlId, Text), -- user confirmed the session remoteOutputQ :: TBQueue ChatResponse } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cc3eb9f199..cb943ac2c5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -7,7 +8,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} - {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Remote where @@ -41,7 +41,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Messages (chatNameStr) import Simplex.Chat.Remote.Protocol -import Simplex.Chat.Remote.RevHTTP (announceRevHTTP2, connectRevHTTP2) +import Simplex.Chat.Remote.RevHTTP (announceRevHTTP2, attachHTTP2Server) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Files @@ -56,6 +56,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport (tlsUniq) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) @@ -65,6 +66,7 @@ import qualified Simplex.RemoteControl.Discovery as Discovery import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO +import UnliftIO.Concurrent (threadDelay) import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) -- * Desktop side @@ -93,13 +95,15 @@ startRemoteHost rhId = do rh <- withStore (`getRemoteHost` rhId) tasks <- startRemoteHostSession rh logInfo $ "Remote host session starting for " <> tshow rhId - asyncRegistered tasks $ run rh tasks `catchAny` \err -> do - logError $ "Remote host session startup failed for " <> tshow rhId <> ": " <> tshow err - cancelTasks tasks - chatModifyVar remoteHostSessions $ M.delete rhId - throwError $ fromMaybe (mkChatError err) $ fromException err - -- logInfo $ "Remote host session starting for " <> tshow rhId + asyncRegistered tasks $ + run rh tasks `catchAny` \err -> do + logError $ "Remote host session startup failed for " <> tshow rhId <> ": " <> tshow err + cancelTasks tasks + chatModifyVar remoteHostSessions $ M.delete rhId + throwError $ fromMaybe (mkChatError err) $ fromException err where + -- logInfo $ "Remote host session starting for " <> tshow rhId + run :: ChatMonad m => RemoteHost -> Tasks -> m () run rh@RemoteHost {storePath} tasks = do (fingerprint, credentials) <- liftIO $ genSessionCredentials rh @@ -109,7 +113,7 @@ startRemoteHost rhId = do chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH withRemoteHostSession rhId $ \sessions _ -> Right <$> TM.delete rhId sessions toView (CRRemoteHostStopped rhId) -- only signal "stopped" when the session is unregistered cleanly - -- block until some client is connected or an error happens + -- block until some client is connected or an error happens logInfo $ "Remote host session connecting for " <> tshow rhId rcName <- chatReadVar localDeviceName localAddr <- asks multicastSubscribers >>= Discovery.getLocalAddress >>= maybe (throwError . ChatError $ CEInternalError "unable to get local address") pure @@ -127,12 +131,14 @@ startRemoteHost rhId = do logInfo $ "Remote host session started for " <> tshow rhId chatModifyVar remoteHostSessions $ M.adjust (\rhs -> rhs {remoteHostClient = Just remoteHostClient}) rhId chatWriteVar currentRemoteHost $ Just rhId - toView $ CRRemoteHostConnected RemoteHostInfo - { remoteHostId = rhId, - storePath = storePath, - displayName = hostDeviceName remoteHostClient, - sessionActive = True - } + toView $ + CRRemoteHostConnected + RemoteHostInfo + { remoteHostId = rhId, + storePath = storePath, + displayName = hostDeviceName remoteHostClient, + sessionActive = True + } genSessionCredentials RemoteHost {caKey, caCert} = do sessionCreds <- genCredentials (Just parent) (0, 24) "Session" @@ -251,34 +257,48 @@ liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError) -- * Mobile side -startRemoteCtrl :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> m () -startRemoteCtrl execChatCommand = do +findKnownRemoteCtrl :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> m () +findKnownRemoteCtrl execChatCommand = do logInfo "Starting remote host" checkNoRemoteCtrlSession -- tiny race with the final @chatWriteVar@ until the setup finishes and supervisor spawned discovered <- newTVarIO mempty discoverer <- async $ discoverRemoteCtrls discovered -- TODO extract to a controller service singleton size <- asks $ tbqSize . config remoteOutputQ <- newTBQueueIO size - accepted <- newEmptyTMVarIO - supervisor <- async $ runHost discovered accepted $ handleRemoteCommand execChatCommand remoteOutputQ - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} + confirmed <- newEmptyTMVarIO + verified <- newEmptyTMVarIO + supervisor <- async $ do + threadDelay 500000 -- give chat controller a chance to reply with "ok" to prevent flaking tests + runHost discovered confirmed verified $ handleRemoteCommand execChatCommand remoteOutputQ + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, confirmed, verified, remoteOutputQ} -- | Track remote host lifecycle in controller session state and signal UI on its progress -runHost :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () -runHost discovered accepted handleHttp = do - remoteCtrlId <- atomically (readTMVar accepted) -- wait for ??? +runHost :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> TMVar RemoteCtrlId -> TMVar (RemoteCtrlId, Text) -> (HTTP2Request -> m ()) -> m () +runHost discovered confirmed verified handleHttp = do + remoteCtrlId <- atomically (readTMVar confirmed) -- wait for discoverRemoteCtrls.process or confirmRemoteCtrl to confirm fingerprint as a known RC rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) serviceAddress <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands + server <- async $ + -- spawn server for remote protocol commands + Discovery.connectTLSClient serviceAddress fingerprint $ \tls -> do + let sessionCode = decodeUtf8 . strEncode $ tlsUniq tls + toView $ CRRemoteCtrlSessionCode {remoteCtrl = remoteCtrlInfo rc True, sessionCode, newCtrl = False} + userInfo <- atomically $ readTMVar verified + if userInfo == (remoteCtrlId, sessionCode) + then do + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True + attachHTTP2Server handleHttp tls + else do + toView $ CRChatCmdError Nothing $ ChatErrorRemoteCtrl RCEBadVerificationCode + -- the server doesn't enter its loop and waitCatch below falls through chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server -- wait for the server to finish chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped -handleRemoteCommand :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> TBQueue ChatResponse -> HTTP2Request -> m () +handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> TBQueue ChatResponse -> HTTP2Request -> m () handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftRC (tryRemoteError parseRequest) >>= \case @@ -358,7 +378,7 @@ handleStoreFile fileName fileSize fileDigest getChunk = pure filePath handleGetFile :: ChatMonad m => User -> RemoteFile -> Respond m -> m () -handleGetFile User {userId} RemoteFile{userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do +handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do logDebug $ "GetFile: " <> tshow filePath unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId} path <- maybe filePath ( filePath) <$> chatReadVar filesFolder @@ -385,7 +405,7 @@ discoverRemoteCtrls discovered = do Left _ -> receive sock -- TODO it is probably better to report errors to view here _nonV4 -> receive sock - process sock (sockAddr, Announce {caFingerprint, serviceAddress=(annAddr, port)}) = do + process sock (sockAddr, Announce {caFingerprint, serviceAddress = (annAddr, port)}) = do unless (annAddr == sockAddr) $ logError "Announced address doesn't match socket address" let addr = THIPv4 (hostAddressToTuple sockAddr) ifM @@ -406,13 +426,13 @@ discoverRemoteCtrls discovered = do Just True -> chatReadVar remoteCtrlSession >>= \case Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" - Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically + Just RemoteCtrlSession {confirmed} -> atomically $ void $ tryPutTMVar confirmed remoteCtrlId -- previously accepted controller, connect automatically listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do active <- - chatReadVar remoteCtrlSession - $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted + chatReadVar remoteCtrlSession $>>= \RemoteCtrlSession {confirmed} -> + atomically $ tryReadTMVar confirmed map (rcInfo active) <$> withStore' getRemoteCtrls where rcInfo activeRcId rc@RemoteCtrl {remoteCtrlId} = @@ -422,19 +442,17 @@ remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive = RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive} -acceptRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () -acceptRemoteCtrl rcId = do +confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +confirmRemoteCtrl rcId = do -- TODO check it exists, check the ID is the same as in session - RemoteCtrlSession {accepted} <- getRemoteCtrlSession + RemoteCtrlSession {confirmed} <- getRemoteCtrlSession withStore' $ \db -> markRemoteCtrlResolution db rcId True - atomically . void $ tryPutTMVar accepted rcId -- the remote host can now proceed with connection + atomically . void $ tryPutTMVar confirmed rcId -- the remote host can now proceed with connection -rejectRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () -rejectRemoteCtrl rcId = do - withStore' $ \db -> markRemoteCtrlResolution db rcId False - RemoteCtrlSession {discoverer, supervisor} <- getRemoteCtrlSession - cancel discoverer - cancel supervisor +verifyRemoteCtrlSession :: ChatMonad m => RemoteCtrlId -> Text -> m () +verifyRemoteCtrlSession rcId sessId = do + RemoteCtrlSession {verified} <- getRemoteCtrlSession + void . atomically $ tryPutTMVar verified (rcId, sessId) stopRemoteCtrl :: ChatMonad m => m () stopRemoteCtrl = do diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs index c6c777596a..08c844dcf0 100644 --- a/src/Simplex/Chat/Remote/RevHTTP.hs +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -11,11 +11,9 @@ module Simplex.Chat.Remote.RevHTTP where import Simplex.RemoteControl.Discovery import Simplex.RemoteControl.Types import Control.Logger.Simple -import Data.Word (Word16) import qualified Network.TLS as TLS import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Transport as Transport -import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) @@ -39,9 +37,6 @@ runHTTP2Client finishedVar clientVar tls = -- TODO connection timeout config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} -connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () -connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server - attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () attachHTTP2Server processRequest tls = do withRunInIO $ \unlift -> diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ed4b768cd8..8779480a5c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -275,17 +275,26 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostStarted {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionOOB} -> ["remote host " <> sShow rhId <> " started", "connection code:", plain sessionOOB] + CRRemoteHostSessionCode {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionCode} -> + ["remote host " <> sShow rhId <> " is connecting", "Compare session code with host:", plain sessionCode] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] - CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] - CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] + CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> + ["remote controller " <> sShow rcId <> " registered"] + CRRemoteCtrlAnnounce fingerprint -> + ["remote controller announced", "connection code:", plain $ strEncode fingerprint] + CRRemoteCtrlFound rc -> + ["remote controller found:", viewRemoteCtrl rc] + CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> + ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] + CRRemoteCtrlSessionCode {remoteCtrl = RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName}, sessionCode} -> + ["remote controller " <> sShow rcId <> " connected to " <> plain rcName, "Compare session code with controller and use:", "/verify remote ctrl " <> sShow rcId <> " " <> plain sessionCode] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> + ["remote controller " <> sShow rcId <> " session started with " <> plain rcName] CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 7c62333d66..b2e7aa5cb1 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -30,7 +30,7 @@ import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding (smpDecode) -import Simplex.Messaging.Encoding.String (strDecode, strEncode) +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) @@ -408,7 +408,7 @@ startRemote mobile desktop = do mobile ##> "/set device name Mobile" mobile <## "ok" - mobile ##> "/start remote ctrl" + mobile ##> "/find remote ctrl" mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" @@ -416,13 +416,20 @@ startRemote mobile desktop = do -- The user scans OOB QR code and confirms it matches the announced stuff fromString annFingerprint `shouldBe` strEncode oobFingerprint - mobile ##> ("/register remote ctrl " <> oobLink) + mobile ##> ("/connect remote ctrl " <> oobLink) mobile <## "remote controller 1 registered" - mobile ##> "/accept remote ctrl 1" - mobile <## "ok" -- alternative scenario: accepted before controller start + mobile ##> "/confirm remote ctrl 1" + mobile <## "ok" mobile <## "remote controller 1 connecting to My desktop" - mobile <## "remote controller 1 connected, My desktop" - desktop <## "remote host 1 connected" + -- TODO: rework tls connection prelude + mobile <## "remote controller 1 connected to My desktop" + mobile <## "Compare session code with controller and use:" + verifyCmd <- getTermLine mobile + mobile ##> verifyCmd + mobile <## "ok" + concurrently_ + (mobile <## "remote controller 1 session started with My desktop") + (desktop <## "remote host 1 connected") contactBob :: TestCC -> TestCC -> IO () contactBob desktop bob = do From 0cc26d192d5a0f94338ee1584b2e38311d613b4d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 2 Nov 2023 14:07:51 +0000 Subject: [PATCH 028/174] update sha256map.nix --- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 680b920754..9b8d3d5f4b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e9b5a849ab18de085e8c69d239a9706b99bcf787" = "0b50mlnzwian4l9kx4niwnj9qkyp21ryc8x9d3il9jkdfxrx8kqi"; + "https://github.com/simplex-chat/simplexmq.git"."a5fed340e2814a226180ce1abe606ac79366fe5b" = "18sj1499rfb35wsgb9gbr3q99flhw650jm0wnn1iw42m9f17vwbn"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/stack.yaml b/stack.yaml index da69b9e905..fe6771b5cd 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: e9b5a849ab18de085e8c69d239a9706b99bcf787 + commit: a5fed340e2814a226180ce1abe606ac79366fe5b - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From 177112ab18f43278e2f4173e61d0f9420a4da286 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 4 Nov 2023 19:04:40 +0000 Subject: [PATCH 029/174] update simplexmq --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 14 +++++++------- stack.yaml | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cabal.project b/cabal.project index d87f7aca12..e201ebc320 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a5fed340e2814a226180ce1abe606ac79366fe5b + tag: 1a0c4b73de5cda4ac6765dd47e0199238e498d5f source-repository-package type: git diff --git a/package.yaml b/package.yaml index 2bfb36d18d..2166f0c9e1 100644 --- a/package.yaml +++ b/package.yaml @@ -31,7 +31,7 @@ dependencies: - exceptions == 0.10.* - filepath == 1.4.* - http-types == 0.12.* - - http2 == 4.1.* + - http2 >= 4.2.2 && < 4.3 - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 9b8d3d5f4b..ce443e2c3f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a5fed340e2814a226180ce1abe606ac79366fe5b" = "18sj1499rfb35wsgb9gbr3q99flhw650jm0wnn1iw42m9f17vwbn"; + "https://github.com/simplex-chat/simplexmq.git"."1a0c4b73de5cda4ac6765dd47e0199238e498d5f" = "12xpr2lxw9rr3v2bz5m5g9bb0kj7c5yyan47w0nnp52gzfs4pff0"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 2dc77c3c22..5b2a59e297 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -179,7 +179,7 @@ library , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 ==4.1.* + , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -231,7 +231,7 @@ executable simplex-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 ==4.1.* + , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -284,7 +284,7 @@ executable simplex-bot-advanced , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 ==4.1.* + , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -339,7 +339,7 @@ executable simplex-broadcast-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 ==4.1.* + , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -393,7 +393,7 @@ executable simplex-chat , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 ==4.1.* + , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* @@ -451,7 +451,7 @@ executable simplex-directory-service , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 ==4.1.* + , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -535,7 +535,7 @@ test-suite simplex-chat-test , generic-random ==1.5.* , hspec ==2.11.* , http-types ==0.12.* - , http2 ==4.1.* + , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* diff --git a/stack.yaml b/stack.yaml index fe6771b5cd..9043a56952 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: a5fed340e2814a226180ce1abe606ac79366fe5b + commit: 1a0c4b73de5cda4ac6765dd47e0199238e498d5f - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From e3938f6fb52b9e0eea05a90e8f9a25a2a2eb3ebf Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 8 Nov 2023 06:58:19 +0800 Subject: [PATCH 030/174] android: replaced function that requires higher API (#3324) --- .../kotlin/chat/simplex/common/views/helpers/Utils.kt | 2 +- .../kotlin/chat/simplex/common/platform/Images.desktop.kt | 3 +-- .../kotlin/chat/simplex/common/views/call/CallView.desktop.kt | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index dae79e6fc0..5e64de2c54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -137,7 +137,7 @@ fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { val destFileName = generateNewFileName("IMG", ext) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { - val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readAllBytes() ?: return null) + val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readBytes() ?: return null) CryptoFile(destFileName, args) } else { Files.copy(uri.inputStream(), destFile.toPath()) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index c04587656f..0df5ee8156 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -170,9 +170,8 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean { return path.endsWith(".gif") || path.endsWith(".webp") } -@Suppress("NewApi") actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = - Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap() + Image.makeFromEncoded(inputStream.readBytes()).toComposeImageBitmap() // https://stackoverflow.com/a/68926993 fun BufferedImage.rotate(angle: Double): BufferedImage { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 42def0c75f..cce8a3ce85 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -189,12 +189,11 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) - @Suppress("NewApi") fun resourcesToResponse(path: String): Response { val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound val response = newFixedLengthResponse( Status.OK, getMimeTypeForFile(uri.file), - uri.openStream().readAllBytes() + uri.openStream().readBytes() ) response.setKeepAlive(true) response.setUseGzip(true) From ee6bd0f8397157eec6d7aa08cafab5a0ffea6b3e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:56:55 +0400 Subject: [PATCH 031/174] core: add image to simplex contact profile (#3326) --- 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 f3b3a4acc6..cb7fc17af7 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -6074,7 +6074,7 @@ simplexContactProfile :: Profile simplexContactProfile = Profile { displayName = "SimpleX Chat team", fullName = "", - image = Nothing, + image = Just (ImageData "data:image/jpg;base64,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"), contactLink = Just adminContactReq, preferences = Nothing } From 8722d35278b2fd096a7acd4e359af390116a0427 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 8 Nov 2023 13:15:08 +0400 Subject: [PATCH 032/174] core: fix deletion of contact without connections (#3327) --- src/Simplex/Chat.hs | 8 ++++---- src/Simplex/Chat/Store/Direct.hs | 4 ++-- tests/ChatTests/Profiles.hs | 6 ++++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index cb7fc17af7..5e3d2f0dad 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -933,7 +933,7 @@ processChatCommand = \case deleteFilesAndConns user filesInfo when (contactReady ct && contactActive ct && notify) $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) - contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) + contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct) deleteAgentConnectionsAsync user contactConnIds -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) @@ -976,7 +976,7 @@ processChatCommand = \case withStore' (\db -> checkContactHasGroups db user ct) >>= \case Just _ -> pure [] Nothing -> do - conns <- withStore $ \db -> getContactConnections db userId ct + conns <- withStore' $ \db -> getContactConnections db userId ct withStore' (\db -> setContactDeleted db user ct) `catchChatError` (toView . CRChatError (Just user)) pure $ map aConnId conns @@ -4496,7 +4496,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then do checkIntegrityCreateItem (CDDirectRcv c) msgMeta ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted - contactConns <- withStore $ \db -> getContactConnections db userId ct' + contactConns <- withStore' $ \db -> getContactConnections db 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} @@ -4505,7 +4505,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do 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 userId c deleteAgentConnectionsAsync user $ map aConnId contactConns withStore' $ \db -> deleteContact db user c diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index df346948e5..bfc29fcd29 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -723,7 +723,7 @@ getPendingContactConnections db User {userId} = do |] [":user_id" := userId, ":conn_type" := ConnContact] -getContactConnections :: DB.Connection -> UserId -> Contact -> ExceptT StoreError IO [Connection] +getContactConnections :: DB.Connection -> UserId -> Contact -> IO [Connection] getContactConnections db userId Contact {contactId} = connections =<< liftIO getConnections_ where @@ -739,7 +739,7 @@ getContactConnections db userId Contact {contactId} = WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? |] (userId, userId, contactId) - connections [] = throwError $ SEContactNotFound contactId + connections [] = pure [] connections rows = pure $ map toConnection rows getConnectionById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Connection diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 3a38a16135..0a45a74ade 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -779,6 +779,12 @@ testPlanAddressContactViaAddress = void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile bob @@@ [("@alice", "")] + bob ##> "/delete @alice" + bob <## "alice: contact is deleted" + + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + bob @@@ [("@alice", "")] + bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: known contact without connection alice" From d233d07ddc13ee278479c9df4d62337a6fe3d6d8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 8 Nov 2023 12:50:56 +0000 Subject: [PATCH 033/174] ci: ghc 9.6.3 (#3328) --- .github/workflows/build.yml | 2 +- Dockerfile | 4 +-- docs/CLI.md | 2 +- docs/CONTRIBUTING.md | 55 +++++++++++++----------------- scripts/desktop/build-lib-linux.sh | 2 +- scripts/desktop/build-lib-mac.sh | 2 +- 6 files changed, 29 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8785360693..b4301dcb3a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,7 +81,7 @@ jobs: - name: Setup Haskell uses: haskell-actions/setup@v2 with: - ghc-version: "9.6.2" + ghc-version: "9.6.3" cabal-version: "3.10.1.0" - name: Cache dependencies diff --git a/Dockerfile b/Dockerfile index 0c0788c81d..834f2374a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,11 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/ chmod +x /usr/bin/ghcup # Install ghc -RUN ghcup install ghc 9.6.2 +RUN ghcup install ghc 9.6.3 # Install cabal RUN ghcup install cabal 3.10.1.0 # Set both as default -RUN ghcup set ghc 9.6.2 && \ +RUN ghcup set ghc 9.6.3 && \ ghcup set cabal 3.10.1.0 COPY . /project diff --git a/docs/CLI.md b/docs/CLI.md index 7966627c4a..f781f85749 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -102,7 +102,7 @@ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . #### In any OS -1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 9.6.2 and cabal 3.10.1.0: +1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 9.6.3 and cabal 3.10.1.0: ```shell curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0aa09c5166..aaf452af00 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -30,29 +30,29 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t **In simplex-chat repo** -- `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.2). +- `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.3). -- `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7). +- `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7) - only for Android armv7a. -- `stable-ios` - used to build stable iOS core library with Nix (GHC 8.10.7) – this branch should be the same as `stable-android` except Nix configuration files. +- `stable-ios` - used to build stable iOS core library with Nix (GHC 8.10.7) – this branch should be the same as `stable-android` except Nix configuration files. Deprecated. -- `master` - branch for beta version releases (GHC 9.6.2). +- `master` - branch for beta version releases (GHC 9.6.3). -- `master-ghc8107` - branch for beta version releases (GHC 8.10.7). +- `master-ghc8107` - branch for beta version releases (GHC 8.10.7). Deprecated. -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7), same as `master-ghc8107` +- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7) - only for Android armv7a. -- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7). +- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7). Deprecated. -- `windows-ghc8107` - branch for windows core library build (GHC 8.10.7). +- `windows-ghc8107` - branch for windows core library build (GHC 8.10.7). Deprecated? `master-ios` and `windows-ghc8107` branches should be the same as `master-ghc8107` except Nix configuration files. **In simplexmq repo** -- `master` - uses GHC 9.6.2 its commit should be used in `master` branch of simplex-chat repo. +- `master` - uses GHC 9.6.3 its commit should be used in `master` branch of simplex-chat repo. -- `master-ghc8107` - its commit should be used in `master-android` (and `master-ios`) branch of simplex-chat repo. +- `master-ghc8107` - its commit should be used in `master-android` (and `master-ios`) branch of simplex-chat repo. Deprecated. ## Development & release process @@ -61,53 +61,44 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t 2. If simplexmq repo was changed, to build mobile core libraries you need to merge its `master` branch into `master-ghc8107` branch. 3. To build core libraries for Android, iOS and windows: -- merge `master` branch to `master-ghc8107` branch. -- update `simplexmq` commit in `master-ghc8107` branch to the commit in `master-ghc8107` branch (probably, when resolving merge conflicts). +- merge `master` branch to `master-android` branch. - update code to be compatible with GHC 8.10.7 (see below). - push to GitHub. -4. To build Android core library, merge `master-ghc8107` branch to `master-android` branch, and push to GitHub. +4. All libraries should be built from `master` branch, Android armv7a - from `master-android` branch. -5. To build iOS core library, merge `master-ghc8107` branch to `master-ios` branch, and push to GitHub. +5. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. -6. To build windows core library, merge `master-ghc8107` branch to `windows-ghc8107` branch, and push to GitHub. - -7. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. - -8. After the public release to App Store and Play Store, merge: +6. After the public release to App Store and Play Store, merge: - `master` to `stable` -- `master` to `master-ghc8107` (and compile/update code) -- `master-ghc8107` to `master-android` -- `master-ghc8107` to `master-ios` -- `master-ghc8107` to `windows-ghc8107` +- `master` to `master-android` (and compile/update code) - `master-android` to `stable-android` -- `master-ios` to `stable-ios` -9. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. +7. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. -## Differences between GHC 8.10.7 and GHC 9.6.2 +## Differences between GHC 8.10.7 and GHC 9.6.3 1. The main difference is related to `DuplicateRecordFields` extension. -It is no longer possible in GHC 9.6.2 to specify type when using selectors, instead OverloadedRecordDot extension and syntax are used that need to be removed in GHC 8.10.7: +It is no longer possible in GHC 9.6.3 to specify type when using selectors, instead OverloadedRecordDot extension and syntax are used that need to be removed in GHC 8.10.7: ```haskell {-# LANGUAGE DuplicateRecordFields #-} --- use this in GHC 9.6.2 when needed +-- use this in GHC 9.6.3 when needed {-# LANGUAGE OverloadedRecordDot #-} --- GHC 9.6.2 syntax +-- GHC 9.6.3 syntax let x = record.field --- GHC 8.10.7 syntax removed in GHC 9.6.2 +-- GHC 8.10.7 syntax removed in GHC 9.6.3 let x = field (record :: Record) ``` It is still possible to specify type when using record update syntax, use this pragma to suppress compiler warning: ```haskell --- use this in GHC 9.6.2 when needed +-- use this in GHC 9.6.3 when needed {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} let r' = (record :: Record) {field = value} @@ -116,7 +107,7 @@ let r' = (record :: Record) {field = value} 2. Most monad functions now have to be imported from `Control.Monad`, and not from specific monad modules (e.g. `Control.Monad.Except`). ```haskell --- use this in GHC 9.6.2 when needed +-- use this in GHC 9.6.3 when needed import Control.Monad ``` diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index e0ee7e6698..fa1f892a03 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -8,7 +8,7 @@ function readlink() { OS=linux ARCH=${1:-`uname -a | rev | cut -d' ' -f2 | rev`} -GHC_VERSION=9.6.2 +GHC_VERSION=9.6.3 if [ "$ARCH" == "aarch64" ]; then COMPOSE_ARCH=arm64 diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index c33f59253f..1a4deced4a 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -5,7 +5,7 @@ set -e OS=mac ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" COMPOSE_ARCH=$ARCH -GHC_VERSION=9.6.2 +GHC_VERSION=9.6.3 if [ "$ARCH" == "arm64" ]; then ARCH=aarch64 From b72914477375c917efe406339a20ec9c7c7d4781 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 8 Nov 2023 22:13:52 +0200 Subject: [PATCH 034/174] core: use xrcp protocol for desktop/mobile connection (#3305) * WIP: start working on /connect remote ctrl OOB is broken, requires fixing simplexmq bits. * WIP: pull CtrlCryptoHandle from xrcp * place xrcp stubs * WIP: start switching to RemoteControl.Client types * fix http2 sha * fix sha256map.nix * fix cabal.project * update RC test * WIP: add new remote session * fix compilation * simplify * attach HTTP2 server to TLS * starting host session in controller (WIP) * more WIP * compiles * compiles2 * wip * pass startRemote' test * async to poll for events from host, test to send messages fails * move xrcp handshake test to simplexmq * detect session stops * fix connectRemoteCtrl * use step type * app info * WIP: pairing stores * plug in hello/appInfo/pairings * negotiate app version * update simplexmw, remove KEM secrets from DB * fix file tests * tone down http2 shutdown errors * Add stored session test * bump simplexmq tag * update simplexmq * refactor, fix * removed unused errors * rename fields, remove unused file * rename errors --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 44 +- src/Simplex/Chat/Controller.hs | 106 ++-- .../Migrations/M20231114_remote_controller.hs | 32 +- src/Simplex/Chat/Migrations/chat_schema.sql | 32 +- src/Simplex/Chat/Remote.hs | 527 ++++++++++-------- src/Simplex/Chat/Remote/AppVersion.hs | 69 +++ src/Simplex/Chat/Remote/Protocol.hs | 18 +- src/Simplex/Chat/Remote/RevHTTP.hs | 29 +- src/Simplex/Chat/Remote/Types.hs | 83 +-- src/Simplex/Chat/Store/Remote.hs | 120 +++- src/Simplex/Chat/Store/Shared.hs | 3 + src/Simplex/Chat/View.hs | 43 +- stack.yaml | 2 +- tests/ChatClient.hs | 2 +- tests/RemoteTests.hs | 227 +++----- 18 files changed, 761 insertions(+), 581 deletions(-) create mode 100644 src/Simplex/Chat/Remote/AppVersion.hs diff --git a/cabal.project b/cabal.project index 1c149d2015..fe74d21485 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1a0c4b73de5cda4ac6765dd47e0199238e498d5f + tag: 102487bc4fbb865aac4207d2ba6f2ea77eff3290 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3e3df9fdb6..26f7357a11 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."1a0c4b73de5cda4ac6765dd47e0199238e498d5f" = "12xpr2lxw9rr3v2bz5m5g9bb0kj7c5yyan47w0nnp52gzfs4pff0"; + "https://github.com/simplex-chat/simplexmq.git"."102487bc4fbb865aac4207d2ba6f2ea77eff3290" = "1zay63ix9vh20p6843l1zry47zwb7lkirmxrrgdcc7qwl89js1bs"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 32711d3858..ba5297e696 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -131,6 +131,7 @@ library Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote + Simplex.Chat.Remote.AppVersion Simplex.Chat.Remote.Multicast Simplex.Chat.Remote.Protocol Simplex.Chat.Remote.RevHTTP diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b9663bae1d..664661603b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -46,7 +46,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) import Data.Time.Clock.System (SystemTime, systemToUTCTime) @@ -72,7 +72,6 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles -import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -376,8 +375,8 @@ restoreCalls = do stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do - readTVarIO remoteHostSessions >>= mapM_ cancelRemoteHostSession - readTVarIO remoteCtrlSession >>= mapM_ cancelRemoteCtrlSession_ + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost) + atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles @@ -409,7 +408,7 @@ execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ChatCommand -> ByteString -> m ChatResponse -execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh cmd s +execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError)) @@ -1953,17 +1952,18 @@ processChatCommand = \case updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_ - CreateRemoteHost -> CRRemoteHostCreated <$> createRemoteHost - ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts - StartRemoteHost rh -> startRemoteHost rh >> ok_ - StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ - DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ - StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath - GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_ - ConnectRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) - FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl (execChatCommand Nothing) >> ok_ + ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts + StartRemoteHost rh_ -> withUser_ $ do + (remoteHost_, inv) <- startRemoteHost' rh_ + pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} + StopRemoteHost rh_ -> withUser_ $ closeRemoteHost rh_ >> ok_ + DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ + StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath + GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ + ConnectRemoteCtrl oob -> withUser_ $ connectRemoteCtrl oob >> ok_ + FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ - VerifyRemoteCtrlSession rc sessId -> withUser_ $ verifyRemoteCtrlSession rc sessId >> ok_ + VerifyRemoteCtrlSession sessId -> withUser_ $ CRRemoteCtrlConnected <$> verifyRemoteCtrlSession (execChatCommand Nothing) sessId StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ @@ -5717,12 +5717,6 @@ waitChatStarted = do agentStarted <- asks agentAsync atomically $ readTVar agentStarted >>= \a -> unless (isJust a) retry -withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a -withAgent action = - asks smpAgent - >>= runExceptT . action - >>= liftEither . first (`ChatErrorAgent` Nothing) - chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -5981,17 +5975,17 @@ chatCommandP = "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), - "/create remote host" $> CreateRemoteHost, + -- "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, - "/start remote host " *> (StartRemoteHost <$> A.decimal), - "/stop remote host " *> (StopRemoteHost <$> A.decimal), + "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False))))), + "/stop remote host " *> (StopRemoteHost <$> ("new" $> RHNew <|> RHId <$> A.decimal)), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), "/connect remote ctrl " *> (ConnectRemoteCtrl <$> strP), "/find remote ctrl" $> FindKnownRemoteCtrl, "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), - "/verify remote ctrl " *> (VerifyRemoteCtrlSession <$> A.decimal <* A.space <*> textP), + "/verify remote ctrl " *> (VerifyRemoteCtrlSession <$> textP), "/list remote ctrls" $> ListRemoteCtrls, "/stop remote ctrl" $> StopRemoteCtrl, "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6ca541a718..4c38ca95d9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -16,6 +16,7 @@ module Simplex.Chat.Controller where +import Simplex.RemoteControl.Invitation (RCSignedInvitation) import Control.Concurrent (ThreadId) import Control.Concurrent.Async (Async) import Control.Exception @@ -40,7 +41,6 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) -import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -49,6 +49,7 @@ import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol +import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types @@ -73,10 +74,12 @@ import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version -import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM +import Data.Bifunctor (first) +import Simplex.RemoteControl.Client +import Simplex.RemoteControl.Types versionNumber :: String versionNumber = showVersion SC.version @@ -180,7 +183,7 @@ data ChatController = ChatController currentCalls :: TMap ContactId Call, localDeviceName :: TVar Text, multicastSubscribers :: TMVar Int, - remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts + remoteHostSessions :: TMap RHKey RemoteHostSession, -- All the active remote hosts remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers config :: ChatConfig, @@ -419,18 +422,18 @@ data ChatCommand | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) | SetLocalDeviceName Text - | CreateRemoteHost -- ^ Configure a new remote host + -- | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts - | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host + | StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host - | StopRemoteHost RemoteHostId -- ^ Shut down a running session + | StopRemoteHost RHKey -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} - | ConnectRemoteCtrl SignedOOB -- ^ Connect new or existing controller via OOB data + | ConnectRemoteCtrl RCSignedInvitation -- ^ Connect new or existing controller via OOB data | FindKnownRemoteCtrl -- ^ Start listening for announcements from all existing controllers | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm the connection with found controller - | VerifyRemoteCtrlSession RemoteCtrlId Text -- ^ Verify remote controller session + | VerifyRemoteCtrlSession Text -- ^ Verify remote controller session | ListRemoteCtrls | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session @@ -451,7 +454,6 @@ allowRemoteCommand = \case APISuspendChat _ -> False SetTempFolder _ -> False QuitChat -> False - CreateRemoteHost -> False ListRemoteHosts -> False StartRemoteHost _ -> False -- SwitchRemoteHost {} -> False @@ -642,8 +644,8 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} - | CRRemoteHostStarted {remoteHost :: RemoteHostInfo, sessionOOB :: Text} - | CRRemoteHostSessionCode {remoteHost :: RemoteHostInfo, sessionCode :: Text} + | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} + | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} @@ -652,7 +654,7 @@ data ChatResponse | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- TODO remove, unregistered fingerprint, needs confirmation -- TODO is it needed? | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} -- TODO is remove - | CRRemoteCtrlSessionCode {remoteCtrl :: RemoteCtrlInfo, sessionCode :: Text, newCtrl :: Bool} + | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped | CRSQLResult {rows :: [Text]} @@ -949,7 +951,7 @@ data ChatError | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} - | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} + | ChatErrorRemoteHost {rhKey :: RHKey, remoteHostError :: RemoteHostError} deriving (Show, Exception) data ChatErrorType @@ -1048,29 +1050,24 @@ throwDBError = throwError . ChatErrorDatabase -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteHostError - = RHMissing -- ^ No remote session matches this identifier - | RHBusy -- ^ A session is already running - | RHRejected -- ^ A session attempt was rejected by a host - | RHTimeout -- ^ A discovery or a remote operation has timed out - | RHDisconnected {reason :: Text} -- ^ A session disconnected by a host - | RHConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues - | RHProtocolError RemoteProtocolError + = RHEMissing -- ^ No remote session matches this identifier + | RHEBusy -- ^ A session is already running + | RHEBadState -- ^ Illegal state transition + | RHEBadVersion {appVersion :: AppVersion} + | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected? + | RHEProtocolError RemoteProtocolError deriving (Show, Exception) -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError = RCEInactive -- ^ No session is running + | RCEBadState -- ^ A session is in a wrong state for the current operation | RCEBusy -- ^ A session is already running - | RCETimeout -- ^ Remote operation timed out | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller - | RCEConnectionLost {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected due to transport issues - | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period - | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection - | RCEBadFingerprint -- ^ Bad fingerprint data provided in OOB + | RCEBadInvitation + | RCEBadVersion {appVersion :: AppVersion} | RCEBadVerificationCode -- ^ The code submitted doesn't match session TLSunique - | RCEHTTP2Error {http2Error :: String} - | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove - | RCEInvalidResponse {responseError :: String} + | RCEHTTP2Error {http2Error :: Text} -- TODO currently not used | RCEProtocolError {protocolError :: RemoteProtocolError} deriving (Show, Exception) @@ -1080,15 +1077,26 @@ data ArchiveError deriving (Show, Exception) -- | Host (mobile) side of transport to process remote commands and forward notifications -data RemoteCtrlSession = RemoteCtrlSession - { discoverer :: Async (), -- multicast listener - supervisor :: Async (), -- session state/subprocess supervisor - hostServer :: Maybe (Async ()), -- a running session - discovered :: TMap C.KeyHash (TransportHost, Word16), -- multicast-announced services - confirmed :: TMVar RemoteCtrlId, -- connection fingerprint found/stored in DB - verified :: TMVar (RemoteCtrlId, Text), -- user confirmed the session - remoteOutputQ :: TBQueue ChatResponse - } +data RemoteCtrlSession + = RCSessionStarting + | RCSessionConnecting + { rcsClient :: RCCtrlClient, + rcsWaitSession :: Async () + } + | RCSessionPendingConfirmation + { ctrlName :: Text, + rcsClient :: RCCtrlClient, + sessionCode :: Text, + rcsWaitSession :: Async (), + rcsWaitConfirmation :: TMVar (Either RCErrorType (RCCtrlSession, RCCtrlPairing)) + } + | RCSessionConnected + { remoteCtrlId :: RemoteCtrlId, + rcsClient :: RCCtrlClient, + rcsSession :: RCCtrlSession, + http2Server :: Async (), + remoteOutputQ :: TBQueue ChatResponse + } type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) @@ -1140,17 +1148,13 @@ throwChatError = throwError . ChatError toView :: ChatMonad' m => ChatResponse -> m () toView event = do localQ <- asks outputQ - chatReadVar remoteCtrlSession >>= \case - Nothing -> atomically $ writeTBQueue localQ (Nothing, Nothing, event) - Just RemoteCtrlSession {remoteOutputQ} -> - if allowRemoteEvent event - then do - -- TODO: filter events or let the UI ignore trigger events by itself? - -- traceM $ "Sending event to remote Q: " <> show event - atomically $ writeTBQueue remoteOutputQ event -- TODO: check full? - else do - -- traceM $ "Sending event to local Q: " <> show event - atomically $ writeTBQueue localQ (Nothing, Nothing, event) + session <- asks remoteCtrlSession + atomically $ + readTVar session >>= \case + Just RCSessionConnected {remoteOutputQ} | allowRemoteEvent event -> + writeTBQueue remoteOutputQ event + -- TODO potentially, it should hold some events while connecting + _ -> writeTBQueue localQ (Nothing, Nothing, event) withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a withStore' action = withStore $ liftIO . action @@ -1179,6 +1183,12 @@ withStoreCtx ctx_ action = do handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr +withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a +withAgent action = + asks smpAgent + >>= runExceptT . action + >>= liftEither . first (`ChatErrorAgent` Nothing) + $(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs b/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs index 5b7ea1c7b0..a8e92a9988 100644 --- a/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs @@ -10,18 +10,32 @@ m20231114_remote_controller = [sql| CREATE TABLE remote_hosts ( -- hosts known to a controlling app remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, - store_path TEXT NOT NULL, -- file path relative to app store (must not contain "/") - display_name TEXT NOT NULL, -- user-provided name for a remote host - ca_key BLOB NOT NULL, -- private key for signing session certificates - ca_cert BLOB NOT NULL, -- root certificate, whose fingerprint is pinned on a remote - contacted INTEGER NOT NULL DEFAULT 0 -- 0 (first time), 1 (connected before) + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, -- file path for host files relative to app storage (must not contain "/") + -- RCHostPairing + ca_key BLOB NOT NULL, -- private key to sign session certificates + ca_cert BLOB NOT NULL, -- root certificate + id_key BLOB NOT NULL, -- long-term/identity signing key + -- KnownHostPairing + host_fingerprint BLOB NOT NULL, -- pinned remote host CA, set when connected + -- stored host session key + host_dh_pub BLOB NOT NULL, -- session DH key + UNIQUE (host_fingerprint) ON CONFLICT FAIL ); CREATE TABLE remote_controllers ( -- controllers known to a hosting app - remote_controller_id INTEGER PRIMARY KEY AUTOINCREMENT, - display_name TEXT NOT NULL, -- user-provided name for a remote controller - fingerprint BLOB NOT NULL, -- remote controller CA fingerprint - accepted INTEGER -- NULL (unknown), 0 (rejected), 1 (confirmed) + remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, + ctrl_device_name TEXT NOT NULL, + -- RCCtrlPairing + ca_key BLOB NOT NULL, -- CA key + ca_cert BLOB NOT NULL, -- CA certificate for TLS clients + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA, set when connected + id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures + -- stored session key, commited on connection confirmation + dh_priv_key BLOB NOT NULL, -- session DH key + -- prev session key + prev_dh_priv_key BLOB, -- previous session DH key + UNIQUE (ctrl_fingerprint) ON CONFLICT FAIL ); |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index ebe3fc1112..bef0d3605f 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -523,18 +523,32 @@ CREATE TABLE IF NOT EXISTS "received_probes"( CREATE TABLE remote_hosts( -- hosts known to a controlling app remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, - store_path TEXT NOT NULL, -- file path relative to app store(must not contain "/") - display_name TEXT NOT NULL, -- user-provided name for a remote host - ca_key BLOB NOT NULL, -- private key for signing session certificates - ca_cert BLOB NOT NULL, -- root certificate, whose fingerprint is pinned on a remote - contacted INTEGER NOT NULL DEFAULT 0 -- 0(first time), 1(connected before) + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, -- file path for host files relative to app storage(must not contain "/") + -- RCHostPairing + ca_key BLOB NOT NULL, -- private key to sign session certificates + ca_cert BLOB NOT NULL, -- root certificate + id_key BLOB NOT NULL, -- long-term/identity signing key + -- KnownHostPairing + host_fingerprint BLOB NOT NULL, -- pinned remote host CA, set when connected + -- stored host session key + host_dh_pub BLOB NOT NULL, -- session DH key + UNIQUE(host_fingerprint) ON CONFLICT FAIL ); CREATE TABLE remote_controllers( -- controllers known to a hosting app - remote_controller_id INTEGER PRIMARY KEY AUTOINCREMENT, - display_name TEXT NOT NULL, -- user-provided name for a remote controller - fingerprint BLOB NOT NULL, -- remote controller CA fingerprint - accepted INTEGER -- NULL(unknown), 0(rejected), 1(confirmed) + remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, + ctrl_device_name TEXT NOT NULL, + -- RCCtrlPairing + ca_key BLOB NOT NULL, -- CA key + ca_cert BLOB NOT NULL, -- CA certificate for TLS clients + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA, set when connected + id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures + -- stored session key, commited on connection confirmation + dh_priv_key BLOB NOT NULL, -- session DH key + -- prev session key + prev_dh_priv_key BLOB, -- previous session DH key + UNIQUE(ctrl_fingerprint) ON CONFLICT FAIL ); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cb943ac2c5..8bb354c216 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -1,13 +1,14 @@ -{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Remote where @@ -18,161 +19,202 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Control.Monad.Reader -import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J +import qualified Data.Aeson.Types as JT +import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) -import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isNothing) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Data.Text.Encoding (encodeUtf8) import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N +import Network.HTTP2.Client (HTTP2Error (..)) import Network.HTTP2.Server (responseStreaming) -import Network.Socket (SockAddr (..), hostAddressToTuple) +import qualified Paths_simplex_chat as SC import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Messages (chatNameStr) +import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Protocol -import Simplex.Chat.Remote.RevHTTP (announceRevHTTP2, attachHTTP2Server) +import Simplex.Chat.Remote.RevHTTP (attachHTTP2Server, attachRevHTTP2Client) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Files import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared -import Simplex.Chat.Types (User (..)) +import Simplex.Chat.Types import Simplex.Chat.Util (encryptFile) import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Messaging.Agent +import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP)) 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.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (tlsUniq) import Simplex.Messaging.Transport.Client (TransportHost (..)) -import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) -import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>)) -import qualified Simplex.RemoteControl.Discovery as Discovery +import Simplex.Messaging.Util +import Simplex.RemoteControl.Client +import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..)) import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO -import UnliftIO.Concurrent (threadDelay) +import UnliftIO.Concurrent (forkIO) import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) +-- when acting as host +minRemoteCtrlVersion :: AppVersion +minRemoteCtrlVersion = AppVersion [5, 4, 0, 2] + +-- when acting as controller +minRemoteHostVersion :: AppVersion +minRemoteHostVersion = AppVersion [5, 4, 0, 2] + +currentAppVersion :: AppVersion +currentAppVersion = AppVersion SC.version + +ctrlAppVersionRange :: AppVersionRange +ctrlAppVersionRange = mkAppVersionRange minRemoteHostVersion currentAppVersion + +hostAppVersionRange :: AppVersionRange +hostAppVersionRange = mkAppVersionRange minRemoteCtrlVersion currentAppVersion + -- * Desktop side -getRemoteHostSession :: ChatMonad m => RemoteHostId -> m RemoteHostSession -getRemoteHostSession rhId = withRemoteHostSession rhId $ \_ s -> pure $ Right s - -withRemoteHostSession :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> RemoteHostSession -> STM (Either ChatError a)) -> m a -withRemoteHostSession rhId = withRemoteHostSession_ rhId missing +getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient +getRemoteHostClient rhId = withRemoteHostSession rhKey $ \case + s@RHSessionConnected {rhClient} -> Right (rhClient, s) + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState where - missing _ = pure . Left $ ChatErrorRemoteHost rhId RHMissing + rhKey = RHId rhId -withNoRemoteHostSession :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> STM (Either ChatError a)) -> m a -withNoRemoteHostSession rhId action = withRemoteHostSession_ rhId action busy - where - busy _ _ = pure . Left $ ChatErrorRemoteHost rhId RHBusy +withRemoteHostSession :: ChatMonad m => RHKey -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a +withRemoteHostSession rhKey state = withRemoteHostSession_ rhKey $ maybe (Left $ ChatErrorRemoteHost rhKey $ RHEMissing) ((second . second) Just . state) --- | Atomically process controller state wrt. specific remote host session -withRemoteHostSession_ :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> STM (Either ChatError a)) -> (TM.TMap RemoteHostId RemoteHostSession -> RemoteHostSession -> STM (Either ChatError a)) -> m a -withRemoteHostSession_ rhId missing present = do +withRemoteHostSession_ :: ChatMonad m => RHKey -> (Maybe RemoteHostSession -> Either ChatError (a, Maybe RemoteHostSession)) -> m a +withRemoteHostSession_ rhKey state = do sessions <- asks remoteHostSessions - liftIOEither . atomically $ TM.lookup rhId sessions >>= maybe (missing sessions) (present sessions) + r <- atomically $ do + s <- TM.lookup rhKey sessions + case state s of + Left e -> pure $ Left e + Right (a, s') -> Right a <$ maybe (TM.delete rhKey) (TM.insert rhKey) s' sessions + liftEither r -startRemoteHost :: ChatMonad m => RemoteHostId -> m () -startRemoteHost rhId = do - rh <- withStore (`getRemoteHost` rhId) - tasks <- startRemoteHostSession rh - logInfo $ "Remote host session starting for " <> tshow rhId - asyncRegistered tasks $ - run rh tasks `catchAny` \err -> do - logError $ "Remote host session startup failed for " <> tshow rhId <> ": " <> tshow err - cancelTasks tasks - chatModifyVar remoteHostSessions $ M.delete rhId - throwError $ fromMaybe (mkChatError err) $ fromException err +setNewRemoteHostId :: ChatMonad m => RHKey -> RemoteHostId -> m () +setNewRemoteHostId rhKey rhId = do + sessions <- asks remoteHostSessions + r <- atomically $ do + TM.lookupDelete rhKey sessions >>= \case + Nothing -> pure $ Left $ ChatErrorRemoteHost rhKey RHEMissing + Just s -> Right () <$ TM.insert (RHId rhId) s sessions + liftEither r + +startRemoteHost' :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) +startRemoteHost' rh_ = do + (rhKey, multicast, remoteHost_, pairing) <- case rh_ of + Just (rhId, multicast) -> do + rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId + pure (RHId rhId, multicast, Just $ remoteHostInfo rh True, hostPairing) -- get from the database, start multicast if requested + Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing + withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) + ctrlAppInfo <- mkCtrlAppInfo + (invitation, rchClient, vars) <- withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast + rhsWaitSession <- async $ waitForSession rhKey remoteHost_ rchClient vars + let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} + withRemoteHostSession rhKey $ \case + RHSessionStarting -> Right ((), RHSessionConnecting rhs) + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState + pure (remoteHost_, invitation) where - -- logInfo $ "Remote host session starting for " <> tshow rhId - - run :: ChatMonad m => RemoteHost -> Tasks -> m () - run rh@RemoteHost {storePath} tasks = do - (fingerprint, credentials) <- liftIO $ genSessionCredentials rh - cleanupIO <- toIO $ do - logNote $ "Remote host session stopping for " <> tshow rhId - cancelTasks tasks -- cancel our tasks anyway - chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH - withRemoteHostSession rhId $ \sessions _ -> Right <$> TM.delete rhId sessions - toView (CRRemoteHostStopped rhId) -- only signal "stopped" when the session is unregistered cleanly - -- block until some client is connected or an error happens - logInfo $ "Remote host session connecting for " <> tshow rhId - rcName <- chatReadVar localDeviceName - localAddr <- asks multicastSubscribers >>= Discovery.getLocalAddress >>= maybe (throwError . ChatError $ CEInternalError "unable to get local address") pure - (dhKey, sigKey, ann, oob) <- Discovery.startSession (if rcName == "" then Nothing else Just rcName) (localAddr, read Discovery.DISCOVERY_PORT) fingerprint - toView CRRemoteHostStarted {remoteHost = remoteHostInfo rh True, sessionOOB = decodeUtf8 $ strEncode oob} - httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ announceRevHTTP2 tasks (sigKey, ann) credentials cleanupIO - logInfo $ "Remote host session connected for " <> tshow rhId - -- test connection and establish a protocol layer - remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient dhKey rcName - -- set up message polling + mkCtrlAppInfo = do + deviceName <- chatReadVar localDeviceName + pure CtrlAppInfo {appVersionRange = ctrlAppVersionRange, deviceName} + parseHostAppInfo RCHostHello {app = hostAppInfo} rhKey = do + HostAppInfo {deviceName, appVersion} <- + liftEitherWith (ChatErrorRemoteHost rhKey . RHEProtocolError . RPEInvalidJSON) $ JT.parseEither J.parseJSON hostAppInfo + unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ ChatErrorRemoteHost rhKey $ RHEBadVersion appVersion + pure deviceName + waitForSession :: ChatMonad m => RHKey -> Maybe RemoteHostInfo -> RCHostClient -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForSession rhKey remoteHost_ _rchClient_kill_on_error vars = do + -- TODO handle errors + (sessId, vars') <- takeRCStep vars + toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm + (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' + hostDeviceName <- parseHostAppInfo rhHello rhKey + withRemoteHostSession rhKey $ \case + RHSessionConnecting rhs' -> Right ((), RHSessionConfirmed rhs') -- TODO check it's the same session? + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- TODO kill client on error + -- update remoteHost with updated pairing + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName + let rhKey' = RHId remoteHostId + disconnected <- toIO $ onDisconnected remoteHostId + httpClient <- liftEitherError (httpError rhKey) $ attachRevHTTP2Client disconnected tls + rhClient <- liftRC $ createRemoteHostClient httpClient sessionKeys storePath hostDeviceName + pollAction <- async $ pollEvents remoteHostId rhClient + withRemoteHostSession rhKey' $ \case + RHSessionConfirmed RHPendingSession {} -> Right ((), RHSessionConnected {rhClient, pollAction, storePath}) + _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState -- TODO kill client on error + chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host + toView $ CRRemoteHostConnected rhi + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rh_ hostDeviceName = do + KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ + case rh_ of + Nothing -> do + storePath <- liftIO randomStorePath + rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db + setNewRemoteHostId RHNew remoteHostId + pure $ remoteHostInfo rh True + Just rhi@RemoteHostInfo {remoteHostId} -> do + withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' + pure rhi + onDisconnected :: ChatMonad m => RemoteHostId -> m () + onDisconnected remoteHostId = do + logDebug "HTTP2 client disconnected" + chatModifyVar currentRemoteHost $ \cur -> if cur == Just remoteHostId then Nothing else cur -- only wipe the closing RH + sessions <- asks remoteHostSessions + void . atomically $ TM.lookupDelete (RHId remoteHostId) sessions + toView $ CRRemoteHostStopped remoteHostId + pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () + pollEvents rhId rhClient = do oq <- asks outputQ - asyncRegistered tasks . forever $ do - liftRH rhId (remoteRecv remoteHostClient 1000000) >>= mapM_ (atomically . writeTBQueue oq . (Nothing,Just rhId,)) - -- update session state - logInfo $ "Remote host session started for " <> tshow rhId - chatModifyVar remoteHostSessions $ M.adjust (\rhs -> rhs {remoteHostClient = Just remoteHostClient}) rhId - chatWriteVar currentRemoteHost $ Just rhId - toView $ - CRRemoteHostConnected - RemoteHostInfo - { remoteHostId = rhId, - storePath = storePath, - displayName = hostDeviceName remoteHostClient, - sessionActive = True - } + forever $ do + r_ <- liftRH rhId $ remoteRecv rhClient 10000000 + forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) + httpError :: RHKey -> HTTP2ClientError -> ChatError + httpError rhKey = ChatErrorRemoteHost rhKey . RHEProtocolError . RPEHTTP2 . tshow - genSessionCredentials RemoteHost {caKey, caCert} = do - sessionCreds <- genCredentials (Just parent) (0, 24) "Session" - pure . tlsCredentials $ sessionCreds :| [parent] - where - parent = (C.signatureKeyPair caKey, caCert) +closeRemoteHost :: ChatMonad m => RHKey -> m () +closeRemoteHost rhKey = do + logNote $ "Closing remote host session for " <> tshow rhKey + chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH + join . withRemoteHostSession_ rhKey . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ + \s -> Right (liftIO $ cancelRemoteHost s, Nothing) --- | Atomically check/register session and prepare its task list -startRemoteHostSession :: ChatMonad m => RemoteHost -> m Tasks -startRemoteHostSession RemoteHost {remoteHostId, storePath} = withNoRemoteHostSession remoteHostId $ \sessions -> do - remoteHostTasks <- newTVar [] - TM.insert remoteHostId RemoteHostSession {remoteHostTasks, storePath, remoteHostClient = Nothing} sessions - pure $ Right remoteHostTasks - -closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () -closeRemoteHostSession rhId = do - logNote $ "Closing remote host session for " <> tshow rhId - chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH - session <- withRemoteHostSession rhId $ \sessions rhs -> Right rhs <$ TM.delete rhId sessions - cancelRemoteHostSession session - -cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () -cancelRemoteHostSession RemoteHostSession {remoteHostTasks, remoteHostClient} = do - cancelTasks remoteHostTasks - mapM_ closeRemoteHostClient remoteHostClient - -createRemoteHost :: ChatMonad m => m RemoteHostInfo -createRemoteHost = do - ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) "Host" - storePath <- liftIO randomStorePath - let remoteName = "" -- will be passed from remote host in hello - rhId <- withStore' $ \db -> insertRemoteHost db storePath remoteName caKey caCert - rh <- withStore $ \db -> getRemoteHost db rhId - pure $ remoteHostInfo rh False +cancelRemoteHost :: RemoteHostSession -> IO () +cancelRemoteHost = \case + RHSessionStarting -> pure () + RHSessionConnecting rhs -> cancelPendingSession rhs + RHSessionConfirmed rhs -> cancelPendingSession rhs + RHSessionConnected {rhClient = RemoteHostClient {httpClient}, pollAction} -> do + uninterruptibleCancel pollAction + closeHTTP2Client httpClient + where + cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do + cancelHostClient rchClient + uninterruptibleCancel rhsWaitSession -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -184,11 +226,12 @@ listRemoteHosts = do map (rhInfo active) <$> withStore' getRemoteHosts where rhInfo active rh@RemoteHost {remoteHostId} = - remoteHostInfo rh (M.member remoteHostId active) + remoteHostInfo rh (M.member (RHId remoteHostId) active) +-- XXX: replacing hostPairing replaced with sessionActive, could be a ($>) remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, displayName} sessionActive = - RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} +remoteHostInfo RemoteHost {remoteHostId, storePath, hostName} sessionActive = + RemoteHostInfo {remoteHostId, storePath, hostName, sessionActive} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do @@ -202,20 +245,17 @@ deleteRemoteHost rhId = do storeRemoteFile :: forall m. ChatMonad m => RemoteHostId -> Maybe Bool -> FilePath -> m CryptoFile storeRemoteFile rhId encrypted_ localPath = do - RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId - case remoteHostClient of - Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing - Just c@RemoteHostClient {encryptHostFiles} -> do - let encrypt = fromMaybe encryptHostFiles encrypted_ - cf@CryptoFile {filePath} <- if encrypt then encryptLocalFile else pure $ CF.plain localPath - filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath) - hf_ <- chatReadVar remoteHostsFolder - forM_ hf_ $ \hf -> do - let rhf = hf storePath archiveFilesFolder - hPath = rhf takeFileName filePath' - createDirectoryIfMissing True rhf - (if encrypt then renameFile else copyFile) filePath hPath - pure (cf :: CryptoFile) {filePath = filePath'} + c@RemoteHostClient {encryptHostFiles, storePath} <- getRemoteHostClient rhId + let encrypt = fromMaybe encryptHostFiles encrypted_ + cf@CryptoFile {filePath} <- if encrypt then encryptLocalFile else pure $ CF.plain localPath + filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath) + hf_ <- chatReadVar remoteHostsFolder + forM_ hf_ $ \hf -> do + let rhf = hf storePath archiveFilesFolder + hPath = rhf takeFileName filePath' + createDirectoryIfMissing True rhf + (if encrypt then renameFile else copyFile) filePath hPath + pure (cf :: CryptoFile) {filePath = filePath'} where encryptLocalFile :: m CryptoFile encryptLocalFile = do @@ -228,78 +268,69 @@ storeRemoteFile rhId encrypted_ localPath = do getRemoteFile :: ChatMonad m => RemoteHostId -> RemoteFile -> m () getRemoteFile rhId rf = do - RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId - case remoteHostClient of - Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing - Just c -> do - dir <- ( storePath archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar remoteHostsFolder) - createDirectoryIfMissing True dir - liftRH rhId $ remoteGetFile c dir rf + c@RemoteHostClient {storePath} <- getRemoteHostClient rhId + dir <- ( storePath archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar remoteHostsFolder) + createDirectoryIfMissing True dir + liftRH rhId $ remoteGetFile c dir rf -processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ChatCommand -> ByteString -> m ChatResponse -processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} cmd s = case cmd of +processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostClient -> ChatCommand -> ByteString -> m ChatResponse +processRemoteCommand remoteHostId c cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f SendImage chatName f -> sendFile "/img" chatName f - _ -> liftRH remoteHostId $ remoteSend rhc s + _ -> liftRH remoteHostId $ remoteSend c s where sendFile cmdName chatName (CryptoFile path cfArgs) = do -- don't encrypt in host if already encrypted locally CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption - liftRH remoteHostId $ remoteSend rhc $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] cryptoFileStr CryptoFile {filePath, cryptoArgs} = maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs <> encodeUtf8 (T.pack filePath) -processRemoteCommand _ _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started" liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a -liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError) +liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) -- * Mobile side -findKnownRemoteCtrl :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> m () -findKnownRemoteCtrl execChatCommand = do - logInfo "Starting remote host" - checkNoRemoteCtrlSession -- tiny race with the final @chatWriteVar@ until the setup finishes and supervisor spawned - discovered <- newTVarIO mempty - discoverer <- async $ discoverRemoteCtrls discovered -- TODO extract to a controller service singleton - size <- asks $ tbqSize . config - remoteOutputQ <- newTBQueueIO size - confirmed <- newEmptyTMVarIO - verified <- newEmptyTMVarIO - supervisor <- async $ do - threadDelay 500000 -- give chat controller a chance to reply with "ok" to prevent flaking tests - runHost discovered confirmed verified $ handleRemoteCommand execChatCommand remoteOutputQ - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, confirmed, verified, remoteOutputQ} +findKnownRemoteCtrl :: ChatMonad m => m () +findKnownRemoteCtrl = undefined -- do --- | Track remote host lifecycle in controller session state and signal UI on its progress -runHost :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> TMVar RemoteCtrlId -> TMVar (RemoteCtrlId, Text) -> (HTTP2Request -> m ()) -> m () -runHost discovered confirmed verified handleHttp = do - remoteCtrlId <- atomically (readTMVar confirmed) -- wait for discoverRemoteCtrls.process or confirmRemoteCtrl to confirm fingerprint as a known RC - rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) - serviceAddress <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint - toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False - atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ - -- spawn server for remote protocol commands - Discovery.connectTLSClient serviceAddress fingerprint $ \tls -> do - let sessionCode = decodeUtf8 . strEncode $ tlsUniq tls - toView $ CRRemoteCtrlSessionCode {remoteCtrl = remoteCtrlInfo rc True, sessionCode, newCtrl = False} - userInfo <- atomically $ readTMVar verified - if userInfo == (remoteCtrlId, sessionCode) - then do - toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True - attachHTTP2Server handleHttp tls - else do - toView $ CRChatCmdError Nothing $ ChatErrorRemoteCtrl RCEBadVerificationCode - -- the server doesn't enter its loop and waitCatch below falls through - chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - _ <- waitCatch server -- wait for the server to finish - chatWriteVar remoteCtrlSession Nothing - toView CRRemoteCtrlStopped +-- | Use provided OOB link as an annouce +connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m () +connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = do + (ctrlDeviceName, v) <- parseCtrlAppInfo app + withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) + rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca + hostAppInfo <- getHostAppInfo v + (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a inv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + rcsWaitSession <- async $ waitForSession rc_ ctrlDeviceName rcsClient vars + updateRemoteCtrlSession $ \case + RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState -- TODO kill rcsClient + where + waitForSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () + waitForSession rc_ ctrlName rcsClient vars = do + (uniq, rcsWaitConfirmation) <- takeRCStep vars + let sessionCode = verificationCode uniq + toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} + updateRemoteCtrlSession $ \case + RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlName, rcsClient, sessionCode, rcsWaitSession, rcsWaitConfirmation} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState -- TODO kill rcsClient + parseCtrlAppInfo ctrlAppInfo = do + CtrlAppInfo {deviceName, appVersionRange} <- + liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo + v <- case compatibleAppVersion hostAppVersionRange appVersionRange of + Just (AppCompatible v) -> pure v + Nothing -> throwError $ ChatErrorRemoteCtrl $ RCEBadVersion $ maxVersion appVersionRange + pure (deviceName, v) + getHostAppInfo appVersion = do + hostDeviceName <- chatReadVar localDeviceName + encryptFiles <- chatReadVar encryptLocalFiles + pure HostAppInfo {appVersion, deviceName = hostDeviceName, encoding = localEncoding, encryptFiles} -handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> TBQueue ChatResponse -> HTTP2Request -> m () -handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do +handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> CtrlSessKeys -> TBQueue ChatResponse -> HTTP2Request -> m () +handleRemoteCommand execChatCommand _sessionKeys remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftRC (tryRemoteError parseRequest) >>= \case Right (getNext, rc) -> do @@ -311,7 +342,7 @@ handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand) parseRequest = do (header, getNext) <- parseHTTP2Body request reqBody - (getNext,) <$> liftEitherWith (RPEInvalidJSON . T.pack) (J.eitherDecodeStrict' header) + (getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecodeStrict' header) replyError = reply . RRChatResponse . CRChatCmdError Nothing processCommand :: User -> GetChunk -> RemoteCommand -> m () processCommand user getNext = \case @@ -329,6 +360,9 @@ handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody attach send flush +takeRCStep :: ChatMonad m => RCStepTMVar a -> m a +takeRCStep = liftEitherError (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar + type GetChunk = Int -> IO ByteString type SendChunk = Builder -> IO () @@ -393,83 +427,79 @@ handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fi discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> m () discoverRemoteCtrls discovered = do - subscribers <- asks multicastSubscribers - Discovery.withListener subscribers run - where - run sock = receive sock >>= process sock - - receive sock = - Discovery.recvAnnounce sock >>= \case - (SockAddrInet _sockPort sockAddr, sigAnnBytes) -> case smpDecode sigAnnBytes of - Right (SignedAnnounce ann _sig) -> pure (sockAddr, ann) - Left _ -> receive sock -- TODO it is probably better to report errors to view here - _nonV4 -> receive sock - - process sock (sockAddr, Announce {caFingerprint, serviceAddress = (annAddr, port)}) = do - unless (annAddr == sockAddr) $ logError "Announced address doesn't match socket address" - let addr = THIPv4 (hostAddressToTuple sockAddr) - ifM - (atomically $ TM.member caFingerprint discovered) - (logDebug $ "Fingerprint already known: " <> tshow (addr, caFingerprint)) - ( do - logInfo $ "New fingerprint announced: " <> tshow (addr, caFingerprint) - atomically $ TM.insert caFingerprint (addr, port) discovered - ) - -- TODO we check fingerprint for duplicate where id doesn't matter - to prevent re-insert - and don't check to prevent duplicate events, - -- so UI now will have to check for duplicates again - withStore' (`getRemoteCtrlByFingerprint` caFingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce caFingerprint -- unknown controller, ui "register" action required - -- TODO Maybe Bool is very confusing - the intent is very unclear here - Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of - Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required - Just False -> run sock -- restart, skipping a rejected item - Just True -> - chatReadVar remoteCtrlSession >>= \case - Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" - Just RemoteCtrlSession {confirmed} -> atomically $ void $ tryPutTMVar confirmed remoteCtrlId -- previously accepted controller, connect automatically + error "TODO: discoverRemoteCtrls" listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do - active <- - chatReadVar remoteCtrlSession $>>= \RemoteCtrlSession {confirmed} -> - atomically $ tryReadTMVar confirmed + active <- chatReadVar remoteCtrlSession >>= \case + Just RCSessionConnected {remoteCtrlId} -> pure $ Just remoteCtrlId + _ -> pure Nothing map (rcInfo active) <$> withStore' getRemoteCtrls where rcInfo activeRcId rc@RemoteCtrl {remoteCtrlId} = remoteCtrlInfo rc $ activeRcId == Just remoteCtrlId remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo -remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive = - RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive} +remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlName} sessionActive = + RemoteCtrlInfo {remoteCtrlId, ctrlName, sessionActive} +-- XXX: only used for multicast confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () -confirmRemoteCtrl rcId = do +confirmRemoteCtrl _rcId = do -- TODO check it exists, check the ID is the same as in session - RemoteCtrlSession {confirmed} <- getRemoteCtrlSession - withStore' $ \db -> markRemoteCtrlResolution db rcId True - atomically . void $ tryPutTMVar confirmed rcId -- the remote host can now proceed with connection + -- RemoteCtrlSession {confirmed} <- getRemoteCtrlSession + -- withStore' $ \db -> markRemoteCtrlResolution db rcId True + -- atomically . void $ tryPutTMVar confirmed rcId -- the remote host can now proceed with connection + undefined -verifyRemoteCtrlSession :: ChatMonad m => RemoteCtrlId -> Text -> m () -verifyRemoteCtrlSession rcId sessId = do - RemoteCtrlSession {verified} <- getRemoteCtrlSession - void . atomically $ tryPutTMVar verified (rcId, sessId) +-- | Take a look at emoji of tlsunique, commit pairing, and start session server +verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo +verifyRemoteCtrlSession execChatCommand sessCode' = do + (client, ctrlName, sessionCode, vars) <- + getRemoteCtrlSession >>= \case + RCSessionPendingConfirmation {rcsClient, ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) + _ -> throwError $ ChatErrorRemoteCtrl RCEBadState + let verified = sameVerificationCode sessCode' sessionCode + liftIO $ confirmCtrlSession client verified + unless verified $ throwError $ ChatErrorRemoteCtrl RCEBadVerificationCode + (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- takeRCStep vars + rc@RemoteCtrl {remoteCtrlId} <- withStore $ \db -> do + rc_ <- liftIO $ getRemoteCtrlByFingerprint db (ctrlFingerprint rcCtrlPairing) + case rc_ of + Nothing -> insertRemoteCtrl db ctrlName rcCtrlPairing >>= getRemoteCtrl db + Just rc@RemoteCtrl {remoteCtrlId} -> do + liftIO $ updateCtrlPairingKeys db remoteCtrlId (dhPrivKey rcCtrlPairing) + pure rc + remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO + http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand sessionKeys remoteOutputQ + withRemoteCtrlSession $ \case + RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, http2Server, remoteOutputQ}) + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + void . forkIO $ do + waitCatch http2Server >>= \case + Left err | Just (BadThingHappen innerErr) <- fromException err -> logWarn $ "HTTP2 server crashed with internal " <> tshow innerErr + Left err | isNothing (fromException @AsyncCancelled err) -> logError $ "HTTP2 server crashed with " <> tshow err + _ -> logInfo "HTTP2 server stopped" + toView CRRemoteCtrlStopped + pure $ remoteCtrlInfo rc True stopRemoteCtrl :: ChatMonad m => m () -stopRemoteCtrl = do - rcs <- getRemoteCtrlSession - cancelRemoteCtrlSession rcs $ chatWriteVar remoteCtrlSession Nothing +stopRemoteCtrl = + join . withRemoteCtrlSession_ . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ + \s -> Right (liftIO $ cancelRemoteCtrl s, Nothing) -cancelRemoteCtrlSession_ :: MonadUnliftIO m => RemoteCtrlSession -> m () -cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () - -cancelRemoteCtrlSession :: MonadUnliftIO m => RemoteCtrlSession -> m () -> m () -cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do - cancel discoverer -- may be gone by now - case hostServer of - Just host -> cancel host -- supervisor will clean up - Nothing -> do - cancel supervisor -- supervisor is blocked until session progresses - cleanup +cancelRemoteCtrl :: RemoteCtrlSession -> IO () +cancelRemoteCtrl = \case + RCSessionStarting -> pure () + RCSessionConnecting {rcsClient, rcsWaitSession} -> do + cancelCtrlClient rcsClient + uninterruptibleCancel rcsWaitSession + RCSessionPendingConfirmation {rcsClient, rcsWaitSession} -> do + cancelCtrlClient rcsClient + uninterruptibleCancel rcsWaitSession + RCSessionConnected {rcsClient, http2Server} -> do + cancelCtrlClient rcsClient + uninterruptibleCancel http2Server deleteRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () deleteRemoteCtrl rcId = do @@ -485,6 +515,23 @@ checkNoRemoteCtrlSession :: ChatMonad m => m () checkNoRemoteCtrlSession = chatReadVar remoteCtrlSession >>= maybe (pure ()) (\_ -> throwError $ ChatErrorRemoteCtrl RCEBusy) +withRemoteCtrlSession :: ChatMonad m => (RemoteCtrlSession -> Either ChatError (a, RemoteCtrlSession)) -> m a +withRemoteCtrlSession state = withRemoteCtrlSession_ $ maybe (Left $ ChatErrorRemoteCtrl RCEInactive) ((second . second) Just . state) + +-- | Atomically process controller state wrt. specific remote ctrl session +withRemoteCtrlSession_ :: ChatMonad m => (Maybe RemoteCtrlSession -> Either ChatError (a, Maybe RemoteCtrlSession)) -> m a +withRemoteCtrlSession_ state = do + session <- asks remoteCtrlSession + r <- + atomically $ stateTVar session $ \s -> + case state s of + Left e -> (Left e, s) + Right (a, s') -> (Right a, s') + liftEither r + +updateRemoteCtrlSession :: ChatMonad m => (RemoteCtrlSession -> Either ChatError RemoteCtrlSession) -> m () +updateRemoteCtrlSession state = withRemoteCtrlSession $ fmap ((),) . state + utf8String :: [Char] -> ByteString utf8String = encodeUtf8 . T.pack {-# INLINE utf8String #-} diff --git a/src/Simplex/Chat/Remote/AppVersion.hs b/src/Simplex/Chat/Remote/AppVersion.hs new file mode 100644 index 0000000000..a8943968d5 --- /dev/null +++ b/src/Simplex/Chat/Remote/AppVersion.hs @@ -0,0 +1,69 @@ +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Remote.AppVersion + ( AppVersionRange (minVersion, maxVersion), + AppVersion (..), + pattern AppCompatible, + mkAppVersionRange, + compatibleAppVersion, + isAppCompatible, + ) + where + +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 +import qualified Data.Text as T +import Data.Version (parseVersion, showVersion) +import qualified Data.Version as V +import Simplex.Messaging.Parsers (defaultJSON) +import Text.ParserCombinators.ReadP (readP_to_S) + +newtype AppVersion = AppVersion V.Version + deriving (Eq, Ord, Show) + +instance ToJSON AppVersion where + toJSON (AppVersion v) = J.String . T.pack $ showVersion v + toEncoding (AppVersion v) = JE.text . T.pack $ showVersion v + +instance FromJSON AppVersion where + parseJSON = J.withText "AppVersion" $ parse . T.unpack + where + parse s = case filter (null . snd) $ readP_to_S parseVersion s of + (v, _) : _ -> pure $ AppVersion v + _ -> fail $ "bad AppVersion: " <> s + +data AppVersionRange = AppVRange + { minVersion :: AppVersion, + maxVersion :: AppVersion + } + +mkAppVersionRange :: AppVersion -> AppVersion -> AppVersionRange +mkAppVersionRange v1 v2 + | v1 <= v2 = AppVRange v1 v2 + | otherwise = error "invalid version range" + +newtype AppCompatible a = AppCompatible_ a + +pattern AppCompatible :: a -> AppCompatible a +pattern AppCompatible a <- AppCompatible_ a + +{-# COMPLETE AppCompatible #-} + +isAppCompatible :: AppVersion -> AppVersionRange -> Bool +isAppCompatible v (AppVRange v1 v2) = v1 <= v && v <= v2 + +isCompatibleAppRange :: AppVersionRange -> AppVersionRange -> Bool +isCompatibleAppRange (AppVRange min1 max1) (AppVRange min2 max2) = min1 <= max2 && min2 <= max1 + +compatibleAppVersion :: AppVersionRange -> AppVersionRange -> Maybe (AppCompatible AppVersion) +compatibleAppVersion vr1 vr2 = + min (maxVersion vr1) (maxVersion vr2) `mkCompatibleIf` isCompatibleAppRange vr1 vr2 + +mkCompatibleIf :: AppVersion -> Bool -> Maybe (AppCompatible AppVersion) +v `mkCompatibleIf` cond = if cond then Just $ AppCompatible_ v else Nothing + +$(JQ.deriveJSON defaultJSON ''AppVersionRange) diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 45bea60663..2868a45107 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -19,7 +19,7 @@ import qualified Data.Aeson.KeyMap as JM import Data.Aeson.TH (deriveJSON) import qualified Data.Aeson.Types as JT import Data.ByteString (ByteString) -import Data.ByteString.Builder (Builder, word32BE, lazyByteString) +import Data.ByteString.Builder (Builder, lazyByteString, word32BE) import qualified Data.ByteString.Lazy as LB import Data.String (fromString) import Data.Text (Text) @@ -39,7 +39,8 @@ import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBod import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow) -import System.FilePath ((), takeFileName) +import Simplex.RemoteControl.Types (HostSessKeys) +import System.FilePath (takeFileName, ()) import UnliftIO data RemoteCommand @@ -66,14 +67,21 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) -- * Client side / desktop -createRemoteHostClient :: HTTP2Client -> dh -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient -createRemoteHostClient httpClient todo'dhKey desktopName = do +createRemoteHostClient :: HTTP2Client -> HostSessKeys -> FilePath -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient +createRemoteHostClient httpClient sessionKeys storePath desktopName = do logDebug "Sending initial hello" sendRemoteCommand' httpClient localEncoding Nothing RCHello {deviceName = desktopName} >>= \case RRHello {encoding, deviceName = mobileName, encryptFiles} -> do logDebug "Got initial hello" when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding - pure RemoteHostClient {hostEncoding = encoding, hostDeviceName = mobileName, httpClient, encryptHostFiles = encryptFiles} + pure RemoteHostClient + { hostEncoding = encoding, + hostDeviceName = mobileName, + httpClient, + encryptHostFiles = encryptFiles, + sessionKeys, + storePath + } r -> badResponse r closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs index 08c844dcf0..a37d77e20a 100644 --- a/src/Simplex/Chat/Remote/RevHTTP.hs +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -8,37 +8,20 @@ module Simplex.Chat.Remote.RevHTTP where -import Simplex.RemoteControl.Discovery -import Simplex.RemoteControl.Types -import Control.Logger.Simple -import qualified Network.TLS as TLS -import qualified Simplex.Messaging.Crypto as C -import qualified Simplex.Messaging.Transport as Transport +import Simplex.Messaging.Transport (TLS) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) -import Simplex.Messaging.Util (ifM) +import Simplex.RemoteControl.Discovery import UnliftIO -announceRevHTTP2 :: MonadUnliftIO m => Tasks -> (C.PrivateKeyEd25519, Announce) -> TLS.Credentials -> m () -> m (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 = announceCtrl runHTTP2Client - --- | Attach HTTP2 client and hold the TLS until the attached client finishes. -runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () -runHTTP2Client finishedVar clientVar tls = - ifM (isEmptyMVar clientVar) - attachClient - (logError "HTTP2 session already started on this listener") +attachRevHTTP2Client :: IO () -> TLS -> IO (Either HTTP2ClientError HTTP2Client) +attachRevHTTP2Client disconnected = attachHTTP2Client config ANY_ADDR_V4 "0" disconnected defaultHTTP2BufferSize where - attachClient = do - client <- attachHTTP2Client config ANY_ADDR_V4 DISCOVERY_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls - putMVar clientVar client - readMVar finishedVar - -- TODO connection timeout config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} -attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () -attachHTTP2Server processRequest tls = do +attachHTTP2Server :: MonadUnliftIO m => TLS -> (HTTP2Request -> m ()) -> m () +attachHTTP2Server tls processRequest = do withRunInIO $ \unlift -> runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do reqBody <- getHTTP2Body r doNotPrefetchHead diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index dcf70ab714..d9aa9d50bf 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -9,34 +9,45 @@ module Simplex.Chat.Remote.Types where +import Control.Concurrent.Async (Async) import Control.Exception (Exception) import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) -import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Remote.AppVersion import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) -import Simplex.RemoteControl.Types (Tasks) +import Simplex.RemoteControl.Client +import Simplex.RemoteControl.Types import Simplex.Messaging.Crypto.File (CryptoFile) data RemoteHostClient = RemoteHostClient { hostEncoding :: PlatformEncoding, hostDeviceName :: Text, httpClient :: HTTP2Client, - encryptHostFiles :: Bool - } - -data RemoteHostSession = RemoteHostSession - { remoteHostTasks :: Tasks, - remoteHostClient :: Maybe RemoteHostClient, + sessionKeys :: HostSessKeys, + encryptHostFiles :: Bool, storePath :: FilePath } +data RHPendingSession = RHPendingSession + { rhKey :: RHKey, + rchClient :: RCHostClient, + rhsWaitSession :: Async (), + remoteHost_ :: Maybe RemoteHostInfo + } + +data RemoteHostSession + = RHSessionStarting + | RHSessionConnecting {rhPendingSession :: RHPendingSession} + | RHSessionConfirmed {rhPendingSession :: RHPendingSession} + | RHSessionConnected {rhClient :: RemoteHostClient, pollAction :: Async (), storePath :: FilePath} + data RemoteProtocolError = -- | size prefix is malformed RPEInvalidSize | -- | failed to parse RemoteCommand or RemoteResponse - RPEInvalidJSON {invalidJSON :: Text} + RPEInvalidJSON {invalidJSON :: String} | RPEIncompatibleEncoding | RPEUnexpectedFile | RPENoFile @@ -52,47 +63,39 @@ data RemoteProtocolError type RemoteHostId = Int64 +data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} + deriving (Eq, Ord, Show) + +-- | Storable/internal remote host data data RemoteHost = RemoteHost { remoteHostId :: RemoteHostId, + hostName :: Text, storePath :: FilePath, - displayName :: Text, - -- | Credentials signing key for root and session certs - caKey :: C.APrivateSignKey, - -- | A stable part of TLS credentials used in remote session - caCert :: C.SignedCertificate, - contacted :: Bool + hostPairing :: RCHostPairing } - deriving (Show) - -data RemoteCtrlOOB = RemoteCtrlOOB - { fingerprint :: C.KeyHash, - displayName :: Text - } - deriving (Show) +-- | UI-accessible remote host information data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, + hostName :: Text, storePath :: FilePath, - displayName :: Text, sessionActive :: Bool } deriving (Show) type RemoteCtrlId = Int64 +-- | Storable/internal remote controller data data RemoteCtrl = RemoteCtrl { remoteCtrlId :: RemoteCtrlId, - displayName :: Text, - fingerprint :: C.KeyHash, - accepted :: Maybe Bool + ctrlName :: Text, + ctrlPairing :: RCCtrlPairing } - deriving (Show) +-- | UI-accessible remote controller information data RemoteCtrlInfo = RemoteCtrlInfo { remoteCtrlId :: RemoteCtrlId, - displayName :: Text, - fingerprint :: C.KeyHash, - accepted :: Maybe Bool, + ctrlName :: Text, sessionActive :: Bool } deriving (Show) @@ -117,14 +120,30 @@ data RemoteFile = RemoteFile } deriving (Show) +data CtrlAppInfo = CtrlAppInfo + { appVersionRange :: AppVersionRange, + deviceName :: Text + } + +data HostAppInfo = HostAppInfo + { appVersion :: AppVersion, + deviceName :: Text, + encoding :: PlatformEncoding, + encryptFiles :: Bool -- if the host encrypts files in app storage + } + $(J.deriveJSON defaultJSON ''RemoteFile) $(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RHKey) + $(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) $(J.deriveJSON defaultJSON ''RemoteHostInfo) -$(J.deriveJSON defaultJSON ''RemoteCtrl) - $(J.deriveJSON defaultJSON ''RemoteCtrlInfo) + +$(J.deriveJSON defaultJSON ''CtrlAppInfo) + +$(J.deriveJSON defaultJSON ''HostAppInfo) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index df7ccd499b..e12b581252 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -1,26 +1,39 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} module Simplex.Chat.Store.Remote where import Control.Monad.Except import Data.Int (Int64) -import Data.Maybe (fromMaybe) import Data.Text (Text) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL +import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Shared 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.RemoteControl.Types +import UnliftIO -insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId -insertRemoteHost db storePath displayName caKey caCert = do - DB.execute db "INSERT INTO remote_hosts (store_path, display_name, ca_key, ca_cert) VALUES (?,?,?,?)" (storePath, displayName, caKey, C.SignedObject caCert) - insertedRowId db +insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId +insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do + KnownHostPairing {hostFingerprint, hostDhPubKey} <- + maybe (throwError SERemoteHostUnknown) pure kh_ + checkConstraint SERemoteHostDuplicateCA . liftIO $ + DB.execute + db + [sql| + INSERT INTO remote_hosts + (host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) + VALUES + (?, ?, ?, ?, ?, ?, ?) + |] + (hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) + liftIO $ insertedRowId db getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = @@ -31,22 +44,52 @@ getRemoteHost db remoteHostId = ExceptT . firstRow toRemoteHost (SERemoteHostNotFound remoteHostId) $ DB.query db (remoteHostQuery <> " WHERE remote_host_id = ?") (Only remoteHostId) -remoteHostQuery :: SQL.Query -remoteHostQuery = "SELECT remote_host_id, store_path, display_name, ca_key, ca_cert, contacted FROM remote_hosts" +getRemoteHostByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteHost) +getRemoteHostByFingerprint db fingerprint = + maybeFirstRow toRemoteHost $ + DB.query db (remoteHostQuery <> " WHERE host_fingerprint = ?") (Only fingerprint) -toRemoteHost :: (Int64, FilePath, Text, C.APrivateSignKey, C.SignedObject C.Certificate, Bool) -> RemoteHost -toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert, contacted) = - RemoteHost {remoteHostId, storePath, displayName, caKey, caCert, contacted} +remoteHostQuery :: SQL.Query +remoteHostQuery = + [sql| + SELECT remote_host_id, host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub + FROM remote_hosts + |] + +toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519) -> RemoteHost +toRemoteHost (remoteHostId, hostName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) = + RemoteHost {remoteHostId, hostName, storePath, hostPairing} + where + hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost} + knownHost = KnownHostPairing {hostFingerprint, hostDhPubKey} + +updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> IO () +updateHostPairing db rhId hostName hostDhPubKey = + DB.execute + db + [sql| + UPDATE remote_hosts + SET host_device_name = ?, host_dh_pub = ? + WHERE remote_host_id = ? + |] + (hostName, hostDhPubKey, rhId) deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) -insertRemoteCtrl :: DB.Connection -> SignedOOB -> IO RemoteCtrlInfo -insertRemoteCtrl db (SignedOOB OOB {deviceName, caFingerprint = fingerprint} _) = do - let displayName = fromMaybe "" deviceName - DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint) - remoteCtrlId <- insertedRowId db - pure RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted = Nothing, sessionActive = False} +insertRemoteCtrl :: DB.Connection -> Text -> RCCtrlPairing -> ExceptT StoreError IO RemoteCtrlId +insertRemoteCtrl db ctrlDeviceName RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} = do + checkConstraint SERemoteCtrlDuplicateCA . liftIO $ + DB.execute + db + [sql| + INSERT INTO remote_controllers + (ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key) + VALUES + (?, ?, ?, ?, ?, ?, ?) + |] + (ctrlDeviceName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) + liftIO $ insertedRowId db getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] getRemoteCtrls db = @@ -55,24 +98,49 @@ getRemoteCtrls db = getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> ExceptT StoreError IO RemoteCtrl getRemoteCtrl db remoteCtrlId = ExceptT . firstRow toRemoteCtrl (SERemoteCtrlNotFound remoteCtrlId) $ - DB.query db (remoteCtrlQuery <> " WHERE remote_controller_id = ?") (Only remoteCtrlId) + DB.query db (remoteCtrlQuery <> " WHERE remote_ctrl_id = ?") (Only remoteCtrlId) getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) getRemoteCtrlByFingerprint db fingerprint = maybeFirstRow toRemoteCtrl $ - DB.query db (remoteCtrlQuery <> " WHERE fingerprint = ?") (Only fingerprint) + DB.query db (remoteCtrlQuery <> " WHERE ctrl_fingerprint = ?") (Only fingerprint) remoteCtrlQuery :: SQL.Query -remoteCtrlQuery = "SELECT remote_controller_id, display_name, fingerprint, accepted FROM remote_controllers" +remoteCtrlQuery = + [sql| + SELECT remote_ctrl_id, ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key + FROM remote_controllers + |] -toRemoteCtrl :: (Int64, Text, C.KeyHash, Maybe Bool) -> RemoteCtrl -toRemoteCtrl (remoteCtrlId, displayName, fingerprint, accepted) = - RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} +toRemoteCtrl :: + ( RemoteCtrlId, + Text, + C.APrivateSignKey, + C.SignedObject C.Certificate, + C.KeyHash, + C.PublicKeyEd25519, + C.PrivateKeyX25519, + Maybe C.PrivateKeyX25519 + ) -> + RemoteCtrl +toRemoteCtrl (remoteCtrlId, ctrlName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) = + RemoteCtrl + { remoteCtrlId, + ctrlName, + ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} + } -markRemoteCtrlResolution :: DB.Connection -> RemoteCtrlId -> Bool -> IO () -markRemoteCtrlResolution db remoteCtrlId accepted = - DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ? AND accepted IS NULL" (accepted, remoteCtrlId) +updateCtrlPairingKeys :: DB.Connection -> RemoteCtrlId -> C.PrivateKeyX25519 -> IO () +updateCtrlPairingKeys db rcId dhPrivKey = + DB.execute + db + [sql| + UPDATE remote_controllers + SET dh_priv_key = ?, prev_dh_priv_key = dh_priv_key + WHERE remote_ctrl_id = ? + |] + (dhPrivKey, rcId) deleteRemoteCtrlRecord :: DB.Connection -> RemoteCtrlId -> IO () deleteRemoteCtrlRecord db remoteCtrlId = - DB.execute db "DELETE FROM remote_controllers WHERE remote_controller_id = ?" (Only remoteCtrlId) + DB.execute db "DELETE FROM remote_controllers WHERE remote_ctrl_id = ?" (Only remoteCtrlId) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e72d68b8df..ab7330454e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -100,7 +100,10 @@ data StoreError | SEContactNotFoundByFileId {fileId :: FileTransferId} | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} | SERemoteHostNotFound {remoteHostId :: RemoteHostId} + | SERemoteHostUnknown -- ^ attempting to store KnownHost without a known fingerprint + | SERemoteHostDuplicateCA | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} + | SERemoteCtrlDuplicateCA deriving (Show, Exception) $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 572403e595..ca47cb15e5 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -277,9 +277,16 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfMessages {} -> [] CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionOOB} -> ["remote host " <> sShow rhId <> " started", "connection code:", plain sessionOOB] - CRRemoteHostSessionCode {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionCode} -> - ["remote host " <> sShow rhId <> " is connecting", "Compare session code with host:", plain sessionCode] + CRRemoteHostStarted {remoteHost_, invitation} -> + [ maybe "new remote host started" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " started") remoteHost_, + "Remote session invitation:", + plain invitation + ] + CRRemoteHostSessionCode {remoteHost_, sessionCode} -> + [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, + "Compare session code with host:", + plain sessionCode + ] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> @@ -292,12 +299,15 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> - ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] - CRRemoteCtrlSessionCode {remoteCtrl = RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName}, sessionCode} -> - ["remote controller " <> sShow rcId <> " connected to " <> plain rcName, "Compare session code with controller and use:", "/verify remote ctrl " <> sShow rcId <> " " <> plain sessionCode] - CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> - ["remote controller " <> sShow rcId <> " session started with " <> plain rcName] + CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, ctrlName} -> + ["remote controller " <> sShow rcId <> " connecting to " <> plain ctrlName] + CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> + [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, + "Compare session code with controller and use:", + "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId + ] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlName} -> + ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlName] CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> @@ -1681,21 +1691,21 @@ viewRemoteHosts = \case [] -> ["No remote hosts"] hs -> "Remote hosts: " : map viewRemoteHostInfo hs where - viewRemoteHostInfo RemoteHostInfo {remoteHostId, displayName, sessionActive} = - plain $ tshow remoteHostId <> ". " <> displayName <> if sessionActive then " (active)" else "" + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostName, sessionActive} = + plain $ tshow remoteHostId <> ". " <> hostName <> if sessionActive then " (active)" else "" viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] viewRemoteCtrls = \case [] -> ["No remote controllers"] hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs where - viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} = - plain $ tshow remoteCtrlId <> ". " <> displayName <> if sessionActive then " (active)" else "" + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlName, sessionActive} = + plain $ tshow remoteCtrlId <> ". " <> ctrlName <> if sessionActive then " (active)" else "" -- TODO fingerprint, accepted? viewRemoteCtrl :: RemoteCtrlInfo -> StyledString -viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, displayName} = - plain $ tshow remoteCtrlId <> ". " <> displayName +viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlName} = + plain $ tshow remoteCtrlId <> ". " <> ctrlName viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case @@ -1843,7 +1853,8 @@ viewChatError logLevel = \case cId :: Connection -> StyledString cId conn = sShow conn.connId ChatErrorRemoteCtrl e -> [plain $ "remote controller error: " <> show e] - ChatErrorRemoteHost rhId e -> [plain $ "remote host " <> show rhId <> " error: " <> show e] + ChatErrorRemoteHost RHNew e -> [plain $ "new remote host error: " <> show e] + ChatErrorRemoteHost (RHId rhId) e -> [plain $ "remote host " <> show rhId <> " error: " <> show e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] sqliteError' = \case diff --git a/stack.yaml b/stack.yaml index 9043a56952..4c26936c02 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 1a0c4b73de5cda4ac6765dd47e0199238e498d5f + commit: 102487bc4fbb865aac4207d2ba6f2ea77eff3290 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ea455a0fc1..f9e24727cc 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -91,7 +91,7 @@ termSettings :: VirtualTerminalSettings termSettings = VirtualTerminalSettings { virtualType = "xterm", - virtualWindowSize = pure C.Size {height = 24, width = 1000}, + virtualWindowSize = pure C.Size {height = 24, width = 2250}, virtualEvent = retry, virtualInterrupt = retry } diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index b2e7aa5cb1..83f7eeee5a 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -1,13 +1,11 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} module RemoteTests where -import Simplex.Chat.Remote.RevHTTP -import qualified Simplex.RemoteControl.Discovery as Discovery -import Simplex.RemoteControl.Types import ChatClient import ChatTests.Utils import Control.Logger.Simple @@ -16,11 +14,6 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Data.String (fromString) -import Network.HTTP.Types (ok200) -import qualified Network.HTTP2.Client as C -import qualified Network.HTTP2.Server as S -import qualified Network.Socket as N import qualified Network.TLS as TLS import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) @@ -29,12 +22,8 @@ import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) -import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) -import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util import System.FilePath (()) import Test.Hspec @@ -44,113 +33,35 @@ import UnliftIO.Directory remoteTests :: SpecWith FilePath remoteTests = describe "Remote" $ do - -- it "generates usable credentials" genCredentialsTest - -- it "OOB encoding, decoding, and signatures are correct" oobCodecTest - it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - it "performs protocol handshake" remoteHandshakeTest - it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check + describe "protocol handshake" $ do + it "connects with new pairing" remoteHandshakeTest + it "connects with new pairing (again)" remoteHandshakeTest -- leaking servers regression check + it "connects with stored pairing" remoteHandshakeStoredTest it "sends messages" remoteMessageTest describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest - it "should sends files from CLI wihtout /store" remoteCLIFileTest - --- * Low-level TLS with ephemeral credentials - --- -- XXX: extract --- genCredentialsTest :: (HasCallStack) => FilePath -> IO () --- genCredentialsTest _tmp = do --- (fingerprint, credentials) <- genTestCredentials --- started <- newEmptyTMVarIO --- bracket (startTLSServer started credentials serverHandler) cancel $ \_server -> do --- ok <- atomically (readTMVar started) --- port <- maybe (error "TLS server failed to start") pure ok --- logNote $ "Assigned port: " <> tshow port --- connectTLSClient ("127.0.0.1", fromIntegral port) fingerprint clientHandler --- where --- serverHandler serverTls = do --- logNote "Sending from server" --- Transport.putLn serverTls "hi client" --- logNote "Reading from server" --- Transport.getLn serverTls `shouldReturn` "hi server" --- clientHandler clientTls = do --- logNote "Sending from client" --- Transport.putLn clientTls "hi server" --- logNote "Reading from client" --- Transport.getLn clientTls `shouldReturn` "hi client" - --- * UDP discovery and rever HTTP2 - --- oobCodecTest :: (HasCallStack) => FilePath -> IO () --- oobCodecTest _tmp = do --- subscribers <- newTMVarIO 0 --- localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure --- (fingerprint, _credentials) <- genTestCredentials --- (_dhKey, _sigKey, _ann, signedOOB@(SignedOOB oob _sig)) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint --- verifySignedOOB signedOOB `shouldBe` True --- strDecode (strEncode oob) `shouldBe` Right oob --- strDecode (strEncode signedOOB) `shouldBe` Right signedOOB - -announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () -announceDiscoverHttp2Test _tmp = do - subscribers <- newTMVarIO 0 - localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure - (fingerprint, credentials) <- genTestCredentials - (_dhKey, sigKey, ann, _oob) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint - tasks <- newTVarIO [] - finished <- newEmptyMVar - controller <- async $ do - logNote "Controller: starting" - bracket - (announceRevHTTP2 tasks (sigKey, ann) credentials (putMVar finished ()) >>= either (fail . show) pure) - closeHTTP2Client - ( \http -> do - logNote "Controller: got client" - sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case - Left err -> do - logNote "Controller: got error" - fail $ show err - Right HTTP2Response {} -> - logNote "Controller: got response" - ) - host <- async $ Discovery.withListener subscribers $ \sock -> do - (N.SockAddrInet _port addr, sigAnn) <- Discovery.recvAnnounce sock - SignedAnnounce Announce {caFingerprint, serviceAddress=(hostAddr, port)} _sig <- either fail pure $ smpDecode sigAnn - caFingerprint `shouldBe` fingerprint - addr `shouldBe` hostAddr - let service = (THIPv4 $ N.hostAddressToTuple hostAddr, port) - logNote $ "Host: connecting to " <> tshow service - server <- async $ Discovery.connectTLSClient service fingerprint $ \tls -> do - logNote "Host: got tls" - flip attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do - logNote "Host: got request" - sendResponse $ S.responseNoBody ok200 [] - logNote "Host: sent response" - takeMVar finished `finally` cancel server - logNote "Host: finished" - tasks `registerAsync` controller - tasks `registerAsync` host - (waitBoth host controller `shouldReturn` ((), ())) `finally` cancelTasks tasks + it "should send files from CLI wihtout /store" remoteCLIFileTest -- * Chat commands -remoteHandshakeTest :: (HasCallStack) => FilePath -> IO () -remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do +remoteHandshakeTest :: HasCallStack => FilePath -> IO () +remoteHandshakeTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" + mobile ##> "/list remote ctrls" + mobile <## "No remote controllers" startRemote mobile desktop - logNote "Session active" - desktop ##> "/list remote hosts" desktop <## "Remote hosts:" - desktop <## "1. (active)" + desktop <## "1. Mobile (active)" + mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" mobile <## "1. My desktop (active)" stopMobile mobile desktop `catchAny` (logError . tshow) - -- TODO: add a case for 'stopDesktop' desktop ##> "/delete remote host 1" desktop <## "ok" @@ -162,7 +73,28 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile ##> "/list remote ctrls" mobile <## "No remote controllers" -remoteMessageTest :: (HasCallStack) => FilePath -> IO () +remoteHandshakeStoredTest :: HasCallStack => FilePath -> IO () +remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + logNote "Starting new session" + startRemote mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + + logNote "Starting stored session" + startRemoteStored mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. Mobile" + mobile ##> "/list remote ctrls" + mobile <## "Remote controllers:" + mobile <## "1. My desktop" + + logNote "Starting stored session again" + startRemoteStored mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + +remoteMessageTest :: HasCallStack => FilePath -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop contactBob desktop bob @@ -204,11 +136,13 @@ remoteStoreFileTest = let bobFiles = "./tests/tmp/bob_files" bob ##> ("/_files_folder " <> bobFiles) bob <## "ok" + startRemote mobile desktop contactBob desktop bob + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) - desktopHostStore <- case M.lookup 1 rhs of - Just RemoteHostSession {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + desktopHostStore <- case M.lookup (RHId 1) rhs of + Just RHSessionConnected {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" desktop ##> "/store remote file 1 tests/fixtures/test.pdf" desktop <## "file test.pdf stored on remote host 1" @@ -317,7 +251,7 @@ remoteStoreFileTest = r `shouldStartWith` "remote host 1 error" r `shouldContain` err -remoteCLIFileTest :: (HasCallStack) => FilePath -> IO () +remoteCLIFileTest :: HasCallStack => FilePath -> IO () remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do createDirectoryIfMissing True "./tests/tmp/tmp/" let mobileFiles = "./tests/tmp/mobile_files" @@ -333,8 +267,8 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile contactBob desktop bob rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) - desktopHostStore <- case M.lookup 1 rhs of - Just RemoteHostSession {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + desktopHostStore <- case M.lookup (RHId 1) rhs of + Just RHSessionConnected {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" mobileName <- userName mobile @@ -395,41 +329,41 @@ startRemote :: TestCC -> TestCC -> IO () startRemote mobile desktop = do desktop ##> "/set device name My desktop" desktop <## "ok" - desktop ##> "/create remote host" - desktop <## "remote host 1 created" - -- A new host is started [automatically] by UI - desktop ##> "/start remote host 1" - desktop <## "ok" - desktop <## "remote host 1 started" - desktop <## "connection code:" - oobLink <- getTermLine desktop - OOB {caFingerprint = oobFingerprint} <- either (fail . mappend "OOB link failed: ") pure $ decodeOOBLink (fromString oobLink) - -- Desktop displays OOB QR code - mobile ##> "/set device name Mobile" mobile <## "ok" - mobile ##> "/find remote ctrl" + desktop ##> "/start remote host new" + desktop <## "new remote host started" + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + mobile ##> ("/connect remote ctrl " <> inv) mobile <## "ok" - mobile <## "remote controller announced" - mobile <## "connection code:" - annFingerprint <- getTermLine mobile - -- The user scans OOB QR code and confirms it matches the announced stuff - fromString annFingerprint `shouldBe` strEncode oobFingerprint - - mobile ##> ("/connect remote ctrl " <> oobLink) - mobile <## "remote controller 1 registered" - mobile ##> "/confirm remote ctrl 1" - mobile <## "ok" - mobile <## "remote controller 1 connecting to My desktop" - -- TODO: rework tls connection prelude - mobile <## "remote controller 1 connected to My desktop" + desktop <## "new remote host connecting" + desktop <## "Compare session code with host:" + sessId <- getTermLine desktop + mobile <## "new remote controller connected" mobile <## "Compare session code with controller and use:" - verifyCmd <- getTermLine mobile - mobile ##> verifyCmd + mobile <## ("/verify remote ctrl " <> sessId) + mobile ##> ("/verify remote ctrl " <> sessId) + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + +startRemoteStored :: TestCC -> TestCC -> IO () +startRemoteStored mobile desktop = do + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + mobile ##> ("/connect remote ctrl " <> inv) mobile <## "ok" - concurrently_ - (mobile <## "remote controller 1 session started with My desktop") - (desktop <## "remote host 1 connected") + desktop <## "remote host 1 connecting" + desktop <## "Compare session code with host:" + sessId <- getTermLine desktop + mobile <## "remote controller 1 connected" + mobile <## "Compare session code with controller and use:" + mobile <## ("/verify remote ctrl " <> sessId) + mobile ##> ("/verify remote ctrl " <> sessId) + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" contactBob :: TestCC -> TestCC -> IO () contactBob desktop bob = do @@ -453,9 +387,12 @@ stopDesktop mobile desktop = do logWarn "stopping via desktop" desktop ##> "/stop remote host 1" -- desktop <## "ok" - concurrently_ - (desktop <## "remote host 1 stopped") - (eventually 3 $ mobile <## "remote controller stopped") + concurrentlyN_ + [ do + desktop <## "remote host 1 stopped" + desktop <## "ok", + eventually 3 $ mobile <## "remote controller stopped" + ] stopMobile :: HasCallStack => TestCC -> TestCC -> IO () stopMobile mobile desktop = do @@ -468,7 +405,9 @@ stopMobile mobile desktop = do -- | Run action with extended timeout eventually :: Int -> IO a -> IO a -eventually retries action = tryAny action >>= \case -- TODO: only catch timeouts - Left err | retries == 0 -> throwIO err - Left _ -> eventually (retries - 1) action - Right r -> pure r +eventually retries action = + tryAny action >>= \case + -- TODO: only catch timeouts + Left err | retries == 0 -> throwIO err + Left _ -> eventually (retries - 1) action + Right r -> pure r From 96d94d34381cb8ea9733cb63ee1aa5f6ab0ee53c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 9 Nov 2023 15:55:01 +0800 Subject: [PATCH 035/174] android, desktop: fix linking (#3333) Co-authored-by: avently --- libsimplex.dll.def | 1 + scripts/desktop/download-lib-windows.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/libsimplex.dll.def b/libsimplex.dll.def index ee32ec36a0..755119fca8 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -8,6 +8,7 @@ EXPORTS chat_parse_markdown chat_parse_server chat_password_hash + chat_valid_name chat_encrypt_media chat_decrypt_media chat_write_file diff --git a/scripts/desktop/download-lib-windows.sh b/scripts/desktop/download-lib-windows.sh index 945bd7b5e7..7f2e3c3879 100644 --- a/scripts/desktop/download-lib-windows.sh +++ b/scripts/desktop/download-lib-windows.sh @@ -18,7 +18,7 @@ output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/ mkdir -p "$output_dir" 2> /dev/null -curl --location -o libsimplex.zip $job_repo/$arch-linux.$arch-windows:lib:simplex-chat/latest/download/1 && \ +curl --location -o libsimplex.zip $job_repo/$arch-windows:lib:simplex-chat.$arch-linux/latest/download/1 && \ $WINDIR\\System32\\tar.exe -xf libsimplex.zip && \ mv libsimplex.dll "$output_dir" && \ mv libcrypto*.dll "$output_dir" && \ From 3dd62ab05a365d973bb4c2c13b46994028db37c1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 9 Nov 2023 09:37:56 +0000 Subject: [PATCH 036/174] core: remove Hello from the app remote protocol (#3336) --- src/Simplex/Chat/Remote.hs | 25 +++++++++------------ src/Simplex/Chat/Remote/Protocol.hs | 35 +++++++++++------------------ 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8bb354c216..6ff470cca1 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -141,18 +141,21 @@ startRemoteHost' rh_ = do mkCtrlAppInfo = do deviceName <- chatReadVar localDeviceName pure CtrlAppInfo {appVersionRange = ctrlAppVersionRange, deviceName} - parseHostAppInfo RCHostHello {app = hostAppInfo} rhKey = do - HostAppInfo {deviceName, appVersion} <- - liftEitherWith (ChatErrorRemoteHost rhKey . RHEProtocolError . RPEInvalidJSON) $ JT.parseEither J.parseJSON hostAppInfo - unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ ChatErrorRemoteHost rhKey $ RHEBadVersion appVersion - pure deviceName + parseHostAppInfo :: RCHostHello -> ExceptT RemoteHostError IO HostAppInfo + parseHostAppInfo RCHostHello {app = hostAppInfo} = do + hostInfo@HostAppInfo {deviceName, appVersion, encoding, encryptFiles} <- + liftEitherWith (RHEProtocolError . RPEInvalidJSON) $ JT.parseEither J.parseJSON hostAppInfo + unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion + when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding + pure hostInfo waitForSession :: ChatMonad m => RHKey -> Maybe RemoteHostInfo -> RCHostClient -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForSession rhKey remoteHost_ _rchClient_kill_on_error vars = do -- TODO handle errors (sessId, vars') <- takeRCStep vars toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' - hostDeviceName <- parseHostAppInfo rhHello rhKey + hostInfo@HostAppInfo {deviceName = hostDeviceName} <- + liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case RHSessionConnecting rhs' -> Right ((), RHSessionConfirmed rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- TODO kill client on error @@ -161,7 +164,7 @@ startRemoteHost' rh_ = do let rhKey' = RHId remoteHostId disconnected <- toIO $ onDisconnected remoteHostId httpClient <- liftEitherError (httpError rhKey) $ attachRevHTTP2Client disconnected tls - rhClient <- liftRC $ createRemoteHostClient httpClient sessionKeys storePath hostDeviceName + let rhClient = mkRemoteHostClient httpClient sessionKeys storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient withRemoteHostSession rhKey' $ \case RHSessionConfirmed RHPendingSession {} -> Right ((), RHSessionConnected {rhClient, pollAction, storePath}) @@ -346,7 +349,6 @@ handleRemoteCommand execChatCommand _sessionKeys remoteOutputQ HTTP2Request {req replyError = reply . RRChatResponse . CRChatCmdError Nothing processCommand :: User -> GetChunk -> RemoteCommand -> m () processCommand user getNext = \case - RCHello {deviceName = desktopName} -> handleHello desktopName >>= reply RCSend {command} -> handleSend execChatCommand command >>= reply RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile fileName fileSize fileDigest getNext >>= reply @@ -376,13 +378,6 @@ tryRemoteError :: ExceptT RemoteProtocolError IO a -> ExceptT RemoteProtocolErro tryRemoteError = tryAllErrors (RPEException . tshow) {-# INLINE tryRemoteError #-} -handleHello :: ChatMonad m => Text -> m RemoteResponse -handleHello desktopName = do - logInfo $ "Hello from " <> tshow desktopName - mobileName <- chatReadVar localDeviceName - encryptFiles <- chatReadVar encryptLocalFiles - pure RRHello {encoding = localEncoding, deviceName = mobileName, encryptFiles} - handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse handleSend execChatCommand command = do logDebug $ "Send: " <> tshow command diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 2868a45107..62db487cbd 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -9,7 +9,6 @@ module Simplex.Chat.Remote.Protocol where -import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Data.Aeson ((.=)) @@ -44,8 +43,7 @@ import System.FilePath (takeFileName, ()) import UnliftIO data RemoteCommand - = RCHello {deviceName :: Text} - | RCSend {command :: Text} -- TODO maybe ChatCommand here? + = RCSend {command :: Text} -- TODO maybe ChatCommand here? | RCRecv {wait :: Int} -- this wait should be less than HTTP timeout | -- local file encryption is determined by the host, but can be overridden for videos RCStoreFile {fileName :: String, fileSize :: Word32, fileDigest :: FileDigest} -- requires attachment @@ -53,8 +51,7 @@ data RemoteCommand deriving (Show) data RemoteResponse - = RRHello {encoding :: PlatformEncoding, deviceName :: Text, encryptFiles :: Bool} - | RRChatResponse {chatResponse :: ChatResponse} + = RRChatResponse {chatResponse :: ChatResponse} | RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout | RRFileStored {filePath :: String} | RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest @@ -67,22 +64,16 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) -- * Client side / desktop -createRemoteHostClient :: HTTP2Client -> HostSessKeys -> FilePath -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient -createRemoteHostClient httpClient sessionKeys storePath desktopName = do - logDebug "Sending initial hello" - sendRemoteCommand' httpClient localEncoding Nothing RCHello {deviceName = desktopName} >>= \case - RRHello {encoding, deviceName = mobileName, encryptFiles} -> do - logDebug "Got initial hello" - when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding - pure RemoteHostClient - { hostEncoding = encoding, - hostDeviceName = mobileName, - httpClient, - encryptHostFiles = encryptFiles, - sessionKeys, - storePath - } - r -> badResponse r +mkRemoteHostClient :: HTTP2Client -> HostSessKeys -> FilePath -> HostAppInfo -> RemoteHostClient +mkRemoteHostClient httpClient sessionKeys storePath HostAppInfo {encoding, deviceName, encryptFiles} = + RemoteHostClient + { hostEncoding = encoding, + hostDeviceName = deviceName, + httpClient, + encryptHostFiles = encryptFiles, + sessionKeys, + storePath + } closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient @@ -148,7 +139,7 @@ convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value convertJSON _remote@PEKotlin _local@PEKotlin = id convertJSON PESwift PESwift = id convertJSON PESwift PEKotlin = owsf2tagged -convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by createRemoteHostClient +convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by handshake -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value From 6d4febb669e0b86a8facb164b7b1daa4bcef9736 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 9 Nov 2023 20:25:05 +0200 Subject: [PATCH 037/174] core: handle remote control session setup errors (#3332) * handle session setup errors * add command/async wrapper * move furniture around --- src/Simplex/Chat/Remote.hs | 92 +++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 6ff470cca1..8dcf0de89c 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -131,26 +131,38 @@ startRemoteHost' rh_ = do withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) ctrlAppInfo <- mkCtrlAppInfo (invitation, rchClient, vars) <- withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast - rhsWaitSession <- async $ waitForSession rhKey remoteHost_ rchClient vars + cmdOk <- newEmptyTMVarIO + rhsWaitSession <- async $ do + atomically $ takeTMVar cmdOk + cleanupOnError rchClient $ waitForSession remoteHost_ vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} withRemoteHostSession rhKey $ \case RHSessionStarting -> Right ((), RHSessionConnecting rhs) _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - pure (remoteHost_, invitation) + (remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) where mkCtrlAppInfo = do deviceName <- chatReadVar localDeviceName pure CtrlAppInfo {appVersionRange = ctrlAppVersionRange, deviceName} parseHostAppInfo :: RCHostHello -> ExceptT RemoteHostError IO HostAppInfo parseHostAppInfo RCHostHello {app = hostAppInfo} = do - hostInfo@HostAppInfo {deviceName, appVersion, encoding, encryptFiles} <- + hostInfo@HostAppInfo {appVersion, encoding} <- liftEitherWith (RHEProtocolError . RPEInvalidJSON) $ JT.parseEither J.parseJSON hostAppInfo unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo - waitForSession :: ChatMonad m => RHKey -> Maybe RemoteHostInfo -> RCHostClient -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () - waitForSession rhKey remoteHost_ _rchClient_kill_on_error vars = do - -- TODO handle errors + cleanupOnError :: ChatMonad m => RCHostClient -> (TMVar RHKey -> m ()) -> m () + cleanupOnError rchClient action = do + currentKey <- newEmptyTMVarIO + action currentKey `catchChatError` \err -> do + logError $ "startRemoteHost'.waitForSession crashed: " <> tshow err + sessions <- asks remoteHostSessions + atomically $ readTMVar currentKey >>= (`TM.delete` sessions) + liftIO $ cancelHostClient rchClient + waitForSession :: ChatMonad m => Maybe RemoteHostInfo -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> TMVar RHKey -> m () + waitForSession remoteHost_ vars currentKey = do + let rhKey = maybe RHNew (\RemoteHostInfo {remoteHostId} -> RHId remoteHostId) remoteHost_ + atomically $ writeTMVar currentKey rhKey (sessId, vars') <- takeRCStep vars toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' @@ -158,23 +170,24 @@ startRemoteHost' rh_ = do liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case RHSessionConnecting rhs' -> Right ((), RHSessionConfirmed rhs') -- TODO check it's the same session? - _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- TODO kill client on error + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName - let rhKey' = RHId remoteHostId + let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew + atomically $ writeTMVar currentKey rhKey' disconnected <- toIO $ onDisconnected remoteHostId - httpClient <- liftEitherError (httpError rhKey) $ attachRevHTTP2Client disconnected tls + httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls let rhClient = mkRemoteHostClient httpClient sessionKeys storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient withRemoteHostSession rhKey' $ \case RHSessionConfirmed RHPendingSession {} -> Right ((), RHSessionConnected {rhClient, pollAction, storePath}) - _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState -- TODO kill client on error + _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> m RemoteHostInfo - upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rh_ hostDeviceName = do + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ - case rh_ of + case rhi_ of Nothing -> do storePath <- liftIO randomStorePath rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db @@ -307,11 +320,20 @@ connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca hostAppInfo <- getHostAppInfo v (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a inv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) - rcsWaitSession <- async $ waitForSession rc_ ctrlDeviceName rcsClient vars - updateRemoteCtrlSession $ \case + cmdOk <- newEmptyTMVarIO + rcsWaitSession <- async $ do + atomically $ takeTMVar cmdOk + cleanupOnError rcsClient $ waitForSession rc_ ctrlDeviceName rcsClient vars + cleanupOnError rcsClient . updateRemoteCtrlSession $ \case RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} - _ -> Left $ ChatErrorRemoteCtrl RCEBadState -- TODO kill rcsClient + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + atomically $ putTMVar cmdOk () where + cleanupOnError :: ChatMonad m => RCCtrlClient -> m () -> m () + cleanupOnError rcsClient action = action `catchChatError` \e -> do + logError $ "connectRemoteCtrl crashed with: " <> tshow e + chatWriteVar remoteCtrlSession Nothing -- XXX: can only wipe PendingConfirmation or RCSessionConnecting, which only have rcsClient to cancel + liftIO $ cancelCtrlClient rcsClient waitForSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () waitForSession rc_ ctrlName rcsClient vars = do (uniq, rcsWaitConfirmation) <- takeRCStep vars @@ -319,7 +341,7 @@ connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlName, rcsClient, sessionCode, rcsWaitSession, rcsWaitConfirmation} - _ -> Left $ ChatErrorRemoteCtrl RCEBadState -- TODO kill rcsClient + _ -> Left $ ChatErrorRemoteCtrl RCEBadState parseCtrlAppInfo ctrlAppInfo = do CtrlAppInfo {deviceName, appVersionRange} <- liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo @@ -449,7 +471,7 @@ confirmRemoteCtrl _rcId = do -- | Take a look at emoji of tlsunique, commit pairing, and start session server verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo -verifyRemoteCtrlSession execChatCommand sessCode' = do +verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do (client, ctrlName, sessionCode, vars) <- getRemoteCtrlSession >>= \case RCSessionPendingConfirmation {rcsClient, ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) @@ -458,25 +480,35 @@ verifyRemoteCtrlSession execChatCommand sessCode' = do liftIO $ confirmCtrlSession client verified unless verified $ throwError $ ChatErrorRemoteCtrl RCEBadVerificationCode (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- takeRCStep vars - rc@RemoteCtrl {remoteCtrlId} <- withStore $ \db -> do - rc_ <- liftIO $ getRemoteCtrlByFingerprint db (ctrlFingerprint rcCtrlPairing) - case rc_ of - Nothing -> insertRemoteCtrl db ctrlName rcCtrlPairing >>= getRemoteCtrl db - Just rc@RemoteCtrl {remoteCtrlId} -> do - liftIO $ updateCtrlPairingKeys db remoteCtrlId (dhPrivKey rcCtrlPairing) - pure rc + rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand sessionKeys remoteOutputQ + void . forkIO $ monitor http2Server withRemoteCtrlSession $ \case RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, http2Server, remoteOutputQ}) _ -> Left $ ChatErrorRemoteCtrl RCEBadState - void . forkIO $ do - waitCatch http2Server >>= \case - Left err | Just (BadThingHappen innerErr) <- fromException err -> logWarn $ "HTTP2 server crashed with internal " <> tshow innerErr - Left err | isNothing (fromException @AsyncCancelled err) -> logError $ "HTTP2 server crashed with " <> tshow err - _ -> logInfo "HTTP2 server stopped" - toView CRRemoteCtrlStopped pure $ remoteCtrlInfo rc True + where + upsertRemoteCtrl :: ChatMonad m => Text -> RCCtrlPairing -> m RemoteCtrl + upsertRemoteCtrl ctrlName rcCtrlPairing = withStore $ \db -> do + rc_ <- liftIO $ getRemoteCtrlByFingerprint db (ctrlFingerprint rcCtrlPairing) + case rc_ of + Nothing -> insertRemoteCtrl db ctrlName rcCtrlPairing >>= getRemoteCtrl db + Just rc@RemoteCtrl {remoteCtrlId} -> do + liftIO $ updateCtrlPairingKeys db remoteCtrlId (dhPrivKey rcCtrlPairing) + pure rc + cleanupOnError :: ChatMonad m => m a -> m a + cleanupOnError action = action `catchChatError` \e -> do + logError $ "verifyRemoteCtrlSession crashed with: " <> tshow e + withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) -- cancel session threads, if any + throwError e + monitor :: ChatMonad m => Async a -> m () + monitor server = do + waitCatch server >>= \case + Left err | Just (BadThingHappen innerErr) <- fromException err -> logWarn $ "HTTP2 server crashed with internal " <> tshow innerErr + Left err | isNothing (fromException @AsyncCancelled err) -> logError $ "HTTP2 server crashed with " <> tshow err + _ -> logInfo "HTTP2 server stopped" + toView CRRemoteCtrlStopped stopRemoteCtrl :: ChatMonad m => m () stopRemoteCtrl = From f41861c026bf1f8de873d497b8ce6e74f41aa539 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 10 Nov 2023 00:43:44 +0200 Subject: [PATCH 038/174] core: terminate remote control TLS connection on both sides (#3338) * handle session setup errors * add command/async wrapper * move furniture around * detect disconnects and force them with closeConnection * simplify http server log * close TLS in other cases --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 12 ++++++---- src/Simplex/Chat/Remote.hs | 39 ++++++++++++++++++-------------- src/Simplex/Chat/Remote/Types.hs | 5 ++-- stack.yaml | 2 +- tests/RemoteTests.hs | 22 ++++++++++-------- 7 files changed, 47 insertions(+), 37 deletions(-) diff --git a/cabal.project b/cabal.project index fe74d21485..ea6129f3f6 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 102487bc4fbb865aac4207d2ba6f2ea77eff3290 + tag: bd06b47a9df13506ee77251868a5a1d1e7cadafe source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26f7357a11..d9033088a8 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."102487bc4fbb865aac4207d2ba6f2ea77eff3290" = "1zay63ix9vh20p6843l1zry47zwb7lkirmxrrgdcc7qwl89js1bs"; + "https://github.com/simplex-chat/simplexmq.git"."bd06b47a9df13506ee77251868a5a1d1e7cadafe" = "1x6hy3awxf10l5ai82p3fhsrv1flc24gxsw9jl1b0cl7iypxhmsp"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4c38ca95d9..6832bb5621 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -16,7 +16,6 @@ module Simplex.Chat.Controller where -import Simplex.RemoteControl.Invitation (RCSignedInvitation) import Control.Concurrent (ThreadId) import Control.Concurrent.Async (Async) import Control.Exception @@ -29,6 +28,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) @@ -70,16 +70,16 @@ import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Transport (simplexMQVersion) +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) +import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM -import Data.Bifunctor (first) -import Simplex.RemoteControl.Client -import Simplex.RemoteControl.Types versionNumber :: String versionNumber = showVersion SC.version @@ -1086,6 +1086,7 @@ data RemoteCtrlSession | RCSessionPendingConfirmation { ctrlName :: Text, rcsClient :: RCCtrlClient, + tls :: TLS, sessionCode :: Text, rcsWaitSession :: Async (), rcsWaitConfirmation :: TMVar (Either RCErrorType (RCCtrlSession, RCCtrlPairing)) @@ -1093,6 +1094,7 @@ data RemoteCtrlSession | RCSessionConnected { remoteCtrlId :: RemoteCtrlId, rcsClient :: RCCtrlClient, + tls :: TLS, rcsSession :: RCCtrlSession, http2Server :: Async (), remoteOutputQ :: TBQueue ChatResponse diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8dcf0de89c..a5ddc3439e 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -60,6 +60,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport (TLS, closeConnection) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) @@ -169,7 +170,7 @@ startRemoteHost' rh_ = do hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case - RHSessionConnecting rhs' -> Right ((), RHSessionConfirmed rhs') -- TODO check it's the same session? + RHSessionConnecting rhs' -> Right ((), RHSessionConfirmed tls rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName @@ -180,7 +181,7 @@ startRemoteHost' rh_ = do let rhClient = mkRemoteHostClient httpClient sessionKeys storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient withRemoteHostSession rhKey' $ \case - RHSessionConfirmed RHPendingSession {} -> Right ((), RHSessionConnected {rhClient, pollAction, storePath}) + RHSessionConfirmed _ RHPendingSession {} -> Right ((), RHSessionConnected {tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi @@ -223,14 +224,17 @@ cancelRemoteHost :: RemoteHostSession -> IO () cancelRemoteHost = \case RHSessionStarting -> pure () RHSessionConnecting rhs -> cancelPendingSession rhs - RHSessionConfirmed rhs -> cancelPendingSession rhs - RHSessionConnected {rhClient = RemoteHostClient {httpClient}, pollAction} -> do + RHSessionConfirmed tls rhs -> do + cancelPendingSession rhs + closeConnection tls + RHSessionConnected {tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do uninterruptibleCancel pollAction closeHTTP2Client httpClient + closeConnection tls where cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do - cancelHostClient rchClient uninterruptibleCancel rhsWaitSession + cancelHostClient rchClient -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -334,13 +338,13 @@ connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = logError $ "connectRemoteCtrl crashed with: " <> tshow e chatWriteVar remoteCtrlSession Nothing -- XXX: can only wipe PendingConfirmation or RCSessionConnecting, which only have rcsClient to cancel liftIO $ cancelCtrlClient rcsClient - waitForSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () + waitForSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () waitForSession rc_ ctrlName rcsClient vars = do - (uniq, rcsWaitConfirmation) <- takeRCStep vars + (uniq, tls, rcsWaitConfirmation) <- takeRCStep vars let sessionCode = verificationCode uniq toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case - RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlName, rcsClient, sessionCode, rcsWaitSession, rcsWaitConfirmation} + RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState parseCtrlAppInfo ctrlAppInfo = do CtrlAppInfo {deviceName, appVersionRange} <- @@ -485,7 +489,7 @@ verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand sessionKeys remoteOutputQ void . forkIO $ monitor http2Server withRemoteCtrlSession $ \case - RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, http2Server, remoteOutputQ}) + RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ}) _ -> Left $ ChatErrorRemoteCtrl RCEBadState pure $ remoteCtrlInfo rc True where @@ -502,12 +506,11 @@ verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do logError $ "verifyRemoteCtrlSession crashed with: " <> tshow e withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) -- cancel session threads, if any throwError e - monitor :: ChatMonad m => Async a -> m () + monitor :: ChatMonad m => Async () -> m () monitor server = do - waitCatch server >>= \case - Left err | Just (BadThingHappen innerErr) <- fromException err -> logWarn $ "HTTP2 server crashed with internal " <> tshow innerErr - Left err | isNothing (fromException @AsyncCancelled err) -> logError $ "HTTP2 server crashed with " <> tshow err - _ -> logInfo "HTTP2 server stopped" + res <- waitCatch server + logInfo $ "HTTP2 server stopped: " <> tshow res + withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) -- cancel session threads, if any toView CRRemoteCtrlStopped stopRemoteCtrl :: ChatMonad m => m () @@ -519,14 +522,16 @@ cancelRemoteCtrl :: RemoteCtrlSession -> IO () cancelRemoteCtrl = \case RCSessionStarting -> pure () RCSessionConnecting {rcsClient, rcsWaitSession} -> do - cancelCtrlClient rcsClient uninterruptibleCancel rcsWaitSession - RCSessionPendingConfirmation {rcsClient, rcsWaitSession} -> do cancelCtrlClient rcsClient + RCSessionPendingConfirmation {rcsClient, tls, rcsWaitSession} -> do uninterruptibleCancel rcsWaitSession - RCSessionConnected {rcsClient, http2Server} -> do cancelCtrlClient rcsClient + closeConnection tls + RCSessionConnected {rcsClient, tls, http2Server} -> do uninterruptibleCancel http2Server + cancelCtrlClient rcsClient + closeConnection tls deleteRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () deleteRemoteCtrl rcId = do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index d9aa9d50bf..638707563f 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -20,6 +20,7 @@ import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Types import Simplex.Messaging.Crypto.File (CryptoFile) +import Simplex.Messaging.Transport (TLS) data RemoteHostClient = RemoteHostClient { hostEncoding :: PlatformEncoding, @@ -40,8 +41,8 @@ data RHPendingSession = RHPendingSession data RemoteHostSession = RHSessionStarting | RHSessionConnecting {rhPendingSession :: RHPendingSession} - | RHSessionConfirmed {rhPendingSession :: RHPendingSession} - | RHSessionConnected {rhClient :: RemoteHostClient, pollAction :: Async (), storePath :: FilePath} + | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} + | RHSessionConnected {tls :: TLS, rhClient :: RemoteHostClient, pollAction :: Async (), storePath :: FilePath} data RemoteProtocolError = -- | size prefix is malformed diff --git a/stack.yaml b/stack.yaml index 4c26936c02..c90140963a 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 102487bc4fbb865aac4207d2ba6f2ea77eff3290 + commit: bd06b47a9df13506ee77251868a5a1d1e7cadafe - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 83f7eeee5a..b35e54032f 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -34,8 +34,8 @@ import UnliftIO.Directory remoteTests :: SpecWith FilePath remoteTests = describe "Remote" $ do describe "protocol handshake" $ do - it "connects with new pairing" remoteHandshakeTest - it "connects with new pairing (again)" remoteHandshakeTest -- leaking servers regression check + it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False + it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True it "connects with stored pairing" remoteHandshakeStoredTest it "sends messages" remoteMessageTest describe "remote files" $ do @@ -44,8 +44,8 @@ remoteTests = describe "Remote" $ do -- * Chat commands -remoteHandshakeTest :: HasCallStack => FilePath -> IO () -remoteHandshakeTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do +remoteHandshakeTest :: HasCallStack => Bool -> FilePath -> IO () +remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" mobile ##> "/list remote ctrls" @@ -61,7 +61,7 @@ remoteHandshakeTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile deskt mobile <## "Remote controllers:" mobile <## "1. My desktop (active)" - stopMobile mobile desktop `catchAny` (logError . tshow) + if viaDesktop then stopDesktop mobile desktop else stopMobile mobile desktop desktop ##> "/delete remote host 1" desktop <## "ok" @@ -81,7 +81,7 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile logNote "Starting stored session" startRemoteStored mobile desktop - stopMobile mobile desktop `catchAny` (logError . tshow) + stopDesktop mobile desktop `catchAny` (logError . tshow) desktop ##> "/list remote hosts" desktop <## "Remote hosts:" @@ -398,10 +398,12 @@ stopMobile :: HasCallStack => TestCC -> TestCC -> IO () stopMobile mobile desktop = do logWarn "stopping via mobile" mobile ##> "/stop remote ctrl" - mobile <## "ok" - concurrently_ - (mobile <## "remote controller stopped") - (eventually 3 $ desktop <## "remote host 1 stopped") + concurrentlyN_ + [ do + mobile <## "remote controller stopped" + mobile <## "ok", + eventually 3 $ desktop <## "remote host 1 stopped" + ] -- | Run action with extended timeout eventually :: Int -> IO a -> IO a From f49ded5ae536b96f6f05c562b684d3fc12b647a9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 10 Nov 2023 10:16:06 +0400 Subject: [PATCH 039/174] ios: connect with contact via address (for preset simplex contact) (#3323) * ios: connect with contact via address (for preset simplex contact) * remove diff * remove floating button * refactor active * open chat * remove disabled * fix incognito --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 12 ++- apps/ios/Shared/Model/SimpleXAPI.swift | 30 ++++-- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/ChatList/ChatListNavLink.swift | 93 ++++++++++++++----- .../Shared/Views/ChatList/ChatListView.swift | 7 -- .../Views/ChatList/ChatPreviewView.swift | 7 +- .../Shared/Views/NewChat/NewChatButton.swift | 29 ++++++ apps/ios/SimpleXChat/APITypes.swift | 7 ++ apps/ios/SimpleXChat/ChatTypes.swift | 16 ++-- 11 files changed, 156 insertions(+), 51 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f562ea7f51..fe9032e7c2 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -194,7 +194,7 @@ final class ChatModel: ObservableObject { func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) { var updatedConn = contact.activeConn - updatedConn.connectionStats = connectionStats + updatedConn?.connectionStats = connectionStats var updatedContact = contact updatedContact.activeConn = updatedConn updateContact(updatedContact) @@ -671,11 +671,17 @@ final class ChatModel: ObservableObject { } func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { - networkStatuses[contact.activeConn.agentConnId] = status + if let conn = contact.activeConn { + networkStatuses[conn.agentConnId] = status + } } func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { - networkStatuses[contact.activeConn.agentConnId] ?? .unknown + if let conn = contact.activeConn { + networkStatuses[conn.agentConnId] ?? .unknown + } else { + .unknown + } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 32e983a841..ec539b7fa5 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -675,6 +675,18 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert { } } +func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Contact?, Alert?) { + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiConnectContactViaAddress: no current user") + return (nil, nil) + } + let r = await chatSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId)) + if case let .sentInvitationToContact(_, contact, _) = r { return (contact, nil) } + logger.error("apiConnectContactViaAddress error: \(responseError(r))") + let alert = connectionErrorAlert(r) + return (nil, alert) +} + func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws { let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false) if case .direct = type, case .contactDeleted = r { return } @@ -1326,8 +1338,10 @@ func processReceivedMsg(_ res: ChatResponse) async { if active(user) && contact.directOrUsed { await MainActor.run { m.updateContact(contact) - m.dismissConnReqView(contact.activeConn.id) - m.removeChat(contact.activeConn.id) + if let conn = contact.activeConn { + m.dismissConnReqView(conn.id) + m.removeChat(conn.id) + } } } if contact.directOrUsed { @@ -1340,8 +1354,10 @@ func processReceivedMsg(_ res: ChatResponse) async { if active(user) && contact.directOrUsed { await MainActor.run { m.updateContact(contact) - m.dismissConnReqView(contact.activeConn.id) - m.removeChat(contact.activeConn.id) + if let conn = contact.activeConn { + m.dismissConnReqView(conn.id) + m.removeChat(conn.id) + } } } case let .receivedContactRequest(user, contactRequest): @@ -1480,9 +1496,9 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { m.updateGroup(groupInfo) - if let hostContact = hostContact { - m.dismissConnReqView(hostContact.activeConn.id) - m.removeChat(hostContact.activeConn.id) + if let conn = hostContact?.activeConn { + m.dismissConnReqView(conn.id) + m.removeChat(conn.id) } } case let .groupLinkConnecting(user, groupInfo, hostMember): diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index b90c9e7479..b702c2cc23 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -338,7 +338,7 @@ struct ChatInfoView: View { verify: { code in if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) { let (verified, existingCode) = r - contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil + contact.activeConn?.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil connectionCode = existingCode DispatchQueue.main.async { chat.chatInfo = .direct(contact: contact) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index f276025dda..d8a560640e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -66,7 +66,7 @@ struct CIRcvDecryptionError: View { @ViewBuilder private func viewBody() -> some View { if case let .direct(contact) = chat.chatInfo, - let contactStats = contact.activeConn.connectionStats { + let contactStats = contact.activeConn?.connectionStats { if contactStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncContactConnection(contact) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5e5a7f8b51..1f7cf1e371 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -114,7 +114,7 @@ struct ChatView: View { connectionStats = stats customUserProfile = profile connectionCode = code - if contact.activeConn.connectionCode != ct.activeConn.connectionCode { + if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode { chat.chatInfo = .direct(contact: ct) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 971c0e0888..18464b3bb5 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -33,6 +33,7 @@ struct ChatListNavLink: View { @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false @State private var showDeleteContactActionSheet = false + @State private var showConnectContactViaAddressDialog = false @State private var inProgress = false @State private var progressByTimeout = false @@ -63,32 +64,52 @@ struct ChatListNavLink: View { } @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { - NavLinkPlain( - tag: chat.chatInfo.id, - selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } - ) - .swipeActions(edge: .leading, allowsFullSwipe: true) { - markReadButton() - toggleFavoriteButton() - toggleNtfsButton(chat) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - if !chat.chatItems.isEmpty { - clearChatButton() - } - Button { - if contact.ready || !contact.active { - showDeleteContactActionSheet = true - } else { - AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) + Group { + if contact.activeConn == nil && contact.profile.contactLink != nil { + ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) + .frame(height: rowHeights[dynamicTypeSize]) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + showDeleteContactActionSheet = true + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .onTapGesture { showConnectContactViaAddressDialog = true } + .confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) { + Button("Use current profile") { connectContactViaAddress_(contact, false) } + Button("Use new incognito profile") { connectContactViaAddress_(contact, true) } + } + } else { + NavLinkPlain( + tag: chat.chatInfo.id, + selection: $chatModel.chatId, + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } + ) + .swipeActions(edge: .leading, allowsFullSwipe: true) { + markReadButton() + toggleFavoriteButton() + toggleNtfsButton(chat) } - } label: { - Label("Delete", systemImage: "trash") + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if !chat.chatItems.isEmpty { + clearChatButton() + } + Button { + if contact.ready || !contact.active { + showDeleteContactActionSheet = true + } else { + AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) + } + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .frame(height: rowHeights[dynamicTypeSize]) } - .tint(.red) } - .frame(height: rowHeights[dynamicTypeSize]) .actionSheet(isPresented: $showDeleteContactActionSheet) { if contact.ready && contact.active { return ActionSheet( @@ -411,6 +432,17 @@ struct ChatListNavLink: View { .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } } + + private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) { + Task { + let ok = await connectContactViaAddress(contact.contactId, incognito) + if ok { + await MainActor.run { + chatModel.chatId = contact.id + } + } + } + } } func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { @@ -439,6 +471,21 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, ) } +func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool { + let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId) + if let alert = alert { + AlertManager.shared.showAlert(alert) + return false + } else if let contact = contact { + await MainActor.run { + ChatModel.shared.updateContact(contact) + AlertManager.shared.showAlert(connReqSentAlert(.contact)) + } + return true + } + return false +} + func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { Task { logger.debug("joinGroup") diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index baab2bcf95..a006f333f8 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -177,13 +177,6 @@ struct ChatListView: View { showAddChat = true } - connectButton("or chat with the developers") { - DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) - } - } - .padding(.top, 10) - Spacer() Text("You have no chats") .foregroundColor(.secondary) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 71f8baf748..30068114f3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -190,7 +190,10 @@ struct ChatPreviewView: View { } else { switch (chat.chatInfo) { case let .direct(contact): - if !contact.ready { + if contact.activeConn == nil && contact.profile.contactLink != nil { + chatPreviewInfoText("Tap to Connect") + .foregroundColor(.accentColor) + } else if !contact.ready && contact.activeConn != nil { if contact.nextSendGrpInv { chatPreviewInfoText("send direct message") } else if contact.active { @@ -238,7 +241,7 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { switch chat.chatInfo { case let .direct(contact): - if contact.active { + if contact.active && contact.activeConn != nil { switch (chatModel.contactNetworkStatus(contact)) { case .connected: incognitoIcon(chat.chatInfo.incognito) case .error: diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 8d095e907b..637c010328 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -155,12 +155,14 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { enum PlanAndConnectActionSheet: Identifiable { case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact) case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) var id: String { switch self { case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" + case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)" case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" } } @@ -186,6 +188,15 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool .cancel() ] ) + case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): + return ActionSheet( + title: Text("Connect with \(contact.chatViewName)"), + buttons: [ + .default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false) }, + .default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo): if let incognito = incognito { return ActionSheet( @@ -277,6 +288,13 @@ func planAndConnect( case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + case let .contactViaAddress(contact): + logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact)) + } } case let .groupLink(glp): switch glp { @@ -315,6 +333,17 @@ func planAndConnect( } } +private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool) { + Task { + if dismiss { + DispatchQueue.main.async { + dismissAllSheets(animated: true) + } + } + _ = await connectContactViaAddress(contact.contactId, incognito) + } +} + private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { Task { if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3b0b4de04d..c19e65c9d4 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -90,6 +90,7 @@ public enum ChatCommand { case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) + case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) @@ -226,6 +227,7 @@ public enum ChatCommand { case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" + case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" case let .apiDeleteChat(type, id, notify): if let notify = notify { return "/_delete \(ref(type, id)) notify=\(onOff(notify))" } else { @@ -297,6 +299,7 @@ public enum ChatCommand { case .apiSendMessage: return "apiSendMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" case .apiChatItemReaction: return "apiChatItemReaction" case .apiGetNtfToken: return "apiGetNtfToken" @@ -478,6 +481,7 @@ public enum ChatResponse: Decodable, Error { case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef) case sentInvitation(user: UserRef) + case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) @@ -622,6 +626,7 @@ public enum ChatResponse: Decodable, Error { case .connectionPlan: return "connectionPlan" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" + case .sentInvitationToContact: return "sentInvitationToContact" case .contactAlreadyExists: return "contactAlreadyExists" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactDeleted: return "contactDeleted" @@ -763,6 +768,7 @@ public enum ChatResponse: Decodable, Error { case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails + case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) @@ -902,6 +908,7 @@ public enum ContactAddressPlan: Decodable { case connectingConfirmReconnect case connectingProhibit(contact: Contact) case known(contact: Contact) + case contactViaAddress(contact: Contact) } public enum GroupLinkPlan: Decodable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 25511e1bae..f8a6d78a53 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1370,7 +1370,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var contactId: Int64 var localDisplayName: ContactName public var profile: LocalProfile - public var activeConn: Connection + public var activeConn: Connection? public var viaGroup: Int64? public var contactUsed: Bool public var contactStatus: ContactStatus @@ -1384,10 +1384,10 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } - public var ready: Bool { get { activeConn.connStatus == .ready } } + public var ready: Bool { get { activeConn?.connStatus == .ready } } public var active: Bool { get { contactStatus == .active } } public var sendMsgEnabled: Bool { get { - (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) + (ready && active && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false)) || nextSendGrpInv } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } @@ -1396,14 +1396,18 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var image: String? { get { profile.image } } public var contactLink: String? { get { profile.contactLink } } public var localAlias: String { profile.localAlias } - public var verified: Bool { activeConn.connectionCode != nil } + public var verified: Bool { activeConn?.connectionCode != nil } public var directOrUsed: Bool { - (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed + if let activeConn = activeConn { + (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed + } else { + true + } } public var contactConnIncognito: Bool { - activeConn.customUserProfileId != nil + activeConn?.customUserProfileId != nil } public func allowsFeature(_ feature: ChatFeature) -> Bool { From c0be36737d8bae7191736f03523f28e3f714036e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 10 Nov 2023 10:16:28 +0400 Subject: [PATCH 040/174] android: connect with contact via address (for preset simplex contact) (#3330) --- .../chat/simplex/common/model/ChatModel.kt | 41 ++++++--- .../chat/simplex/common/model/SimpleXAPI.kt | 45 ++++++++-- .../simplex/common/views/chat/ChatInfoView.kt | 2 +- .../views/chat/item/CIRcvDecryptionError.kt | 2 +- .../views/chatlist/ChatListNavLinkView.kt | 87 ++++++++++++++++--- .../common/views/chatlist/ChatListView.kt | 5 -- .../common/views/chatlist/ChatPreviewView.kt | 14 +-- .../views/chatlist/ShareListNavLinkView.kt | 2 +- .../common/views/newchat/ScanToConnectView.kt | 13 ++- .../commonMain/resources/MR/base/strings.xml | 2 + 10 files changed, 165 insertions(+), 48 deletions(-) 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 4d95bfd499..91b4a8d8f6 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 @@ -147,12 +147,13 @@ object ChatModel { val currentCInfo = chats[i].chatInfo var newCInfo = cInfo if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) { - val currentStats = currentCInfo.contact.activeConn.connectionStats - val newStats = newCInfo.contact.activeConn.connectionStats - if (currentStats != null && newStats == null) { + val currentStats = currentCInfo.contact.activeConn?.connectionStats + val newConn = newCInfo.contact.activeConn + val newStats = newConn?.connectionStats + if (currentStats != null && newConn != null && newStats == null) { newCInfo = newCInfo.copy( contact = newCInfo.contact.copy( - activeConn = newCInfo.contact.activeConn.copy( + activeConn = newConn.copy( connectionStats = currentStats ) ) @@ -168,7 +169,7 @@ object ChatModel { fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed) fun updateContactConnectionStats(contact: Contact, connectionStats: ConnectionStats) { - val updatedConn = contact.activeConn.copy(connectionStats = connectionStats) + val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats) val updatedContact = contact.copy(activeConn = updatedConn) updateContact(updatedContact) } @@ -570,11 +571,19 @@ object ChatModel { } fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { - networkStatuses[contact.activeConn.agentConnId] = status + val conn = contact.activeConn + if (conn != null) { + networkStatuses[conn.agentConnId] = status + } } - fun contactNetworkStatus(contact: Contact): NetworkStatus = - networkStatuses[contact.activeConn.agentConnId] ?: NetworkStatus.Unknown() + fun contactNetworkStatus(contact: Contact): NetworkStatus { + val conn = contact.activeConn + return if (conn != null) + networkStatuses[conn.agentConnId] ?: NetworkStatus.Unknown() + else + NetworkStatus.Unknown() + } fun addTerminalItem(item: TerminalItem) { if (terminalItems.size >= 500) { @@ -891,7 +900,7 @@ data class Contact( val contactId: Long, override val localDisplayName: String, val profile: LocalProfile, - val activeConn: Connection, + val activeConn: Connection? = null, val viaGroup: Long? = null, val contactUsed: Boolean, val contactStatus: ContactStatus, @@ -906,10 +915,10 @@ data class Contact( override val chatType get() = ChatType.Direct override val id get() = "@$contactId" override val apiId get() = contactId - override val ready get() = activeConn.connStatus == ConnStatus.Ready + override val ready get() = activeConn?.connStatus == ConnStatus.Ready val active get() = contactStatus == ContactStatus.Active override val sendMsgEnabled get() = - (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) + (ready && active && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false)) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All @@ -927,13 +936,17 @@ data class Contact( override val image get() = profile.image val contactLink: String? = profile.contactLink override val localAlias get() = profile.localAlias - val verified get() = activeConn.connectionCode != null + val verified get() = activeConn?.connectionCode != null val directOrUsed: Boolean get() = - (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed + if (activeConn != null) { + (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed + } else { + true + } val contactConnIncognito = - activeConn.customUserProfileId != null + activeConn?.customUserProfileId != null fun allowsFeature(feature: ChatFeature): Boolean = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO 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 431ddf1e2a..1fa8c35d57 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 @@ -907,6 +907,23 @@ object ChatController { } } + suspend fun apiConnectContactViaAddress(incognito: Boolean, contactId: Long): Contact? { + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiConnectContactViaAddress: no current user") + return null + } + val r = sendCmd(CC.ApiConnectContactViaAddress(userId, incognito, contactId)) + when { + r is CR.SentInvitationToContact -> return r.contact + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiConnectContactViaAddress", generalGetString(MR.strings.connection_error), r) + } + return null + } + } + } + suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean { val r = sendCmd(CC.ApiDeleteChat(type, id, notify)) when { @@ -1413,8 +1430,11 @@ object ChatController { is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) - chatModel.dismissConnReqView(r.contact.activeConn.id) - chatModel.removeChat(r.contact.activeConn.id) + val conn = r.contact.activeConn + if (conn != null) { + chatModel.dismissConnReqView(conn.id) + chatModel.removeChat(conn.id) + } } if (r.contact.directOrUsed) { ntfManager.notifyContactConnected(r.user, r.contact) @@ -1424,8 +1444,11 @@ object ChatController { is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) - chatModel.dismissConnReqView(r.contact.activeConn.id) - chatModel.removeChat(r.contact.activeConn.id) + val conn = r.contact.activeConn + if (conn != null) { + chatModel.dismissConnReqView(conn.id) + chatModel.removeChat(conn.id) + } } } is CR.ReceivedContactRequest -> { @@ -1556,9 +1579,10 @@ object ChatController { if (!active(r.user)) return chatModel.updateGroup(r.groupInfo) - if (r.hostContact != null) { - chatModel.dismissConnReqView(r.hostContact.activeConn.id) - chatModel.removeChat(r.hostContact.activeConn.id) + val conn = r.hostContact?.activeConn + if (conn != null) { + chatModel.dismissConnReqView(conn.id) + chatModel.removeChat(conn.id) } } is CR.GroupLinkConnecting -> { @@ -1946,6 +1970,7 @@ sealed class CC { class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() + class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() @@ -2057,6 +2082,7 @@ sealed class CC { is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" + is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" is ApiDeleteChat -> if (notify != null) { "/_delete ${chatRef(type, id)} notify=${onOff(notify)}" } else { @@ -2164,6 +2190,7 @@ sealed class CC { is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" is APIConnectPlan -> "apiConnectPlan" is APIConnect -> "apiConnect" + is ApiConnectContactViaAddress -> "apiConnectContactViaAddress" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" is ApiListContacts -> "apiListContacts" @@ -3379,6 +3406,7 @@ sealed class CR { @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() + @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @@ -3517,6 +3545,7 @@ sealed class CR { is CRConnectionPlan -> "connectionPlan" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" + is SentInvitationToContact -> "sentInvitationToContact" is ContactAlreadyExists -> "contactAlreadyExists" is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted" is ContactDeleted -> "contactDeleted" @@ -3650,6 +3679,7 @@ sealed class CR { is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is SentConfirmation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, noDetails()) + is SentInvitationToContact -> withUser(user, json.encodeToString(contact)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact)) @@ -3785,6 +3815,7 @@ sealed class ContactAddressPlan { @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() @Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan() + @Serializable @SerialName("contactViaAddress") class ContactViaAddress(val contact: Contact): ContactAddressPlan() } @Serializable 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 4564a4a6e8..b7c5e66a68 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 @@ -150,7 +150,7 @@ fun ChatInfoView( val (verified, existingCode) = r chatModel.updateContact( ct.copy( - activeConn = ct.activeConn.copy( + activeConn = ct.activeConn?.copy( connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null ) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index 8de309fc8f..ecf7f10dd9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -57,7 +57,7 @@ fun CIRcvDecryptionError( if (cInfo is ChatInfo.Direct) { val modelCInfo = findModelChat(cInfo.id)?.chatInfo if (modelCInfo is ChatInfo.Direct) { - val modelContactStats = modelCInfo.contact.activeConn.connectionStats + val modelContactStats = modelCInfo.contact.activeConn?.connectionStats if (modelContactStats != null) { if (modelContactStats.ratchetSyncAllowed) { DecryptionErrorItemFixButton( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index bcabb7cfd4..13380a664b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.datetime.Clock +import java.net.URI @Composable fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { @@ -61,8 +62,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, - click = { directChatAction(chat.chatInfo, chatModel) }, - dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, + click = { directChatAction(chat.chatInfo.contact, chatModel) }, + dropdownMenuItems = { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) }, showMenu, stopped, selectedChat @@ -118,8 +119,11 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } -fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) { - withBGApi { openChat(chatInfo, chatModel) } +fun directChatAction(contact: Contact, chatModel: ChatModel) { + when { + contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close = null, openChat = true) + else -> withBGApi { openChat(ChatInfo.Direct(contact), chatModel) } + } } fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { @@ -192,15 +196,17 @@ suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { } @Composable -fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { - if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) - } else { - MarkUnreadChatAction(chat, chatModel, showMenu) +fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { + if (contact.activeConn != null) { + if (showMarkRead) { + MarkReadChatAction(chat, chatModel, showMenu) + } else { + MarkUnreadChatAction(chat, chatModel, showMenu) + } + ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) + ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ClearChatAction(chat, chatModel, showMenu) } - ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) - ClearChatAction(chat, chatModel, showMenu) DeleteContactAction(chat, chatModel, showMenu) } @@ -591,6 +597,63 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { ) } +fun askCurrentOrIncognitoProfileConnectContactViaAddress( + chatModel: ChatModel, + contact: Contact, + close: (() -> Unit)?, + openChat: Boolean +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + close?.invoke() + val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = false) + if (ok && openChat) { + openDirectChat(contact.contactId, chatModel) + } + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + close?.invoke() + val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = true) + if (ok && openChat) { + openDirectChat(contact.contactId, chatModel) + } + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +suspend fun connectContactViaAddress(chatModel: ChatModel, contactId: Long, incognito: Boolean): Boolean { + val contact = chatModel.controller.apiConnectContactViaAddress(incognito, contactId) + if (contact != null) { + chatModel.updateContact(contact) + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.connection_request_sent), + text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) + ) + return true + } + return false +} + fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), 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 4df62e12e3..7af4d2670a 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 @@ -146,11 +146,6 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf @Composable private fun OnboardingButtons(openNewChatSheet: () -> Unit) { Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) { - val uriHandler = LocalUriHandler.current - ConnectButton(generalGetString(MR.strings.chat_with_developers)) { - uriHandler.openVerifiedSimplexUri(simplexTeamUri) - } - Spacer(Modifier.height(DEFAULT_PADDING)) ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet) val color = MaterialTheme.colors.primaryVariant Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index d3413e2e08..af1f49a088 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -185,10 +185,14 @@ fun ChatPreviewView( } else { when (cInfo) { is ChatInfo.Direct -> - if (cInfo.contact.nextSendGrpInv) { - Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary) - } else if (!cInfo.ready && cInfo.contact.active) { - Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary) + if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) { + Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary) + } else if (!cInfo.ready && cInfo.contact.activeConn != null) { + if (cInfo.contact.nextSendGrpInv) { + Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary) + } else if (cInfo.contact.active) { + Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary) + } } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { @@ -215,7 +219,7 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { - if (cInfo.contact.active) { + if (cInfo.contact.active && cInfo.contact.activeConn != null) { val descr = contactNetworkStatus?.statusString when (contactNetworkStatus) { is NetworkStatus.Connected -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index b9b8ea54bc..e423a591da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -20,7 +20,7 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat) }, - click = { directChatAction(chat.chatInfo, chatModel) }, + click = { directChatAction(chat.chatInfo.contact, chatModel) }, stopped ) is ChatInfo.Group -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 2f52c2cacf..7629256fc3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -19,8 +19,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chatlist.openDirectChat -import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR @@ -171,6 +170,16 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) ) } + is ContactAddressPlan.ContactViaAddress -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress, incognito=$incognito") + val contact = connectionPlan.contactAddressPlan.contact + if (incognito != null) { + close?.invoke() + connectContactViaAddress(chatModel, contact.contactId, incognito) + } else { + askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close, openChat = false) + } + } } is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { GroupLinkPlan.Ok -> { 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 ff34934e41..fa2782531d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -289,6 +289,8 @@ Chat with the developers You have no chats No filtered chats + Tap to Connect + Connect with %1$s? No selected chat From fcdd8ce7c15a874e8ab40100105c266779b2c4d1 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 10 Nov 2023 19:49:53 +0800 Subject: [PATCH 041/174] windows: script for building the lib (#3340) * windows: script for building the lib * changes * change * change --- .github/workflows/build.yml | 19 ++++++-- apps/multiplatform/desktop/build.gradle.kts | 6 +-- .../kotlin/chat/simplex/desktop/Main.kt | 7 +-- scripts/desktop/build-lib-windows.sh | 47 +++++++++++++++++-- scripts/desktop/download-lib-windows.sh | 30 ------------ 5 files changed, 63 insertions(+), 46 deletions(-) delete mode 100644 scripts/desktop/download-lib-windows.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4301dcb3a..6687f47977 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -293,13 +293,26 @@ jobs: body: | ${{ steps.windows_build.outputs.bin_hash }} + - name: 'Setup MSYS2' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: msys2/setup-msys2@v2 + with: + msystem: ucrt64 + update: true + install: >- + git + perl + make + pacboy: >- + toolchain:p + cmake:p + - name: Windows build desktop id: windows_desktop_build if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - env: - SIMPLEX_CI_REPO_URL: ${{ secrets.SIMPLEX_CI_REPO_URL }} - shell: bash + shell: msys2 {0} run: | + export PATH=$PATH:/c/ghcup/bin scripts/desktop/build-lib-windows.sh cd apps/multiplatform ./gradlew packageMsi diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index ea808a32db..672b5f8710 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -141,9 +141,9 @@ cmake { val main by creating { cmakeLists.set(file("$cppPath/desktop/CMakeLists.txt")) targetMachines.addAll(compileMachineTargets.toSet()) - if (machines.host.name.contains("win")) { - cmakeArgs.add("-G MinGW Makefiles") - } + //if (machines.host.name.contains("win")) { + // cmakeArgs.add("-G MinGW Makefiles") + //} } } } diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index ff92b08ff0..787e41fd81 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -35,12 +35,7 @@ private fun initHaskell() { private fun windowsLoadRequiredLibs(libsTmpDir: File, vlcDir: File) { val mainLibs = arrayOf( - "libcrypto-3-x64.dll", - "mcfgthread-12.dll", - "libgcc_s_seh-1.dll", - "libstdc++-6.dll", - "libffi-8.dll", - "libgmp-10.dll", + "libcrypto-1_1-x64.dll", "libsimplex.dll", "libapp-lib.dll" ) diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index ef39ef8683..881e0aea2a 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -8,22 +8,61 @@ function readlink() { root_dir="$(dirname "$(dirname "$(readlink "$0")")")" OS=windows -ARCH="x86_64" -JOB_REPO=${1:-$SIMPLEX_CI_REPO_URL} - +ARCH=${1:-x86_64} if [ "$ARCH" == "aarch64" ]; then COMPOSE_ARCH=arm64 else COMPOSE_ARCH=x64 fi +BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* + +# IMPORTANT: in order to get a working build you should use x86_64 MinGW with make, cmake, gcc. +# 100% working MinGW is https://github.com/brechtsanders/winlibs_mingw/releases/download/13.1.0-16.0.5-11.0.0-ucrt-r5/winlibs-x86_64-posix-seh-gcc-13.1.0-mingw-w64ucrt-11.0.0-r5.zip +# Many other distributions I tested don't work in some cases or don't have required tools. +# Also, standalone Cmake installed globally via .msi package does not produce working library, you should use MinGW's Cmake. +# Example of export: +# export PATH=/c/MinGW/bin:/c/ghcup/bin:/c/Program\ Files/Amazon\ Corretto/jdk17.0.9_8/bin/:$PATH +# If you use Msys2, use UCRT64 (NOT Mingw64, because it will crash on launch because of non-posix threads), install these packages: +# pacman -S perl make mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-gcc +# and export path to ghcup/bin and java + cd $root_dir +mkdir dist-newstyle 2>/dev/null || true + +if [ ! -f dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll ]; then + cd dist-newstyle + curl https://www.openssl.org/source/openssl-1.1.1w.tar.gz -o openssl.tar.gz + $WINDIR\\System32\\tar.exe -xvzf openssl.tar.gz + cd openssl-1.1.1w + ./Configure mingw64 + make + cd ../../ +fi +openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') +rm -rf $BUILD_DIR 2>/dev/null || true +# Existence of this directory produces build error: cabal's bug +rm -rf dist-newstyle/src/direct-sq* 2>/dev/null || true +rm cabal.project.local 2>/dev/null || true +echo "ignore-project: False" >> cabal.project.local +echo "package direct-sqlcipher" >> cabal.project.local +echo " flags: +openssl" >> cabal.project.local +echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local +echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local +echo "package simplex-chat" >> cabal.project.local +echo " ghc-options: -shared -threaded -optl-L$openssl_windows_style_path -optl-lcrypto-1_1-x64 -o libsimplex.dll libsimplex.dll.def" >> cabal.project.local +# Very important! Without it the build fails on linking step since the linker can't find exported symbols. +# It looks like GHC bug because with such random path the build ends successfully +sed -i "s/ld.lld.exe/abracadabra.exe/" `ghc --print-libdir`/settings +cabal build lib:simplex-chat rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -scripts/desktop/download-lib-windows.sh $JOB_REPO +cp dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp libsimplex.dll apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ + scripts/desktop/prepare-vlc-windows.sh links_dir=apps/multiplatform/build/links diff --git a/scripts/desktop/download-lib-windows.sh b/scripts/desktop/download-lib-windows.sh deleted file mode 100644 index 7f2e3c3879..0000000000 --- a/scripts/desktop/download-lib-windows.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash - -set -e - -function readlink() { - echo "$(cd "$(dirname "$1")"; pwd -P)" -} - -if [ -z "${1}" ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{master,stable}" - exit 1 -fi - -job_repo=$1 -arch=x86_64 -root_dir="$(dirname "$(dirname "$(readlink "$0")")")" -output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-$arch/" - -mkdir -p "$output_dir" 2> /dev/null - -curl --location -o libsimplex.zip $job_repo/$arch-windows:lib:simplex-chat.$arch-linux/latest/download/1 && \ -$WINDIR\\System32\\tar.exe -xf libsimplex.zip && \ -mv libsimplex.dll "$output_dir" && \ -mv libcrypto*.dll "$output_dir" && \ -mv libffi*.dll "$output_dir" && \ -mv libgmp*.dll "$output_dir" && \ -mv mcfgthread*.dll "$output_dir" && \ -mv libgcc_s_seh*.dll "$output_dir" && \ -mv libstdc++*.dll "$output_dir" && \ -rm libsimplex.zip From f6480869344b177915a458eb3ddf4c73ae121909 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 10 Nov 2023 19:50:31 +0800 Subject: [PATCH 042/174] windows: upgrade UUID (#3341) --- apps/multiplatform/desktop/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 672b5f8710..a784d5c5fe 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -68,6 +68,7 @@ compose { perUserInstall = false dirChooser = true shortcut = true + upgradeUuid = "CC9EFBC8-AFFF-40D8-BB69-FCD7CE99EFB9" } macOS { packageName = "SimpleX" From 8d891005d957a8b534b6c8ef1ac578eb8ff75e28 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Nov 2023 00:09:01 +0800 Subject: [PATCH 043/174] ui: disable expanding one item (#3344) * ui: disable expanding one item * better * when --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../common/views/chat/item/ChatItemView.kt | 134 ++++++++++-------- 2 files changed, 75 insertions(+), 61 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1f7cf1e371..4fb93ffe22 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -767,7 +767,7 @@ struct ChatView: View { } else if ci.isDeletedContent { menu.append(viewInfoUIAction(ci)) menu.append(deleteUIAction(ci)) - } else if ci.mergeCategory != nil { + } else if ci.mergeCategory != nil && ((range?.count ?? 0) > 1 || revealed) { menu.append(revealed ? shrinkUIAction() : expandUIAction()) } return menu 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 d2dcfcaeec..fdf361bf69 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 @@ -177,77 +177,91 @@ fun ChatItemView( @Composable fun MsgContentItemDropdownMenu() { val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) - DefaultDropdownMenu(showMenu) { - if (cItem.content.msgContent != null) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + when { + cItem.content.msgContent != null -> { + DefaultDropdownMenu(showMenu) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() + } + if (cItem.meta.itemDeleted == null && !live) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + val clipboard = LocalClipboardManager.current + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + val fileSource = getLoadedFileSource(cItem.file) + when { + fileSource != null -> shareFile(cItem.text, fileSource) + else -> clipboard.shareText(cItem.content.text) } showMenu.value = false }) - } - val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) - } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { - SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) showMenu.value = false }) + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + showMenu.value = false + }) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (revealed.value) { + HideItemAction(revealed, showMenu) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (revealed.value) { - HideItemAction(revealed, showMenu) - } - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - if (!(live && cItem.meta.isLive)) { + } + cItem.meta.itemDeleted != null -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + HideItemAction(revealed, showMenu) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu) + } else if (range != null) { + ExpandItemAction(revealed, showMenu) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } + cItem.isDeletedContent -> { + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } - } else if (cItem.meta.itemDeleted != null) { - if (revealed.value) { - HideItemAction(revealed, showMenu) - } else if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu) - } else if (range != null) { - ExpandItemAction(revealed, showMenu) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - } else if (cItem.isDeletedContent) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - } else if (cItem.mergeCategory != null) { - if (revealed.value) { - ShrinkItemAction(revealed, showMenu) - } else { - ExpandItemAction(revealed, showMenu) + } + cItem.mergeCategory != null && ((range?.count() ?: 0) > 1 || revealed.value) -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + ShrinkItemAction(revealed, showMenu) + } else { + ExpandItemAction(revealed, showMenu) + } } } + else -> { + showMenu.value = false + } } } From e7d6ed66da85fd3cd72469f880f902e6006b4eda Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Nov 2023 00:09:19 +0800 Subject: [PATCH 044/174] android: fix crash when playing recorded voice message (#3325) * android: fix crash when playing recorded voice message * better --- .../chat/simplex/common/platform/RecAndPlay.android.kt | 8 +++++--- .../kotlin/chat/simplex/common/views/chat/ComposeView.kt | 6 +++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index b89719b2e1..f99dea77ca 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -284,9 +284,11 @@ actual object AudioPlayer: AudioPlayerInterface { kotlin.runCatching { helperPlayer.setDataSource(unencryptedFilePath) helperPlayer.prepare() - helperPlayer.start() - helperPlayer.stop() - res = helperPlayer.duration + if (helperPlayer.duration <= 0) { + Log.e(TAG, "Duration of audio is incorrect: ${helperPlayer.duration}") + } else { + res = helperPlayer.duration + } helperPlayer.reset() } return res diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 59e43557e7..959ded42bf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -776,7 +776,11 @@ fun ComposeView( .collect { when(it) { is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true) + is RecordingState.Finished -> if (it.durationMs > 300) { + onAudioAdded(it.filePath, it.durationMs, true) + } else { + cancelVoice() + } is RecordingState.NotStarted -> {} } } From 02225df274a2ab332681ebadf87ccbf28396c845 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 10 Nov 2023 16:10:10 +0000 Subject: [PATCH 045/174] core: remote control command/response encryption and signing inside TLS (#3339) * core: remote control command/response encryption inside TLS (except files, no signing) * sign/verify * update simplexmq * fix lazy * remove RSNone --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Remote.hs | 22 ++-- src/Simplex/Chat/Remote/Protocol.hs | 188 +++++++++++++++++++++------- src/Simplex/Chat/Remote/Types.hs | 28 ++++- stack.yaml | 2 +- 6 files changed, 187 insertions(+), 57 deletions(-) diff --git a/cabal.project b/cabal.project index ea6129f3f6..8255bb7938 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: bd06b47a9df13506ee77251868a5a1d1e7cadafe + tag: 6a2e6b040ec8566de2f4cf97bae6700a6a5cbeda source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d9033088a8..26d4dbbeae 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."bd06b47a9df13506ee77251868a5a1d1e7cadafe" = "1x6hy3awxf10l5ai82p3fhsrv1flc24gxsw9jl1b0cl7iypxhmsp"; + "https://github.com/simplex-chat/simplexmq.git"."6a2e6b040ec8566de2f4cf97bae6700a6a5cbeda" = "0diwdkwxxrly01ag7aygaa86ycwz13q2majvn48yg495zvqkrp7n"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index a5ddc3439e..53b63d72b1 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -60,7 +60,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (TLS, closeConnection) +import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) @@ -178,7 +178,7 @@ startRemoteHost' rh_ = do atomically $ writeTMVar currentKey rhKey' disconnected <- toIO $ onDisconnected remoteHostId httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls - let rhClient = mkRemoteHostClient httpClient sessionKeys storePath hostInfo + rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient withRemoteHostSession rhKey' $ \case RHSessionConfirmed _ RHPendingSession {} -> Right ((), RHSessionConnected {tls, rhClient, pollAction, storePath}) @@ -358,8 +358,8 @@ connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = encryptFiles <- chatReadVar encryptLocalFiles pure HostAppInfo {appVersion, deviceName = hostDeviceName, encoding = localEncoding, encryptFiles} -handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> CtrlSessKeys -> TBQueue ChatResponse -> HTTP2Request -> m () -handleRemoteCommand execChatCommand _sessionKeys remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do +handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> RemoteCrypto -> TBQueue ChatResponse -> HTTP2Request -> m () +handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftRC (tryRemoteError parseRequest) >>= \case Right (getNext, rc) -> do @@ -370,8 +370,8 @@ handleRemoteCommand execChatCommand _sessionKeys remoteOutputQ HTTP2Request {req where parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand) parseRequest = do - (header, getNext) <- parseHTTP2Body request reqBody - (getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecodeStrict' header) + (header, getNext) <- parseDecryptHTTP2Body encryption request reqBody + (getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecode header) replyError = reply . RRChatResponse . CRChatCmdError Nothing processCommand :: User -> GetChunk -> RemoteCommand -> m () processCommand user getNext = \case @@ -382,9 +382,10 @@ handleRemoteCommand execChatCommand _sessionKeys remoteOutputQ HTTP2Request {req reply :: RemoteResponse -> m () reply = (`replyWith` \_ -> pure ()) replyWith :: Respond m - replyWith rr attach = + replyWith rr attach = do + resp <- liftRC $ encryptEncodeHTTP2Body encryption $ J.encode rr liftIO . sendResponse . responseStreaming N.status200 [] $ \send flush -> do - send $ sizePrefixedEncode rr + send resp attach send flush @@ -482,11 +483,12 @@ verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do _ -> throwError $ ChatErrorRemoteCtrl RCEBadState let verified = sameVerificationCode sessCode' sessionCode liftIO $ confirmCtrlSession client verified - unless verified $ throwError $ ChatErrorRemoteCtrl RCEBadVerificationCode + unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- takeRCStep vars rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO - http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand sessionKeys remoteOutputQ + encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls + http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand encryption remoteOutputQ void . forkIO $ monitor http2Server withRemoteCtrlSession $ \case RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ}) diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 62db487cbd..eae71d09c7 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -6,19 +6,26 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TupleSections #-} module Simplex.Chat.Remote.Protocol where import Control.Monad import Control.Monad.Except +import Control.Monad.Reader +import Crypto.Hash (SHA512) +import qualified Crypto.Hash as CH import Data.Aeson ((.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Key as JK import qualified Data.Aeson.KeyMap as JM import Data.Aeson.TH (deriveJSON) import qualified Data.Aeson.Types as JT +import qualified Data.ByteArray as BA import Data.ByteString (ByteString) -import Data.ByteString.Builder (Builder, lazyByteString, word32BE) +import qualified Data.ByteString as B +import Data.ByteString.Builder (Builder, byteString, lazyByteString) import qualified Data.ByteString.Lazy as LB import Data.String (fromString) import Data.Text (Text) @@ -26,19 +33,25 @@ import Data.Text.Encoding (decodeUtf8) import Data.Word (Word32) import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H -import Network.Transport.Internal (decodeWord32) +import Network.Transport.Internal (decodeWord32, encodeWord32) import Simplex.Chat.Controller import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Messaging.Agent.Client (agentDRG) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) +import Simplex.Messaging.Crypto.Lazy (LazyByteString) +import Simplex.Messaging.Encoding import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import Simplex.Messaging.Transport.Buffer (getBuffered) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) -import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow) -import Simplex.RemoteControl.Types (HostSessKeys) +import Simplex.Messaging.Util (liftEitherError, liftEitherWith, liftError, tshow) +import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode) +import Simplex.RemoteControl.Client (xrcpBlockSize) +import qualified Simplex.RemoteControl.Client as RC import System.FilePath (takeFileName, ()) import UnliftIO @@ -64,16 +77,29 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) -- * Client side / desktop -mkRemoteHostClient :: HTTP2Client -> HostSessKeys -> FilePath -> HostAppInfo -> RemoteHostClient -mkRemoteHostClient httpClient sessionKeys storePath HostAppInfo {encoding, deviceName, encryptFiles} = - RemoteHostClient - { hostEncoding = encoding, - hostDeviceName = deviceName, - httpClient, - encryptHostFiles = encryptFiles, - sessionKeys, - storePath - } +mkRemoteHostClient :: ChatMonad m => HTTP2Client -> HostSessKeys -> SessionCode -> FilePath -> HostAppInfo -> m RemoteHostClient +mkRemoteHostClient httpClient sessionKeys sessionCode storePath HostAppInfo {encoding, deviceName, encryptFiles} = do + drg <- asks $ agentDRG . smpAgent + counter <- newTVarIO 1 + let HostSessKeys {hybridKey, idPrivKey, sessPrivKey} = sessionKeys + signatures = RSSign {idPrivKey, sessPrivKey} + encryption = RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} + pure + RemoteHostClient + { hostEncoding = encoding, + hostDeviceName = deviceName, + httpClient, + encryption, + encryptHostFiles = encryptFiles, + storePath + } + +mkCtrlRemoteCrypto :: ChatMonad m => CtrlSessKeys -> SessionCode -> m RemoteCrypto +mkCtrlRemoteCrypto CtrlSessKeys {hybridKey, idPubKey, sessPubKey} sessionCode = do + drg <- asks $ agentDRG . smpAgent + counter <- newTVarIO 1 + let signatures = RSVerify {idPubKey, sessPubKey} + pure RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient @@ -81,28 +107,28 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client -- ** Commands remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse -remoteSend RemoteHostClient {httpClient, hostEncoding} cmd = - sendRemoteCommand' httpClient hostEncoding Nothing RCSend {command = decodeUtf8 cmd} >>= \case +remoteSend c cmd = + sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case RRChatResponse cr -> pure cr r -> badResponse r remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) -remoteRecv RemoteHostClient {httpClient, hostEncoding} ms = - sendRemoteCommand' httpClient hostEncoding Nothing RCRecv {wait = ms} >>= \case +remoteRecv c ms = + sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case RRChatEvent cr_ -> pure cr_ r -> badResponse r remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath -remoteStoreFile RemoteHostClient {httpClient, hostEncoding} localPath fileName = do +remoteStoreFile c localPath fileName = do (fileSize, fileDigest) <- getFileInfo localPath - let send h = sendRemoteCommand' httpClient hostEncoding (Just (h, fileSize)) RCStoreFile {fileName, fileSize, fileDigest} + let send h = sendRemoteCommand' c (Just (h, fileSize)) RCStoreFile {fileName, fileSize, fileDigest} withFile localPath ReadMode send >>= \case RRFileStored {filePath = filePath'} -> pure filePath' r -> badResponse r remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () -remoteGetFile RemoteHostClient {httpClient, hostEncoding} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = - sendRemoteCommand httpClient hostEncoding Nothing RCGetFile {file = rf} >>= \case +remoteGetFile c destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = + sendRemoteCommand c Nothing RCGetFile {file = rf} >>= \case (getChunk, RRFile {fileSize, fileDigest}) -> do -- TODO we could optimize by checking size and hash before receiving the file let localPath = destDir takeFileName filePath @@ -110,18 +136,19 @@ remoteGetFile RemoteHostClient {httpClient, hostEncoding} destDir rf@RemoteFile (_, r) -> badResponse r -- TODO validate there is no attachment -sendRemoteCommand' :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse -sendRemoteCommand' http remoteEncoding attachment_ rc = snd <$> sendRemoteCommand http remoteEncoding attachment_ rc +sendRemoteCommand' :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse +sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc -sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) -sendRemoteCommand http remoteEncoding attachment_ rc = do - HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect http httpRequest Nothing - (header, getNext) <- parseHTTP2Body response respBody - rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecodeStrict header >>= JT.parseEither J.parseJSON . convertJSON remoteEncoding localEncoding +sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) +sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} attachment_ cmd = do + req <- httpRequest <$> encryptEncodeHTTP2Body encryption (J.encode cmd) + HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing + (header, getNext) <- parseDecryptHTTP2Body encryption response respBody + rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecode header >>= JT.parseEither J.parseJSON . convertJSON hostEncoding localEncoding pure (getNext, rr) where - httpRequest = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do - send $ sizePrefixedEncode rc + httpRequest cmdBld = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do + send cmdBld case attachment_ of Nothing -> pure () Just (h, sz) -> hSendFile h send sz @@ -175,18 +202,93 @@ owsf2tagged = fst . convert pattern OwsfTag :: (JK.Key, J.Value) pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) --- | Convert a command or a response into 'Builder'. -sizePrefixedEncode :: J.ToJSON a => a -> Builder -sizePrefixedEncode value = word32BE (fromIntegral $ LB.length json) <> lazyByteString json - where - json = J.encode value +-- ``` +-- commandBody = encBody sessSignature idSignature (attachment / noAttachment) +-- responseBody = encBody attachment; should match counter in the command +-- encBody = nonce encLength32 encrypted(tlsunique counter body) +-- attachment = %x01 nonce encLength32 encrypted(attachment) +-- noAttachment = %x00 +-- tlsunique = length 1*OCTET +-- nonce = 24*24 OCTET +-- counter = 8*8 OCTET ; int64 +-- encLength32 = 4*4 OCTET ; uint32, includes authTag +-- ``` --- | Parse HTTP request or response to a size-prefixed chunk and a function to read more. -parseHTTP2Body :: HTTP2BodyChunk a => a -> HTTP2Body -> ExceptT RemoteProtocolError IO (ByteString, Int -> IO ByteString) -parseHTTP2Body hr HTTP2Body {bodyBuffer} = do - rSize <- liftIO $ decodeWord32 <$> getNext 4 - when (rSize > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize - r <- liftIO $ getNext $ fromIntegral rSize - pure (r, getNext) +-- See https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2023-10-25-remote-control.md for encoding + +encryptEncodeHTTP2Body :: RemoteCrypto -> LazyByteString -> ExceptT RemoteProtocolError IO Builder +encryptEncodeHTTP2Body RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} s = do + corrId <- atomically $ stateTVar counter $ \c -> (c, c + 1) + let pfx = smpEncode (sessionCode, corrId) + (nonce, ct) <- liftError PRERemoteControl $ RC.rcEncryptBody drg hybridKey $ LB.fromStrict pfx <> s + let ctLen = encodeWord32 (fromIntegral $ LB.length ct) + signed = LB.fromStrict (smpEncode nonce <> ctLen) <> ct + sigs <- bodySignatures signed + pure $ lazyByteString signed <> sigs where + bodySignatures :: LazyByteString -> ExceptT RemoteProtocolError IO Builder + bodySignatures signed = case signatures of + RSSign {idPrivKey, sessPrivKey} -> do + let hc = CH.hashUpdates (CH.hashInit @SHA512) (LB.toChunks signed) + ssig = sign sessPrivKey hc + idsig = sign idPrivKey $ CH.hashUpdate hc ssig + pure $ byteString $ smpEncode (ssig, idsig) + _ -> pure mempty + sign :: C.PrivateKeyEd25519 -> CH.Context SHA512 -> ByteString + sign k = C.signatureBytes . C.sign' k . BA.convert . CH.hashFinalize + +-- | Parse and decrypt HTTP2 request/response +parseDecryptHTTP2Body :: HTTP2BodyChunk a => RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (LazyByteString, Int -> IO ByteString) +parseDecryptHTTP2Body RemoteCrypto {hybridKey, sessionCode, signatures} hr HTTP2Body {bodyBuffer} = do + (nonce, ct) <- getBody + s <- liftError PRERemoteControl $ RC.rcDecryptBody hybridKey nonce ct + (,getNext) <$> parseBody s + where + getBody :: ExceptT RemoteProtocolError IO (C.CbNonce, LazyByteString) + getBody = do + nonceStr <- liftIO $ getNext 24 + nonce <- liftEitherWith RPEInvalidBody $ smpDecode nonceStr + ctLenStr <- liftIO $ getNext 4 + let ctLen = decodeWord32 ctLenStr + when (ctLen > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize + chunks <- liftIO $ getLazy $ fromIntegral ctLen + let hc = CH.hashUpdates (CH.hashInit @SHA512) [nonceStr, ctLenStr] + hc' = CH.hashUpdates hc chunks + verifySignatures hc' + pure (nonce, LB.fromChunks chunks) + getLazy :: Int -> IO [ByteString] + getLazy 0 = pure [] + getLazy n = do + let sz = min n xrcpBlockSize + bs <- getNext sz + let n' = if B.length bs < sz then 0 else max 0 (n - xrcpBlockSize) + (bs :) <$> getLazy n' + verifySignatures :: CH.Context SHA512 -> ExceptT RemoteProtocolError IO () + verifySignatures hc = case signatures of + RSVerify {sessPubKey, idPubKey} -> do + ssig <- getSig + idsig <- getSig + verifySig sessPubKey ssig hc + verifySig idPubKey idsig $ CH.hashUpdate hc $ C.signatureBytes ssig + _ -> pure () + where + getSig = do + len <- liftIO $ B.head <$> getNext 1 + liftEitherError RPEInvalidBody $ C.decodeSignature <$> getNext (fromIntegral len) + verifySig key sig hc' = do + let signed = BA.convert $ CH.hashFinalize hc' + unless (C.verify' key sig signed) $ throwError $ PRERemoteControl RCECtrlAuth + parseBody :: LazyByteString -> ExceptT RemoteProtocolError IO LazyByteString + parseBody s = case LB.uncons s of + Nothing -> throwError $ RPEInvalidBody "empty body" + Just (scLen, rest) -> do + (sessCode', rest') <- takeBytes (fromIntegral scLen) rest + unless (sessCode' == sessionCode) $ throwError PRESessionCode + (_corrId, s') <- takeBytes 8 rest' + pure s' + where + takeBytes n s' = do + let (bs, rest) = LB.splitAt n s' + unless (LB.length bs == n) $ throwError PRESessionCode + pure (LB.toStrict bs, rest) getNext sz = getBuffered bodyBuffer sz Nothing $ getBodyChunk hr diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 638707563f..3177ae3ef6 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -10,11 +10,16 @@ module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) +import Control.Concurrent.STM (TVar) import Control.Exception (Exception) +import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as J +import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Remote.AppVersion +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.RemoteControl.Client @@ -26,11 +31,29 @@ data RemoteHostClient = RemoteHostClient { hostEncoding :: PlatformEncoding, hostDeviceName :: Text, httpClient :: HTTP2Client, - sessionKeys :: HostSessKeys, + encryption :: RemoteCrypto, encryptHostFiles :: Bool, storePath :: FilePath } +data RemoteCrypto = RemoteCrypto + { drg :: TVar ChaChaDRG, + counter :: TVar Int64, + sessionCode :: ByteString, + hybridKey :: KEMHybridSecret, + signatures :: RemoteSignatures + } + +data RemoteSignatures + = RSSign + { idPrivKey :: C.PrivateKeyEd25519, + sessPrivKey :: C.PrivateKeyEd25519 + } + | RSVerify + { idPubKey :: C.PublicKeyEd25519, + sessPubKey :: C.PublicKeyEd25519 + } + data RHPendingSession = RHPendingSession { rhKey :: RHKey, rchClient :: RCHostClient, @@ -49,6 +72,8 @@ data RemoteProtocolError RPEInvalidSize | -- | failed to parse RemoteCommand or RemoteResponse RPEInvalidJSON {invalidJSON :: String} + | RPEInvalidBody {invalidBody :: String} + | PRESessionCode | RPEIncompatibleEncoding | RPEUnexpectedFile | RPENoFile @@ -58,6 +83,7 @@ data RemoteProtocolError RPEUnexpectedResponse {response :: Text} | -- | A file already exists in the destination position RPEStoredFileExists + | PRERemoteControl {rcError :: RCErrorType} | RPEHTTP2 {http2Error :: Text} | RPEException {someException :: Text} deriving (Show, Exception) diff --git a/stack.yaml b/stack.yaml index c90140963a..11920f5317 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: bd06b47a9df13506ee77251868a5a1d1e7cadafe + commit: 6a2e6b040ec8566de2f4cf97bae6700a6a5cbeda - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From e17e6adefbff0466f6feffcc3b80d3b22ac26a9b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 10 Nov 2023 16:31:59 +0000 Subject: [PATCH 046/174] ui: translations (#3343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (French) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Italian) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Japanese) Currently translated at 98.6% (1233 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ja/ * Translated using Weblate (Japanese) Currently translated at 99.2% (1376 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Arabic) Currently translated at 99.7% (1383 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (German) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Czech) Currently translated at 98.0% (1360 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Bulgarian) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/bg/ * Translated using Weblate (Bulgarian) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Italian) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Italian) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Czech) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Russian) Currently translated at 99.5% (1381 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Czech) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Polish) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Russian) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1250 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (1387 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Czech) Currently translated at 99.9% (1249 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/ * Translated using Weblate (Arabic) Currently translated at 99.7% (1383 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Finnish) Currently translated at 99.2% (1376 of 1387 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fi/ * Translated using Weblate (Finnish) Currently translated at 98.9% (1237 of 1250 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fi/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * corrections * correction * fix android translations * ios: import/export localizations --------- Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: Random Co-authored-by: M1K4 Co-authored-by: a4318 Co-authored-by: jonnysemon Co-authored-by: mlanp Co-authored-by: Jan Čejka Co-authored-by: elgratea Co-authored-by: ItaiShek Co-authored-by: Eric Co-authored-by: No name Co-authored-by: zenobit Co-authored-by: pazengaz Co-authored-by: B.O.S.S Co-authored-by: Shamil Bikineyev Co-authored-by: sith-on-mars Co-authored-by: Jiri Grönroos Co-authored-by: Hosted Weblate --- .../bg.xcloc/Localized Contents/bg.xliff | 307 ++++++++++++--- .../cs.xcloc/Localized Contents/cs.xliff | 332 +++++++++++++--- .../de.xcloc/Localized Contents/de.xliff | 319 ++++++++++++--- .../en.xcloc/Localized Contents/en.xliff | 369 +++++++++++++++--- .../es.xcloc/Localized Contents/es.xliff | 329 +++++++++++++--- .../fi.xcloc/Localized Contents/fi.xliff | 304 ++++++++++++--- .../fr.xcloc/Localized Contents/fr.xliff | 309 ++++++++++++--- .../it.xcloc/Localized Contents/it.xliff | 315 ++++++++++++--- .../ja.xcloc/Localized Contents/ja.xliff | 314 ++++++++++++--- .../nl.xcloc/Localized Contents/nl.xliff | 311 ++++++++++++--- .../pl.xcloc/Localized Contents/pl.xliff | 307 ++++++++++++--- .../ru.xcloc/Localized Contents/ru.xliff | 301 +++++++++++--- .../th.xcloc/Localized Contents/th.xliff | 299 +++++++++++--- .../uk.xcloc/Localized Contents/uk.xliff | 301 +++++++++++--- .../Localized Contents/zh-Hans.xliff | 318 ++++++++++++--- apps/ios/bg.lproj/Localizable.strings | 57 +-- apps/ios/cs.lproj/Localizable.strings | 122 ++++-- apps/ios/de.lproj/Localizable.strings | 83 ++-- apps/ios/es.lproj/Localizable.strings | 97 +++-- apps/ios/fi.lproj/Localizable.strings | 44 +-- apps/ios/fr.lproj/Localizable.strings | 59 +-- apps/ios/it.lproj/Localizable.strings | 65 ++- apps/ios/ja.lproj/Localizable.strings | 74 ++-- apps/ios/nl.lproj/Localizable.strings | 61 ++- apps/ios/pl.lproj/Localizable.strings | 57 +-- apps/ios/ru.lproj/Localizable.strings | 39 +- apps/ios/th.lproj/Localizable.strings | 33 +- apps/ios/uk.lproj/Localizable.strings | 39 +- apps/ios/zh-Hans.lproj/Localizable.strings | 80 ++-- .../commonMain/resources/MR/ar/strings.xml | 71 ++-- .../commonMain/resources/MR/base/strings.xml | 2 +- .../commonMain/resources/MR/bg/strings.xml | 7 +- .../commonMain/resources/MR/cs/strings.xml | 62 +-- .../commonMain/resources/MR/de/strings.xml | 19 +- .../commonMain/resources/MR/es/strings.xml | 35 +- .../commonMain/resources/MR/fi/strings.xml | 7 +- .../commonMain/resources/MR/fr/strings.xml | 9 +- .../commonMain/resources/MR/it/strings.xml | 15 +- .../commonMain/resources/MR/iw/strings.xml | 36 +- .../commonMain/resources/MR/ja/strings.xml | 16 +- .../commonMain/resources/MR/ko/strings.xml | 1 - .../commonMain/resources/MR/ml/strings.xml | 2 - .../commonMain/resources/MR/nl/strings.xml | 11 +- .../commonMain/resources/MR/pl/strings.xml | 7 +- .../resources/MR/pt-rBR/strings.xml | 2 - .../commonMain/resources/MR/pt/strings.xml | 1 - .../commonMain/resources/MR/ru/strings.xml | 8 +- .../commonMain/resources/MR/th/strings.xml | 2 - .../commonMain/resources/MR/tr/strings.xml | 2 - .../commonMain/resources/MR/uk/strings.xml | 2 - .../resources/MR/zh-rCN/strings.xml | 9 +- .../resources/MR/zh-rTW/strings.xml | 2 - 52 files changed, 4548 insertions(+), 1425 deletions(-) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 2bf06561a2..2b8613f929 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ и %@ са свързани @@ -97,6 +101,10 @@ %1$@ в %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ е свързан! @@ -122,6 +130,10 @@ %@ иска да се свърже! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ и %lld други членове са свързани @@ -187,11 +199,27 @@ %lld файл(а) с общ размер от %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld членове No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld минути @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Всички съобщения ще бъдат изтрити - това не може да бъде отменено! Съобщенията ще бъдат изтрити САМО за вас. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Всички ваши контакти ще останат свързани. @@ -694,6 +730,14 @@ Вече сте свързани? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Винаги използвай реле @@ -829,6 +873,18 @@ По-добри съобщения No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. И вие, и вашият контакт можете да добавяте реакции към съобщението. @@ -1090,24 +1146,27 @@ Свързване server test step - - Connect directly - Свързване директно - No comment provided by engineer. - Connect incognito Свързване инкогнито No comment provided by engineer. - - Connect via contact link - Свързване чрез линк на контакта + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Свързване чрез групов линк? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Свързване чрез еднократен линк за връзка No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Свързване със сървъра… @@ -1170,11 +1233,6 @@ Контактът вече съществува No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Контактът и всички съобщения ще бъдат изтрити - това не може да бъде отменено! - No comment provided by engineer. - Contact hidden: Контактът е скрит: @@ -1225,6 +1283,10 @@ Версия на ядрото: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Създай @@ -1245,6 +1307,10 @@ Създай файл server test step + + Create group + No comment provided by engineer. + Create group link Създай групов линк @@ -1265,6 +1331,10 @@ Създай линк за еднократна покана No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Създай опашка @@ -1423,6 +1493,10 @@ Изтрий chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Изтрий контакт @@ -1448,6 +1522,10 @@ Изтрий всички файлове No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Изтрий архив @@ -1478,9 +1556,9 @@ Изтрий контакт No comment provided by engineer. - - Delete contact? - Изтрий контакт? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Открийте и се присъединете към групи No comment provided by engineer. - - Display name - Показвано Име - No comment provided by engineer. - - - Display name: - Показвано име: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. НЕ използвайте SimpleX за спешни повиквания. @@ -1908,6 +1976,10 @@ Въведи правилна парола. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Въведи парола… @@ -1933,6 +2005,10 @@ Въведи съобщение при посрещане…(незадължително) placeholder + + Enter your name… + No comment provided by engineer. + Error Грешка при свързване със сървъра @@ -1990,6 +2066,7 @@ Error creating member contact + Грешка при създаване на контакт с член No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Грешка при изпращане на съобщение за покана за контакт No comment provided by engineer. @@ -2206,6 +2284,10 @@ Изход без запазване No comment provided by engineer. + + Expand + chat item action + Export database Експортирай база данни @@ -2351,6 +2433,10 @@ Пълно име: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Напълно преработено - работи във фонов режим! @@ -2371,6 +2457,14 @@ Група No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Показвано име на групата @@ -2718,6 +2812,10 @@ Невалиден линк за връзка No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Невалиден адрес на сървъра! @@ -2809,11 +2907,24 @@ Влез в групата No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Влез инкогнито No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Присъединяване към групата @@ -3029,6 +3140,10 @@ Съобщения и файлове No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Архивът на базата данни се мигрира… @@ -3360,6 +3475,7 @@ Open + Отвори No comment provided by engineer. @@ -3377,6 +3493,10 @@ Отвори конзолата authentication reason + + Open group + No comment provided by engineer. + Open user profiles Отвори потребителските профили @@ -3392,11 +3512,6 @@ Отваряне на база данни… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Отварянето на линка в браузъра може да намали поверителността и сигурността на връзката. Несигурните SimpleX линкове ще бъдат червени. - No comment provided by engineer. - PING count PING бройка @@ -3587,6 +3702,14 @@ Профилно изображение No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Профилна парола @@ -3832,6 +3955,14 @@ Предоговори криптирането? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Отговори @@ -4099,6 +4230,7 @@ Send direct message to connect + Изпрати лично съобщение за свързване No comment provided by engineer. @@ -4546,6 +4678,10 @@ Докосни бутона No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Докосни за активиране на профил. @@ -4643,11 +4779,6 @@ It can happen because of some bug or when the connection is compromised.Криптирането работи и новото споразумение за криптиране не е необходимо. Това може да доведе до грешки при свързване! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Групата е напълно децентрализирана – видима е само за членовете. - No comment provided by engineer. - The hash of the previous message is different. Хешът на предишното съобщение е различен. @@ -4743,6 +4874,14 @@ It can happen because of some bug or when the connection is compromised.Тази група вече не съществува. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Тази настройка се прилага за съобщения в текущия ви профил **%@**. @@ -4840,6 +4979,18 @@ You will be prompted to complete authentication before this feature is enabled.< Не може да се запише гласово съобщение No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Неочаквана грешка: %@ @@ -5187,6 +5338,35 @@ To connect, please ask your contact to create another connection link and check Вече сте вече свързани с %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Вие сте свързани към сървъра, използван за получаване на съобщения от този контакт. @@ -5282,6 +5462,15 @@ To connect, please ask your contact to create another connection link and check Не можахте да бъдете потвърдени; Моля, опитайте отново. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Нямате чатове @@ -5332,6 +5521,10 @@ To connect, please ask your contact to create another connection link and check Ще бъдете свързани с групата, когато устройството на домакина на групата е онлайн, моля, изчакайте или проверете по-късно! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Ще бъдете свързани, когато заявката ви за връзка бъде приета, моля, изчакайте или проверете по-късно! @@ -5347,9 +5540,8 @@ To connect, please ask your contact to create another connection link and check Ще трябва да се идентифицирате, когато стартирате или възобновите приложението след 30 секунди във фонов режим. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Ще се присъедините към групата, към която този линк препраща, и ще се свържете с нейните членове. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ To connect, please ask your contact to create another connection link and check Вашата чат база данни не е криптирана - задайте парола, за да я криптирате. No comment provided by engineer. - - Your chat profile will be sent to group members - Вашият чат профил ще бъде изпратен на членовете на групата - No comment provided by engineer. - Your chat profiles Вашите чат профили @@ -5476,6 +5663,10 @@ You can change it in Settings. Вашата поверителност No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Вашият профил **%@** ще бъде споделен. @@ -5568,6 +5759,10 @@ SimpleX сървърите не могат да видят вашия профи винаги pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) аудио разговор (не е e2e криптиран) @@ -5583,6 +5778,10 @@ SimpleX сървърите не могат да видят вашия профи лош хеш на съобщението integrity error chat item + + blocked + No comment provided by engineer. + bold удебелен @@ -5655,6 +5854,7 @@ SimpleX сървърите не могат да видят вашия профи connected directly + свързан директно rcv group event chat item @@ -5752,6 +5952,10 @@ SimpleX сървърите не могат да видят вашия профи изтрит deleted chat item + + deleted contact + rcv direct event chat item + deleted group групата изтрита @@ -6036,7 +6240,8 @@ SimpleX сървърите не могат да видят вашия профи off изключено enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ SimpleX сървърите не могат да видят вашия профи включено group pref value - - or chat with the developers - или пишете на разработчиците - No comment provided by engineer. - owner собственик @@ -6120,6 +6320,7 @@ SimpleX сървърите не могат да видят вашия профи send direct message + изпрати лично съобщение No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index bbfd95e6bf..663dcb022a 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ a %@ připojen @@ -97,6 +101,10 @@ %1$@ na %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ je připojen! @@ -122,6 +130,10 @@ %@ se chce připojit! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ a %lld ostatní členové připojeni @@ -187,11 +199,27 @@ %lld soubor(y) s celkovou velikostí %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld členové No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minut @@ -199,6 +227,7 @@ %lld new interface languages + %d nové jazyky rozhraní No comment provided by engineer. @@ -335,6 +364,9 @@ - connect to [directory service](simplex:/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) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - připojit k [adresářová služba](simplex:/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.cibule) (BETA)! +- doručenky (až 20 členů). +- Rychlejší a stabilnější. No comment provided by engineer. @@ -360,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -585,6 +621,10 @@ Všechny zprávy budou smazány – tuto akci nelze vrátit zpět! Zprávy budou smazány POUZE pro vás. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Všechny vaše kontakty zůstanou připojeny. @@ -690,6 +730,14 @@ Již připojeno? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Spojení přes relé @@ -712,6 +760,7 @@ App encrypts new local files (except videos). + Aplikace šifruje nové místní soubory (s výjimkou videí). No comment provided by engineer. @@ -824,6 +873,18 @@ Lepší zprávy No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Vy i váš kontakt můžete přidávat reakce na zprávy. @@ -851,6 +912,7 @@ Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Bulharský, finský, thajský a ukrajinský - díky uživatelům a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. @@ -1084,24 +1146,27 @@ Připojit server test step - - Connect directly - Připojit přímo - No comment provided by engineer. - Connect incognito Spojit se inkognito No comment provided by engineer. - - Connect via contact link - Připojit se přes odkaz + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Připojit se přes odkaz skupiny? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1184,10 @@ Připojit se jednorázovým odkazem No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Připojování k serveru… @@ -1164,11 +1233,6 @@ Kontakt již existuje No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Kontakt a všechny zprávy budou smazány - nelze to vzít zpět! - No comment provided by engineer. - Contact hidden: Skrytý kontakt: @@ -1219,6 +1283,10 @@ Verze jádra: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Vytvořit @@ -1239,6 +1307,10 @@ Vytvořit soubor server test step + + Create group + No comment provided by engineer. + Create group link Vytvořit odkaz na skupinu @@ -1251,6 +1323,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -1258,6 +1331,10 @@ Vytvořit jednorázovou pozvánku No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Vytvořit frontu @@ -1416,6 +1493,10 @@ Smazat chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Smazat kontakt @@ -1441,6 +1522,10 @@ Odstranit všechny soubory No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Smazat archiv @@ -1471,9 +1556,9 @@ Smazat kontakt No comment provided by engineer. - - Delete contact? - Smazat kontakt? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1708,16 +1793,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - Zobrazované jméno - No comment provided by engineer. - - - Display name: - Zobrazované jméno: + Objevte a připojte skupiny No comment provided by engineer. @@ -1847,10 +1923,12 @@ Encrypt local files + Šifrovat místní soubory No comment provided by engineer. Encrypt stored files & media + Šifrovat uložené soubory a média No comment provided by engineer. @@ -1898,6 +1976,10 @@ Zadejte správnou přístupovou frázi. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Zadejte přístupovou frázi… @@ -1923,6 +2005,10 @@ Zadat uvítací zprávu... (volitelně) placeholder + + Enter your name… + No comment provided by engineer. + Error Chyba @@ -1980,6 +2066,7 @@ Error creating member contact + Chyba vytvoření kontaktu člena No comment provided by engineer. @@ -1989,6 +2076,7 @@ Error decrypting file + Chyba dešifrování souboru No comment provided by engineer. @@ -2113,6 +2201,7 @@ Error sending member contact invitation + Chyba odeslání pozvánky kontaktu No comment provided by engineer. @@ -2195,6 +2284,10 @@ Ukončit bez uložení No comment provided by engineer. + + Expand + chat item action + Export database Export databáze @@ -2340,6 +2433,10 @@ Celé jméno: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Plně přepracováno, prácuje na pozadí! @@ -2360,6 +2457,14 @@ Skupina No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Zobrazovaný název skupiny @@ -2707,6 +2812,10 @@ Neplatný odkaz na spojení No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Neplatná adresa serveru! @@ -2798,11 +2907,24 @@ Připojit ke skupině No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Připojit se inkognito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Připojování ke skupině @@ -3018,6 +3140,10 @@ Zprávy No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Přenášení archivu databáze… @@ -3130,6 +3256,7 @@ New desktop app! + Nová desktopová aplikace! No comment provided by engineer. @@ -3179,6 +3306,7 @@ No delivery information + Žádné informace o dodání No comment provided by engineer. @@ -3347,6 +3475,7 @@ Open + Otevřít No comment provided by engineer. @@ -3364,6 +3493,10 @@ Otevřete konzolu chatu authentication reason + + Open group + No comment provided by engineer. + Open user profiles Otevřít uživatelské profily @@ -3379,11 +3512,6 @@ Otvírání databáze… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Otevření odkazu v prohlížeči může snížit soukromí a bezpečnost připojení. Nedůvěryhodné odkazy SimpleX budou červené. - No comment provided by engineer. - PING count Počet PING @@ -3574,6 +3702,14 @@ Profilový obrázek No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Heslo profilu @@ -3691,6 +3827,7 @@ Receipts are disabled + Informace o dodání jsou zakázány No comment provided by engineer. @@ -3818,6 +3955,14 @@ Znovu vyjednat šifrování? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Odpověď @@ -4085,6 +4230,7 @@ Send direct message to connect + Odeslat přímou zprávu pro připojení No comment provided by engineer. @@ -4159,6 +4305,7 @@ Sending receipts is disabled for %lld groups + Odesílání potvrzení o doručení vypnuto pro %lld skupiny No comment provided by engineer. @@ -4168,6 +4315,7 @@ Sending receipts is enabled for %lld groups + Odesílání potvrzení o doručení povoleno pro %lld skupiny No comment provided by engineer. @@ -4312,6 +4460,7 @@ Show last messages + Zobrazit poslední zprávy No comment provided by engineer. @@ -4386,6 +4535,7 @@ Simplified incognito mode + Zjednodušený inkognito režim No comment provided by engineer. @@ -4400,6 +4550,7 @@ Small groups (max 20) + Malé skupiny (max. 20) No comment provided by engineer. @@ -4527,6 +4678,10 @@ Klepněte na tlačítko No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Klepnutím aktivujete profil. @@ -4624,11 +4779,6 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Šifrování funguje a nové povolení šifrování není vyžadováno. To může vyvolat chybu v připojení! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Skupina je plně decentralizovaná - je viditelná pouze pro členy. - No comment provided by engineer. - The hash of the previous message is different. Hash předchozí zprávy se liší. @@ -4716,6 +4866,7 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován This group has over %lld members, delivery receipts are not sent. + Tato skupina má více než %lld členů, potvrzení o doručení nejsou odesílány. No comment provided by engineer. @@ -4723,6 +4874,14 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Tato skupina již neexistuje. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Toto nastavení platí pro zprávy ve vašem aktuálním chat profilu **%@**. @@ -4782,6 +4941,7 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Toggle incognito when connecting. + Změnit inkognito režim při připojení. No comment provided by engineer. @@ -4819,6 +4979,18 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Nelze nahrát hlasovou zprávu No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Neočekávaná chyba: %@ @@ -4963,6 +5135,7 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use current profile + Použít aktuální profil No comment provided by engineer. @@ -4977,6 +5150,7 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Use new incognito profile + Použít nový inkognito profil No comment provided by engineer. @@ -5164,6 +5338,35 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Již jste připojeni k %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. @@ -5259,6 +5462,15 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Nemohli jste být ověřeni; Zkuste to prosím znovu. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Nemáte žádné konverzace @@ -5309,6 +5521,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Ke skupině budete připojeni, až bude zařízení hostitele skupiny online, vyčkejte prosím nebo se podívejte později! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Budete připojeni, jakmile bude vaše žádost o připojení přijata, vyčkejte prosím nebo se podívejte později! @@ -5324,9 +5540,8 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Při spuštění nebo obnovení aplikace po 30 sekundách na pozadí budete požádáni o ověření. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Připojíte se ke skupině, na kterou tento odkaz odkazuje, a spojíte se s jejími členy. + + You will connect to all group members. No comment provided by engineer. @@ -5394,11 +5609,6 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Vaše chat databáze není šifrována – nastavte přístupovou frázi pro její šifrování. No comment provided by engineer. - - Your chat profile will be sent to group members - Váš chat profil bude zaslán členům skupiny - No comment provided by engineer. - Your chat profiles Vaše chat profily @@ -5453,8 +5663,13 @@ Můžete ji změnit v Nastavení. Vaše soukromí No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. + Váš profil **%@** bude sdílen. No comment provided by engineer. @@ -5544,6 +5759,10 @@ Servery SimpleX nevidí váš profil. vždy pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) zvukový hovor (nešifrovaný e2e) @@ -5559,6 +5778,10 @@ Servery SimpleX nevidí váš profil. špatný hash zprávy integrity error chat item + + blocked + No comment provided by engineer. + bold tučně @@ -5631,6 +5854,7 @@ Servery SimpleX nevidí váš profil. connected directly + připojeno přímo rcv group event chat item @@ -5728,6 +5952,10 @@ Servery SimpleX nevidí váš profil. smazáno deleted chat item + + deleted contact + rcv direct event chat item + deleted group odstraněna skupina @@ -5745,6 +5973,7 @@ Servery SimpleX nevidí váš profil. disabled + vypnut No comment provided by engineer. @@ -6010,7 +6239,8 @@ Servery SimpleX nevidí váš profil. off vypnuto enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6027,11 +6257,6 @@ Servery SimpleX nevidí váš profil. zapnuto group pref value - - or chat with the developers - nebo chat s vývojáři - No comment provided by engineer. - owner vlastník @@ -6094,6 +6319,7 @@ Servery SimpleX nevidí váš profil. send direct message + odeslat přímou zprávu No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 114b7f3e73..00cef659cf 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ und %@ wurden verbunden @@ -97,6 +101,10 @@ %1$@ an %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ ist mit Ihnen verbunden! @@ -122,6 +130,10 @@ %@ will sich mit Ihnen verbinden! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ und %lld weitere Mitglieder wurden verbunden @@ -187,11 +199,27 @@ %lld Datei(en) mit einem Gesamtspeicherverbrauch von %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld Mitglieder No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld Minuten @@ -199,6 +227,7 @@ %lld new interface languages + %lld neue Sprachen für die Bedienoberfläche No comment provided by engineer. @@ -335,6 +364,9 @@ - connect to [directory service](simplex:/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) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - Verbinden mit dem [Directory-Service](simplex:/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) (BETA)! +- Empfangsbestätigungen (für bis zu 20 Mitglieder). +- Schneller und stabiler. No comment provided by engineer. @@ -360,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -585,6 +621,10 @@ Alle Nachrichten werden gelöscht - dies kann nicht rückgängig gemacht werden! Die Nachrichten werden NUR bei Ihnen gelöscht. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Alle Ihre Kontakte bleiben verbunden. @@ -690,6 +730,14 @@ Sind Sie bereits verbunden? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Über ein Relais verbinden @@ -712,6 +760,7 @@ App encrypts new local files (except videos). + Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt. No comment provided by engineer. @@ -824,6 +873,18 @@ Verbesserungen bei Nachrichten No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Sowohl Sie, als auch Ihr Kontakt können Reaktionen auf Nachrichten geben. @@ -851,6 +912,7 @@ Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Bulgarisch, Finnisch, Thailändisch und Ukrainisch - Dank der Nutzer und [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. @@ -1084,24 +1146,27 @@ Verbinden server test step - - Connect directly - Direkt verbinden - No comment provided by engineer. - Connect incognito Inkognito verbinden No comment provided by engineer. - - Connect via contact link - Über den Kontakt-Link verbinden + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Über den Gruppen-Link verbinden? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1184,10 @@ Über einen Einmal-Link verbinden No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Mit dem Server verbinden… @@ -1164,11 +1233,6 @@ Der Kontakt ist bereits vorhanden No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Der Kontakt und alle Nachrichten werden gelöscht - dies kann nicht rückgängig gemacht werden! - No comment provided by engineer. - Contact hidden: Kontakt verborgen: @@ -1219,6 +1283,10 @@ Core Version: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Erstellen @@ -1239,6 +1307,10 @@ Datei erstellen server test step + + Create group + No comment provided by engineer. + Create group link Gruppenlink erstellen @@ -1251,6 +1323,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -1258,6 +1331,10 @@ Einmal-Einladungslink erstellen No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Erzeuge Warteschlange @@ -1416,6 +1493,10 @@ Löschen chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Kontakt löschen @@ -1441,6 +1522,10 @@ Alle Dateien löschen No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Archiv löschen @@ -1471,9 +1556,9 @@ Kontakt löschen No comment provided by engineer. - - Delete contact? - Kontakt löschen? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1708,16 +1793,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - Angezeigter Name - No comment provided by engineer. - - - Display name: - Angezeigter Name: + Gruppen entdecken und ihnen beitreten No comment provided by engineer. @@ -1852,6 +1928,7 @@ Encrypt stored files & media + Gespeicherte Dateien & Medien verschlüsseln No comment provided by engineer. @@ -1899,6 +1976,10 @@ Geben Sie das korrekte Passwort ein. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Passwort eingeben… @@ -1924,6 +2005,10 @@ Geben Sie eine Begrüßungsmeldung ein … (optional) placeholder + + Enter your name… + No comment provided by engineer. + Error Fehler @@ -1981,6 +2066,7 @@ Error creating member contact + Fehler beim Anlegen eines Mitglied-Kontaktes No comment provided by engineer. @@ -2115,6 +2201,7 @@ Error sending member contact invitation + Fehler beim Senden einer Mitglied-Kontakt-Einladung No comment provided by engineer. @@ -2197,6 +2284,10 @@ Beenden ohne Speichern No comment provided by engineer. + + Expand + chat item action + Export database Datenbank exportieren @@ -2342,6 +2433,10 @@ Vollständiger Name: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Komplett neu umgesetzt - arbeitet nun im Hintergrund! @@ -2362,6 +2457,14 @@ Gruppe No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Anzeigename der Gruppe @@ -2709,6 +2812,10 @@ Ungültiger Verbindungslink No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Ungültige Serveradresse! @@ -2800,11 +2907,24 @@ Treten Sie der Gruppe bei No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Inkognito beitreten No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Der Gruppe beitreten @@ -3020,6 +3140,10 @@ Nachrichten No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Datenbank-Archiv wird migriert… @@ -3132,6 +3256,7 @@ New desktop app! + Neue Desktop-App! No comment provided by engineer. @@ -3350,6 +3475,7 @@ Open + Öffnen No comment provided by engineer. @@ -3367,6 +3493,10 @@ Chat-Konsole öffnen authentication reason + + Open group + No comment provided by engineer. + Open user profiles Benutzerprofile öffnen @@ -3382,11 +3512,6 @@ Öffne Datenbank … No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden Rot sein. - No comment provided by engineer. - PING count PING-Zähler @@ -3577,6 +3702,14 @@ Profilbild No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Passwort für Profil @@ -3822,6 +3955,14 @@ Verschlüsselung neu aushandeln? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Antwort @@ -4089,6 +4230,7 @@ Send direct message to connect + Eine Direktnachricht zum Verbinden senden No comment provided by engineer. @@ -4393,6 +4535,7 @@ Simplified incognito mode + Vereinfachter Inkognito-Modus No comment provided by engineer. @@ -4535,6 +4678,10 @@ Schaltfläche antippen No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Tippen Sie auf das Profil um es zu aktivieren. @@ -4632,11 +4779,6 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Die Verschlüsselung funktioniert und ein neues Verschlüsselungsabkommen ist nicht erforderlich. Es kann zu Verbindungsfehlern kommen! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Die Gruppe ist vollständig dezentralisiert – sie ist nur für Mitglieder sichtbar. - No comment provided by engineer. - The hash of the previous message is different. Der Hash der vorherigen Nachricht unterscheidet sich. @@ -4732,6 +4874,14 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Diese Gruppe existiert nicht mehr. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**. @@ -4791,6 +4941,7 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Toggle incognito when connecting. + Inkognito beim Verbinden einschalten. No comment provided by engineer. @@ -4828,6 +4979,18 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Die Aufnahme einer Sprachnachricht ist nicht möglich No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Unerwarteter Fehler: %@ @@ -5175,6 +5338,35 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sie sind bereits mit %@ verbunden. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. @@ -5270,6 +5462,15 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sie konnten nicht überprüft werden; bitte versuchen Sie es erneut. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Sie haben keine Chats @@ -5320,6 +5521,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sie werden mit der Gruppe verbunden, sobald das Endgerät des Gruppen-Hosts online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Sie werden verbunden, sobald Ihre Verbindungsanfrage akzeptiert wird. Bitte warten oder schauen Sie später nochmal nach! @@ -5335,9 +5540,8 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sie müssen sich authentifizieren, wenn Sie die im Hintergrund befindliche App nach 30 Sekunden starten oder fortsetzen. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Sie werden der Gruppe beitreten, auf die sich dieser Link bezieht und sich mit deren Gruppenmitgliedern verbinden. + + You will connect to all group members. No comment provided by engineer. @@ -5405,11 +5609,6 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Ihre Chat-Datenbank ist nicht verschlüsselt. Bitte legen Sie ein Passwort fest, um sie zu schützen. No comment provided by engineer. - - Your chat profile will be sent to group members - Ihr Chat-Profil wird an Gruppenmitglieder gesendet - No comment provided by engineer. - Your chat profiles Meine Chat-Profile @@ -5464,6 +5663,10 @@ Sie können es in den Einstellungen ändern. Meine Privatsphäre No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Ihr Profil **%@** wird geteilt. @@ -5556,6 +5759,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Immer pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) Audioanruf (nicht E2E verschlüsselt) @@ -5571,6 +5778,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Ungültiger Nachrichten-Hash integrity error chat item + + blocked + No comment provided by engineer. + bold fett @@ -5643,6 +5854,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. connected directly + Direkt miteinander verbunden rcv group event chat item @@ -5740,6 +5952,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Gelöscht deleted chat item + + deleted contact + rcv direct event chat item + deleted group Gruppe gelöscht @@ -6024,7 +6240,8 @@ SimpleX-Server können Ihr Profil nicht einsehen. off Aus enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6041,11 +6258,6 @@ SimpleX-Server können Ihr Profil nicht einsehen. Ein group pref value - - or chat with the developers - oder chatten Sie mit den Entwicklern - No comment provided by engineer. - owner Eigentümer @@ -6108,6 +6320,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. send direct message + Direktnachricht senden No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 0aeeecfbe6..696465adcf 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -87,6 +87,11 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ and %@ connected @@ -97,6 +102,11 @@ %1$@ at %2$@: copied message info, <sender> at <time> + + %@ connected + %@ connected + No comment provided by engineer. + %@ is connected! %@ is connected! @@ -122,6 +132,11 @@ %@ wants to connect! notification title + + %@, %@ and %lld members + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ and %lld other members connected @@ -187,11 +202,31 @@ %lld file(s) with total size of %@ No comment provided by engineer. + + %lld group events + %lld group events + No comment provided by engineer. + %lld members %lld members No comment provided by engineer. + + %lld messages blocked + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minutes @@ -364,6 +399,11 @@ . No comment provided by engineer. + + 0 sec + 0 sec + time to disappear + 0s 0s @@ -589,6 +629,11 @@ All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. No comment provided by engineer. + + All new messages from %@ will be hidden! + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. All your contacts will remain connected. @@ -694,6 +739,16 @@ Already connected? No comment provided by engineer. + + Already connecting! + Already connecting! + No comment provided by engineer. + + + Already joining the group! + Already joining the group! + No comment provided by engineer. + Always use relay Always use relay @@ -829,6 +884,21 @@ Better messages No comment provided by engineer. + + Block + Block + No comment provided by engineer. + + + Block member + Block member + No comment provided by engineer. + + + Block member? + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Both you and your contact can add message reactions. @@ -1090,24 +1160,33 @@ Connect server test step - - Connect directly - Connect directly - No comment provided by engineer. - Connect incognito Connect incognito No comment provided by engineer. - - Connect via contact link - Connect via contact link + + Connect to yourself? + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Connect via group link? + + Connect to yourself? +This is your own SimpleX address! + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address + Connect via contact address No comment provided by engineer. @@ -1125,6 +1204,11 @@ Connect via one-time link No comment provided by engineer. + + Connect with %@ + Connect with %@ + No comment provided by engineer. + Connecting to server… Connecting to server… @@ -1170,11 +1254,6 @@ Contact already exists No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Contact and all messages will be deleted - this cannot be undone! - No comment provided by engineer. - Contact hidden: Contact hidden: @@ -1225,6 +1304,11 @@ Core version: v%@ No comment provided by engineer. + + Correct name to %@? + Correct name to %@? + No comment provided by engineer. + Create Create @@ -1245,6 +1329,11 @@ Create file server test step + + Create group + Create group + No comment provided by engineer. + Create group link Create group link @@ -1265,6 +1354,11 @@ Create one-time invitation link No comment provided by engineer. + + Create profile + Create profile + No comment provided by engineer. + Create queue Create queue @@ -1423,6 +1517,11 @@ Delete chat item action + + Delete %lld messages? + Delete %lld messages? + No comment provided by engineer. + Delete Contact Delete Contact @@ -1448,6 +1547,11 @@ Delete all files No comment provided by engineer. + + Delete and notify contact + Delete and notify contact + No comment provided by engineer. + Delete archive Delete archive @@ -1478,9 +1582,11 @@ Delete contact No comment provided by engineer. - - Delete contact? - Delete contact? + + Delete contact? +This cannot be undone! + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1824,6 @@ Discover and join groups No comment provided by engineer. - - Display name - Display name - No comment provided by engineer. - - - Display name: - Display name: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. Do NOT use SimpleX for emergency calls. @@ -1908,6 +2004,11 @@ Enter correct passphrase. No comment provided by engineer. + + Enter group name… + Enter group name… + No comment provided by engineer. + Enter passphrase… Enter passphrase… @@ -1933,6 +2034,11 @@ Enter welcome message… (optional) placeholder + + Enter your name… + Enter your name… + No comment provided by engineer. + Error Error @@ -2208,6 +2314,11 @@ Exit without saving No comment provided by engineer. + + Expand + Expand + chat item action + Export database Export database @@ -2353,6 +2464,11 @@ Full name: No comment provided by engineer. + + Fully decentralized – visible only to members. + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Fully re-implemented - work in background! @@ -2373,6 +2489,16 @@ Group No comment provided by engineer. + + Group already exists + Group already exists + No comment provided by engineer. + + + Group already exists! + Group already exists! + No comment provided by engineer. + Group display name Group display name @@ -2720,6 +2846,11 @@ Invalid connection link No comment provided by engineer. + + Invalid name! + Invalid name! + No comment provided by engineer. + Invalid server address! Invalid server address! @@ -2811,11 +2942,28 @@ Join group No comment provided by engineer. + + Join group? + Join group? + No comment provided by engineer. + Join incognito Join incognito No comment provided by engineer. + + Join with current profile + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Joining group @@ -3031,6 +3179,11 @@ Messages & files No comment provided by engineer. + + Messages from %@ will be shown! + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migrating database archive… @@ -3380,6 +3533,11 @@ Open chat console authentication reason + + Open group + Open group + No comment provided by engineer. + Open user profiles Open user profiles @@ -3395,11 +3553,6 @@ Opening database… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - No comment provided by engineer. - PING count PING count @@ -3590,6 +3743,16 @@ Profile image No comment provided by engineer. + + Profile name + Profile name + No comment provided by engineer. + + + Profile name: + Profile name: + No comment provided by engineer. + Profile password Profile password @@ -3835,6 +3998,16 @@ Renegotiate encryption? No comment provided by engineer. + + Repeat connection request? + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + Repeat join request? + No comment provided by engineer. + Reply Reply @@ -4550,6 +4723,11 @@ Tap button No comment provided by engineer. + + Tap to Connect + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Tap to activate profile. @@ -4647,11 +4825,6 @@ It can happen because of some bug or when the connection is compromised.The encryption is working and the new encryption agreement is not required. It may result in connection errors! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - The group is fully decentralized – it is visible only to the members. - No comment provided by engineer. - The hash of the previous message is different. The hash of the previous message is different. @@ -4747,6 +4920,16 @@ It can happen because of some bug or when the connection is compromised.This group no longer exists. No comment provided by engineer. + + This is your own SimpleX address! + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. This setting applies to messages in your current chat profile **%@**. @@ -4844,6 +5027,21 @@ You will be prompted to complete authentication before this feature is enabled.< Unable to record voice message No comment provided by engineer. + + Unblock + Unblock + No comment provided by engineer. + + + Unblock member + Unblock member + No comment provided by engineer. + + + Unblock member? + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Unexpected error: %@ @@ -5191,6 +5389,43 @@ To connect, please ask your contact to create another connection link and check You are already connected to %@. No comment provided by engineer. + + You are already connecting to %@. + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. You are connected to the server used to receive messages from this contact. @@ -5286,6 +5521,18 @@ To connect, please ask your contact to create another connection link and check You could not be verified; please try again. No comment provided by engineer. + + You have already requested connection via this address! + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats You have no chats @@ -5336,6 +5583,11 @@ To connect, please ask your contact to create another connection link and check You will be connected to group when the group host's device is online, please wait or check later! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! You will be connected when your connection request is accepted, please wait or check later! @@ -5351,9 +5603,9 @@ To connect, please ask your contact to create another connection link and check You will be required to authenticate when you start or resume the app after 30 seconds in background. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - You will join a group this link refers to and connect to its group members. + + You will connect to all group members. + You will connect to all group members. No comment provided by engineer. @@ -5421,11 +5673,6 @@ To connect, please ask your contact to create another connection link and check Your chat database is not encrypted - set passphrase to encrypt it. No comment provided by engineer. - - Your chat profile will be sent to group members - Your chat profile will be sent to group members - No comment provided by engineer. - Your chat profiles Your chat profiles @@ -5480,6 +5727,11 @@ You can change it in Settings. Your privacy No comment provided by engineer. + + Your profile + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Your profile **%@** will be shared. @@ -5572,6 +5824,11 @@ SimpleX servers cannot see your profile. always pref value + + and %lld other events + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) audio call (not e2e encrypted) @@ -5587,6 +5844,11 @@ SimpleX servers cannot see your profile. bad message hash integrity error chat item + + blocked + blocked + No comment provided by engineer. + bold bold @@ -5757,6 +6019,11 @@ SimpleX servers cannot see your profile. deleted deleted chat item + + deleted contact + deleted contact + rcv direct event chat item + deleted group deleted group @@ -6041,7 +6308,8 @@ SimpleX servers cannot see your profile. off off enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6058,11 +6326,6 @@ SimpleX servers cannot see your profile. on group pref value - - or chat with the developers - or chat with the developers - No comment provided by engineer. - owner owner diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 85f02bba1a..a27d02aa68 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ y %@ conectados @@ -97,6 +101,10 @@ %1$@ a las %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ ¡está conectado! @@ -122,6 +130,10 @@ ¡ %@ quiere contactar! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ y %lld miembros más conectados @@ -187,11 +199,27 @@ %lld archivo(s) con un tamaño total de %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld miembros No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minutos @@ -199,6 +227,7 @@ %lld new interface languages + %lld idiomas de interfaz nuevos No comment provided by engineer. @@ -248,12 +277,12 @@ %u messages failed to decrypt. - %u mensajes no pudieron ser descifrados. + %u mensaje(s) no ha(n) podido ser descifrado(s). No comment provided by engineer. %u messages skipped. - %u mensajes omitidos. + %u mensaje(s) omitido(s). No comment provided by engineer. @@ -335,6 +364,9 @@ - connect to [directory service](simplex:/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) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - conexión al [servicio de directorio](simplex:/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) (BETA)! +- confirmaciones de entrega (hasta 20 miembros). +- mayor rapidez y estabilidad. No comment provided by engineer. @@ -360,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -585,6 +621,10 @@ Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse! No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Todos tus contactos permanecerán conectados. @@ -690,6 +730,14 @@ ¿Ya está conectado? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Usar siempre retransmisor @@ -712,6 +760,7 @@ App encrypts new local files (except videos). + Cifrado de los nuevos archivos locales (excepto vídeos). No comment provided by engineer. @@ -824,6 +873,18 @@ Mensajes mejorados No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Tanto tú como tu contacto podéis añadir reacciones a los mensajes. @@ -851,6 +912,7 @@ Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Búlgaro, Finlandés, Tailandés y Ucraniano - gracias a los usuarios y [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. @@ -1084,24 +1146,27 @@ Conectar server test step - - Connect directly - Conectar directamente - No comment provided by engineer. - Connect incognito Conectar incognito No comment provided by engineer. - - Connect via contact link - Conectar mediante enlace de contacto + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - ¿Conectar mediante enlace de grupo? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1184,10 @@ Conectar mediante enlace de un sólo uso No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Conectando con el servidor… @@ -1164,11 +1233,6 @@ El contácto ya existe No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - El contacto y todos los mensajes serán eliminados. ¡No podrá deshacerse! - No comment provided by engineer. - Contact hidden: Contacto oculto: @@ -1219,6 +1283,10 @@ Versión Core: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Crear @@ -1239,6 +1307,10 @@ Crear archivo server test step + + Create group + No comment provided by engineer. + Create group link Crear enlace de grupo @@ -1251,6 +1323,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻 No comment provided by engineer. @@ -1258,6 +1331,10 @@ Crea enlace de invitación de un uso No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Crear cola @@ -1416,6 +1493,10 @@ Eliminar chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Eliminar contacto @@ -1441,6 +1522,10 @@ Eliminar todos los archivos No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Eliminar archivo @@ -1471,9 +1556,9 @@ Eliminar contacto No comment provided by engineer. - - Delete contact? - Eliminar contacto? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1708,16 +1793,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - Nombre mostrado - No comment provided by engineer. - - - Display name: - Nombre mostrado: + Descubre y únete a grupos No comment provided by engineer. @@ -1847,10 +1923,12 @@ Encrypt local files + Cifra archivos locales No comment provided by engineer. Encrypt stored files & media + Cifra archivos almacenados y multimedia No comment provided by engineer. @@ -1898,6 +1976,10 @@ Introduce la contraseña correcta. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Introduce la contraseña… @@ -1923,6 +2005,10 @@ Introduce mensaje de bienvenida… (opcional) placeholder + + Enter your name… + No comment provided by engineer. + Error Error @@ -1980,6 +2066,7 @@ Error creating member contact + Error al establecer contacto con el miembro No comment provided by engineer. @@ -1989,6 +2076,7 @@ Error decrypting file + Error al descifrar el archivo No comment provided by engineer. @@ -2113,6 +2201,7 @@ Error sending member contact invitation + Error al enviar mensaje de invitación al contacto No comment provided by engineer. @@ -2195,6 +2284,10 @@ Salir sin guardar No comment provided by engineer. + + Expand + chat item action + Export database Exportar base de datos @@ -2340,6 +2433,10 @@ Nombre completo: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Completamente reimplementado: ¡funciona en segundo plano! @@ -2360,6 +2457,14 @@ Grupo No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Nombre mostrado del grupo @@ -2462,12 +2567,12 @@ Group will be deleted for all members - this cannot be undone! - El grupo se eliminará para todos los miembros. ¡No podrá deshacerse! + El grupo será eliminado para todos los miembros. ¡No podrá deshacerse! No comment provided by engineer. Group will be deleted for you - this cannot be undone! - El grupo se eliminará para tí. ¡No podrá deshacerse! + El grupo será eliminado para tí. ¡No podrá deshacerse! No comment provided by engineer. @@ -2707,6 +2812,10 @@ Enlace de conexión no válido No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! ¡Dirección de servidor no válida! @@ -2798,11 +2907,24 @@ Únete al grupo No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Únete en modo incógnito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Entrando al grupo @@ -3018,6 +3140,10 @@ Mensajes No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migrando base de datos… @@ -3130,6 +3256,7 @@ New desktop app! + Nueva aplicación para PC! No comment provided by engineer. @@ -3348,6 +3475,7 @@ Open + Abrir No comment provided by engineer. @@ -3365,6 +3493,10 @@ Abrir consola de Chat authentication reason + + Open group + No comment provided by engineer. + Open user profiles Abrir perfil de usuario @@ -3380,11 +3512,6 @@ Abriendo base de datos… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces SimpleX que no son de confianza aparecerán en rojo. - No comment provided by engineer. - PING count Contador PING @@ -3575,6 +3702,14 @@ Imagen del perfil No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Contraseña del perfil @@ -3820,6 +3955,14 @@ ¿Renegociar cifrado? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Responder @@ -4087,6 +4230,7 @@ Send direct message to connect + Enviar mensaje directo para conectar No comment provided by engineer. @@ -4391,6 +4535,7 @@ Simplified incognito mode + Modo incógnito simplificado No comment provided by engineer. @@ -4533,6 +4678,10 @@ Pulsa el botón No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Pulsa sobre un perfil para activarlo. @@ -4630,11 +4779,6 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. El cifrado funciona y un cifrado nuevo no es necesario. ¡Podría dar lugar a errores de conexión! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - El grupo está totalmente descentralizado y sólo es visible para los miembros. - No comment provided by engineer. - The hash of the previous message is different. El hash del mensaje anterior es diferente. @@ -4730,6 +4874,14 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Este grupo ya no existe. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Esta configuración se aplica a los mensajes del perfil actual **%@**. @@ -4789,6 +4941,7 @@ Se te pedirá que completes la autenticación antes de activar esta función. Toggle incognito when connecting. + Activa incógnito al conectar. No comment provided by engineer. @@ -4826,6 +4979,18 @@ Se te pedirá que completes la autenticación antes de activar esta función.No se puede grabar mensaje de voz No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Error inesperado: %@ @@ -5174,6 +5339,35 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Ya estás conectado a %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Estás conectado al servidor usado para recibir mensajes de este contacto. @@ -5269,6 +5463,15 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb No has podido ser autenticado. Inténtalo de nuevo. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats No tienes chats @@ -5319,6 +5522,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Te conectarás al grupo cuando el dispositivo del anfitrión esté en línea, por favor espera o compruébalo más tarde. No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Te conectarás cuando tu solicitud se acepte, por favor espera o compruébalo más tarde. @@ -5334,9 +5541,8 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Se te pedirá identificarte cuándo inicies o continues usando la aplicación tras 30 segundos en segundo plano. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Te unirás al grupo al que hace referencia este enlace y te conectarás con sus miembros. + + You will connect to all group members. No comment provided by engineer. @@ -5404,11 +5610,6 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb La base de datos no está cifrada - establece una contraseña para cifrarla. No comment provided by engineer. - - Your chat profile will be sent to group members - Tu perfil será enviado a los miembros del grupo - No comment provided by engineer. - Your chat profiles Mis perfiles @@ -5463,6 +5664,10 @@ Puedes cambiarlo en Configuración. Privacidad No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Tu perfil **%@** será compartido. @@ -5555,6 +5760,10 @@ Los servidores de SimpleX no pueden ver tu perfil. siempre pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) llamada (sin cifrar) @@ -5570,6 +5779,10 @@ Los servidores de SimpleX no pueden ver tu perfil. hash de mensaje erróneo integrity error chat item + + blocked + No comment provided by engineer. + bold negrita @@ -5642,6 +5855,7 @@ Los servidores de SimpleX no pueden ver tu perfil. connected directly + conectado directamente rcv group event chat item @@ -5739,6 +5953,10 @@ Los servidores de SimpleX no pueden ver tu perfil. eliminado deleted chat item + + deleted contact + rcv direct event chat item + deleted group grupo eliminado @@ -6023,7 +6241,8 @@ Los servidores de SimpleX no pueden ver tu perfil. off desactivado enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6040,11 +6259,6 @@ Los servidores de SimpleX no pueden ver tu perfil. Activado group pref value - - or chat with the developers - o contacta mediante Chat con los desarrolladores - No comment provided by engineer. - owner propietario @@ -6107,6 +6321,7 @@ Los servidores de SimpleX no pueden ver tu perfil. send direct message + Enviar mensaje directo No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index c7e970f6ff..10e249cfdf 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -87,6 +87,10 @@ %@ / % @ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ ja %@ yhdistetty @@ -97,6 +101,10 @@ %1$@ klo %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ on yhdistetty! @@ -122,6 +130,10 @@ %@ haluaa muodostaa yhteyden! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ ja %lld muut jäsenet yhdistetty @@ -187,11 +199,27 @@ %lld tiedosto(a), joiden kokonaiskoko on %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld jäsenet No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minuuttia @@ -199,6 +227,7 @@ %lld new interface languages + %lld uutta käyttöliittymän kieltä No comment provided by engineer. @@ -360,6 +389,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -585,6 +618,10 @@ Kaikki viestit poistetaan - tätä ei voi kumota! Viestit poistuvat VAIN sinulta. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Kaikki kontaktisi pysyvät yhteydessä. @@ -690,6 +727,14 @@ Oletko jo muodostanut yhteyden? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Käytä aina relettä @@ -824,6 +869,18 @@ Parempia viestejä No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Sekä sinä että kontaktisi voivat käyttää viestireaktioita. @@ -1084,24 +1141,27 @@ Yhdistä server test step - - Connect directly - Yhdistä suoraan - No comment provided by engineer. - Connect incognito Yhdistä Incognito No comment provided by engineer. - - Connect via contact link - Yhdistä kontaktilinkillä + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Yhdistetäänkö ryhmälinkin kautta? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1179,10 @@ Yhdistä kertalinkillä No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Yhteyden muodostaminen palvelimeen… @@ -1164,11 +1228,6 @@ Kontakti on jo olemassa No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Kontakti ja kaikki viestit poistetaan - tätä ei voi perua! - No comment provided by engineer. - Contact hidden: Kontakti piilotettu: @@ -1219,6 +1278,10 @@ Ydinversio: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Luo @@ -1239,6 +1302,10 @@ Luo tiedosto server test step + + Create group + No comment provided by engineer. + Create group link Luo ryhmälinkki @@ -1251,6 +1318,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -1258,6 +1326,10 @@ Luo kertakutsulinkki No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Luo jono @@ -1416,6 +1488,10 @@ Poista chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Poista kontakti @@ -1441,6 +1517,10 @@ Poista kaikki tiedostot No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Poista arkisto @@ -1471,9 +1551,9 @@ Poista kontakti No comment provided by engineer. - - Delete contact? - Poista kontakti? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1708,16 +1788,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - Näyttönimi - No comment provided by engineer. - - - Display name: - Näyttönimi: + Löydä ryhmiä ja liity niihin No comment provided by engineer. @@ -1899,6 +1970,10 @@ Anna oikea tunnuslause. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Syötä tunnuslause… @@ -1924,6 +1999,10 @@ Kirjoita tervetuloviesti... (valinnainen) placeholder + + Enter your name… + No comment provided by engineer. + Error Virhe @@ -2197,6 +2276,10 @@ Poistu tallentamatta No comment provided by engineer. + + Expand + chat item action + Export database Vie tietokanta @@ -2342,6 +2425,10 @@ Koko nimi: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Täysin uudistettu - toimii taustalla! @@ -2362,6 +2449,14 @@ Ryhmä No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Ryhmän näyttönimi @@ -2709,6 +2804,10 @@ Virheellinen yhteyslinkki No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Virheellinen palvelinosoite! @@ -2800,11 +2899,24 @@ Liity ryhmään No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Liity incognito-tilassa No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Liittyy ryhmään @@ -3020,6 +3132,10 @@ Viestit ja tiedostot No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Siirretään tietokannan arkistoa… @@ -3367,6 +3483,10 @@ Avaa keskustelukonsoli authentication reason + + Open group + No comment provided by engineer. + Open user profiles Avaa käyttäjäprofiilit @@ -3382,11 +3502,6 @@ Avataan tietokantaa… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina. - No comment provided by engineer. - PING count PING-määrä @@ -3577,6 +3692,14 @@ Profiilikuva No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Profiilin salasana @@ -3822,6 +3945,14 @@ Uudelleenneuvottele salaus? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Vastaa @@ -4535,6 +4666,10 @@ Napauta painiketta No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Aktivoi profiili napauttamalla. @@ -4632,11 +4767,6 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Ryhmä on täysin hajautettu - se näkyy vain jäsenille. - No comment provided by engineer. - The hash of the previous message is different. Edellisen viestin tarkiste on erilainen. @@ -4732,6 +4862,14 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Tätä ryhmää ei enää ole olemassa. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. @@ -4828,6 +4966,18 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Ääniviestiä ei voi tallentaa No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Odottamaton virhe: %@ @@ -5175,6 +5325,35 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Olet jo muodostanut yhteyden %@:n kanssa. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. @@ -5270,6 +5449,15 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Sinua ei voitu todentaa; yritä uudelleen. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Sinulla ei ole keskusteluja @@ -5320,6 +5508,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin! @@ -5335,9 +5527,8 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Sinun on tunnistauduttava, kun käynnistät sovelluksen tai jatkat sen käyttöä 30 sekunnin tauon jälkeen. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin. + + You will connect to all group members. No comment provided by engineer. @@ -5405,11 +5596,6 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Keskustelut-tietokantasi ei ole salattu - aseta tunnuslause sen salaamiseksi. No comment provided by engineer. - - Your chat profile will be sent to group members - Keskusteluprofiilisi lähetetään ryhmän jäsenille - No comment provided by engineer. - Your chat profiles Keskusteluprofiilisi @@ -5464,6 +5650,10 @@ Voit muuttaa sitä Asetuksista. Yksityisyytesi No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Profiilisi **%@** jaetaan. @@ -5556,6 +5746,10 @@ SimpleX-palvelimet eivät näe profiiliasi. aina pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) äänipuhelu (ei e2e-salattu) @@ -5571,6 +5765,10 @@ SimpleX-palvelimet eivät näe profiiliasi. virheellinen viestin tarkiste integrity error chat item + + blocked + No comment provided by engineer. + bold lihavoitu @@ -5740,6 +5938,10 @@ SimpleX-palvelimet eivät näe profiiliasi. poistettu deleted chat item + + deleted contact + rcv direct event chat item + deleted group poistettu ryhmä @@ -6024,7 +6226,8 @@ SimpleX-palvelimet eivät näe profiiliasi. off pois enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6041,11 +6244,6 @@ SimpleX-palvelimet eivät näe profiiliasi. päällä group pref value - - or chat with the developers - tai keskustele kehittäjien kanssa - No comment provided by engineer. - owner omistaja diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 78f7fca921..381a50fe8f 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ et %@ sont connecté.es @@ -97,6 +101,10 @@ %1$@ à %2$@ : copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ est connecté·e ! @@ -122,6 +130,10 @@ %@ veut se connecter ! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ et %lld autres membres sont connectés @@ -187,11 +199,27 @@ %lld fichier·s pour une taille totale de %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld membres No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minutes @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Tous vos contacts resteront connectés. @@ -694,6 +730,14 @@ Déjà connecté ? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Se connecter via relais @@ -829,6 +873,18 @@ Meilleurs messages No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Vous et votre contact pouvez ajouter des réactions aux messages. @@ -1090,24 +1146,27 @@ Se connecter server test step - - Connect directly - Se connecter directement - No comment provided by engineer. - Connect incognito Se connecter incognito No comment provided by engineer. - - Connect via contact link - Se connecter via un lien de contact + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Se connecter via le lien du groupe ? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Se connecter via un lien unique No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Connexion au serveur… @@ -1170,11 +1233,6 @@ Contact déjà existant No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Le contact et tous les messages seront supprimés - impossible de revenir en arrière ! - No comment provided by engineer. - Contact hidden: Contact masqué : @@ -1225,6 +1283,10 @@ Version du cœur : v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Créer @@ -1245,6 +1307,10 @@ Créer un fichier server test step + + Create group + No comment provided by engineer. + Create group link Créer un lien de groupe @@ -1265,6 +1331,10 @@ Créer un lien d'invitation unique No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Créer une file d'attente @@ -1423,6 +1493,10 @@ Supprimer chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Supprimer le contact @@ -1448,6 +1522,10 @@ Effacer tous les fichiers No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Supprimer l'archive @@ -1478,9 +1556,9 @@ Supprimer le contact No comment provided by engineer. - - Delete contact? - Supprimer le contact ? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Découvrir et rejoindre des groupes No comment provided by engineer. - - Display name - Nom affiché - No comment provided by engineer. - - - Display name: - Nom affiché : - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. N'utilisez PAS SimpleX pour les appels d'urgence. @@ -1908,6 +1976,10 @@ Entrez la phrase secrète correcte. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Entrez la phrase secrète… @@ -1933,6 +2005,10 @@ Entrez un message de bienvenue… (facultatif) placeholder + + Enter your name… + No comment provided by engineer. + Error Erreur @@ -1990,6 +2066,7 @@ Error creating member contact + Erreur lors de la création du contact du membre No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Erreur lors de l'envoi de l'invitation de contact d'un membre No comment provided by engineer. @@ -2206,6 +2284,10 @@ Quitter sans sauvegarder No comment provided by engineer. + + Expand + chat item action + Export database Exporter la base de données @@ -2351,6 +2433,10 @@ Nom complet : No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Entièrement réimplémenté - fonctionne en arrière-plan ! @@ -2371,6 +2457,14 @@ Groupe No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Nom d'affichage du groupe @@ -2718,6 +2812,10 @@ Lien de connection invalide No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Adresse de serveur invalide ! @@ -2809,11 +2907,24 @@ Rejoindre le groupe No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Rejoindre en incognito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Entrain de rejoindre le groupe @@ -3029,6 +3140,10 @@ Messages No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migration de l'archive de la base de données… @@ -3360,6 +3475,7 @@ Open + Ouvrir No comment provided by engineer. @@ -3377,6 +3493,10 @@ Ouvrir la console du chat authentication reason + + Open group + No comment provided by engineer. + Open user profiles Ouvrir les profils d'utilisateurs @@ -3392,11 +3512,6 @@ Ouverture de la base de données… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge. - No comment provided by engineer. - PING count Nombre de PING @@ -3587,6 +3702,14 @@ Image de profil No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Mot de passe de profil @@ -3832,6 +3955,14 @@ Renégocier le chiffrement? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Répondre @@ -4094,11 +4225,12 @@ Send direct message - Envoi de message direct + Envoyer un message direct No comment provided by engineer. Send direct message to connect + Envoyer un message direct pour vous connecter No comment provided by engineer. @@ -4546,6 +4678,10 @@ Appuyez sur le bouton No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Appuyez pour activer un profil. @@ -4643,11 +4779,6 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le chiffrement fonctionne et le nouvel accord de chiffrement n'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Le groupe est entièrement décentralisé – il n'est visible que par ses membres. - No comment provided by engineer. - The hash of the previous message is different. Le hash du message précédent est différent. @@ -4743,6 +4874,14 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Ce groupe n'existe plus. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Ce paramètre s'applique aux messages de votre profil de chat actuel **%@**. @@ -4840,6 +4979,18 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Impossible d'enregistrer un message vocal No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Erreur inattendue : %@ @@ -5187,6 +5338,35 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Vous êtes déjà connecté·e à %@ via ce lien. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact. @@ -5282,6 +5462,15 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Vous n'avez pas pu être vérifié·e ; veuillez réessayer. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Vous n'avez aucune discussion @@ -5332,6 +5521,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Vous serez connecté·e au groupe lorsque l'appareil de l'hôte sera en ligne, veuillez attendre ou vérifier plus tard ! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard ! @@ -5347,9 +5540,8 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l'application après 30 secondes en arrière-plan. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Votre base de données de chat n'est pas chiffrée - définisez une phrase secrète. No comment provided by engineer. - - Your chat profile will be sent to group members - Votre profil de chat sera envoyé aux membres du groupe - No comment provided by engineer. - Your chat profiles Vos profils de chat @@ -5476,6 +5663,10 @@ Vous pouvez modifier ce choix dans les Paramètres. Votre vie privée No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Votre profil **%@** sera partagé. @@ -5568,6 +5759,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. toujours pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) appel audio (sans chiffrement) @@ -5583,6 +5778,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. hash de message incorrect integrity error chat item + + blocked + No comment provided by engineer. + bold gras @@ -5655,6 +5854,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. connected directly + s'est connecté.e de manière directe rcv group event chat item @@ -5752,6 +5952,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. supprimé deleted chat item + + deleted contact + rcv direct event chat item + deleted group groupe supprimé @@ -6036,7 +6240,8 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. off off enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. on group pref value - - or chat with the developers - ou ici pour discuter avec les développeurs - No comment provided by engineer. - owner propriétaire @@ -6120,6 +6320,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. send direct message + envoyer un message direct No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 2e9b9a3264..1faabca974 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ e %@ sono connessi/e @@ -97,6 +101,10 @@ %1$@ alle %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ è connesso/a! @@ -122,6 +130,10 @@ %@ si vuole connettere! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ e altri %lld membri sono connessi @@ -187,11 +199,27 @@ %lld file con dimensione totale di %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld membri No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minuti @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Tutti i messaggi verranno eliminati, non è reversibile! I messaggi verranno eliminati SOLO per te. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Tutti i tuoi contatti resteranno connessi. @@ -694,6 +730,14 @@ Già connesso/a? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Connetti via relay @@ -829,6 +873,18 @@ Messaggi migliorati No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Sia tu che il tuo contatto potete aggiungere reazioni ai messaggi. @@ -1090,24 +1146,27 @@ Connetti server test step - - Connect directly - Connetti direttamente - No comment provided by engineer. - Connect incognito Connetti in incognito No comment provided by engineer. - - Connect via contact link - Connetti via link del contatto + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Connettere via link del gruppo? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Connetti via link una tantum No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Connessione al server… @@ -1170,11 +1233,6 @@ Il contatto esiste già No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Il contatto e tutti i messaggi verranno eliminati, non è reversibile! - No comment provided by engineer. - Contact hidden: Contatto nascosto: @@ -1225,6 +1283,10 @@ Versione core: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Crea @@ -1245,6 +1307,10 @@ Crea file server test step + + Create group + No comment provided by engineer. + Create group link Crea link del gruppo @@ -1265,6 +1331,10 @@ Crea link di invito una tantum No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Crea coda @@ -1423,6 +1493,10 @@ Elimina chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Elimina contatto @@ -1448,6 +1522,10 @@ Elimina tutti i file No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Elimina archivio @@ -1478,9 +1556,9 @@ Elimina contatto No comment provided by engineer. - - Delete contact? - Eliminare il contatto? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Scopri ed unisciti ai gruppi No comment provided by engineer. - - Display name - Nome da mostrare - No comment provided by engineer. - - - Display name: - Nome da mostrare: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. NON usare SimpleX per chiamate di emergenza. @@ -1908,6 +1976,10 @@ Inserisci la password giusta. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Inserisci la password… @@ -1933,6 +2005,10 @@ Inserisci il messaggio di benvenuto… (facoltativo) placeholder + + Enter your name… + No comment provided by engineer. + Error Errore @@ -1990,6 +2066,7 @@ Error creating member contact + Errore di creazione del contatto No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Errore di invio dell'invito al contatto No comment provided by engineer. @@ -2206,6 +2284,10 @@ Esci senza salvare No comment provided by engineer. + + Expand + chat item action + Export database Esporta database @@ -2351,6 +2433,10 @@ Nome completo: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Completamente reimplementato - funziona in secondo piano! @@ -2371,6 +2457,14 @@ Gruppo No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Nome mostrato del gruppo @@ -2718,6 +2812,10 @@ Link di connessione non valido No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Indirizzo del server non valido! @@ -2809,11 +2907,24 @@ Entra nel gruppo No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Entra in incognito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Ingresso nel gruppo @@ -3029,6 +3140,10 @@ Messaggi No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migrazione archivio del database… @@ -3360,6 +3475,7 @@ Open + Apri No comment provided by engineer. @@ -3377,6 +3493,10 @@ Apri la console della chat authentication reason + + Open group + No comment provided by engineer. + Open user profiles Apri i profili utente @@ -3392,11 +3512,6 @@ Apertura del database… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Aprire il link nel browser può ridurre la privacy e la sicurezza della connessione. I link SimpleX non fidati saranno in rosso. - No comment provided by engineer. - PING count Conteggio PING @@ -3587,6 +3702,14 @@ Immagine del profilo No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Password del profilo @@ -3832,6 +3955,14 @@ Rinegoziare la crittografia? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Rispondi @@ -3894,7 +4025,7 @@ Revert - Annulla + Ripristina No comment provided by engineer. @@ -4099,6 +4230,7 @@ Send direct message to connect + Invia messaggio diretto per connetterti No comment provided by engineer. @@ -4546,6 +4678,10 @@ Tocca il pulsante No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Tocca per attivare il profilo. @@ -4643,11 +4779,6 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Il gruppo è completamente decentralizzato: è visibile solo ai membri. - No comment provided by engineer. - The hash of the previous message is different. L'hash del messaggio precedente è diverso. @@ -4743,6 +4874,14 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questo gruppo non esiste più. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**. @@ -4840,6 +4979,18 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Impossibile registrare il messaggio vocale No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Errore imprevisto: % @ @@ -5187,6 +5338,35 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Sei già connesso/a a %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Sei connesso/a al server usato per ricevere messaggi da questo contatto. @@ -5282,6 +5462,15 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Non è stato possibile verificarti, riprova. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Non hai chat @@ -5332,6 +5521,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Verrai connesso/a al gruppo quando il dispositivo dell'host del gruppo sarà in linea, attendi o controlla più tardi! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Verrai connesso/a quando la tua richiesta di connessione verrà accettata, attendi o controlla più tardi! @@ -5347,9 +5540,8 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Dovrai autenticarti quando avvii o riapri l'app dopo 30 secondi in secondo piano. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Il tuo database della chat non è crittografato: imposta la password per crittografarlo. No comment provided by engineer. - - Your chat profile will be sent to group members - Il tuo profilo di chat verrà inviato ai membri del gruppo - No comment provided by engineer. - Your chat profiles I tuoi profili di chat @@ -5476,6 +5663,10 @@ Puoi modificarlo nelle impostazioni. La tua privacy No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Il tuo profilo **%@** verrà condiviso. @@ -5568,6 +5759,10 @@ I server di SimpleX non possono vedere il tuo profilo. sempre pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) chiamata audio (non crittografata e2e) @@ -5583,6 +5778,10 @@ I server di SimpleX non possono vedere il tuo profilo. hash del messaggio errato integrity error chat item + + blocked + No comment provided by engineer. + bold grassetto @@ -5655,6 +5854,7 @@ I server di SimpleX non possono vedere il tuo profilo. connected directly + si è connesso/a direttamente rcv group event chat item @@ -5752,6 +5952,10 @@ I server di SimpleX non possono vedere il tuo profilo. eliminato deleted chat item + + deleted contact + rcv direct event chat item + deleted group gruppo eliminato @@ -5969,7 +6173,7 @@ I server di SimpleX non possono vedere il tuo profilo. connected - è connesso/a + si è connesso/a rcv group event chat item @@ -6036,7 +6240,8 @@ I server di SimpleX non possono vedere il tuo profilo. off off enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ I server di SimpleX non possono vedere il tuo profilo. on group pref value - - or chat with the developers - o scrivi agli sviluppatori - No comment provided by engineer. - owner proprietario @@ -6085,7 +6285,7 @@ I server di SimpleX non possono vedere il tuo profilo. removed - ha rimosso + rimosso No comment provided by engineer. @@ -6095,7 +6295,7 @@ I server di SimpleX non possono vedere il tuo profilo. removed you - sei stato/a rimosso/a + ti ha rimosso/a rcv group event chat item @@ -6120,6 +6320,7 @@ I server di SimpleX non possono vedere il tuo profilo. send direct message + invia messaggio diretto No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 27d7cb54f6..9f688d1ff5 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ と %@ は接続中 @@ -97,6 +101,10 @@ %1$@ at %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ 接続中! @@ -122,6 +130,10 @@ %@ が接続を希望しています! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ および %lld 人のメンバーが接続中 @@ -187,11 +199,27 @@ %lld 個のファイル(合計サイズ: %@) No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld 人のメンバー No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld 分 @@ -199,6 +227,7 @@ %lld new interface languages + %lldつの新しいインターフェース言語 No comment provided by engineer. @@ -360,6 +389,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0秒 @@ -585,6 +618,10 @@ 全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。 No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. あなたの連絡先が繋がったまま継続します。 @@ -690,6 +727,14 @@ すでに接続済みですか? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay 常にリレーを経由する @@ -712,6 +757,7 @@ App encrypts new local files (except videos). + アプリは新しいローカルファイル(ビデオを除く)を暗号化します。 No comment provided by engineer. @@ -824,6 +870,18 @@ より良いメッセージ No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. 自分も相手もメッセージへのリアクションを追加できます。 @@ -851,6 +909,7 @@ Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + ブルガリア語、フィンランド語、タイ語、ウクライナ語 - ユーザーと [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)に感謝します! No comment provided by engineer. @@ -1084,24 +1143,27 @@ 接続 server test step - - Connect directly - 直接接続する - No comment provided by engineer. - Connect incognito シークレットモードで接続 No comment provided by engineer. - - Connect via contact link - 連絡先リンク経由で接続しますか? + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - グループリンク経由で接続しますか? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1181,10 @@ 使い捨てリンク経由で接続しますか? No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… サーバーに接続中… @@ -1164,11 +1230,6 @@ 連絡先に既に存在します No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - 連絡先と全メッセージが削除されます (※元に戻せません※)! - No comment provided by engineer. - Contact hidden: 連絡先が非表示: @@ -1219,6 +1280,10 @@ コアのバージョン: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create 作成 @@ -1239,6 +1304,10 @@ ファイルを作成 server test step + + Create group + No comment provided by engineer. + Create group link グループのリンクを生成する @@ -1251,6 +1320,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + [デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻 No comment provided by engineer. @@ -1258,6 +1328,10 @@ 使い捨ての招待リンクを生成する No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue キューの作成 @@ -1416,6 +1490,10 @@ 削除 chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact 連絡先を削除 @@ -1441,6 +1519,10 @@ ファイルを全て削除 No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive アーカイブを削除 @@ -1471,9 +1553,9 @@ 連絡先を削除 No comment provided by engineer. - - Delete contact? - 連絡先を削除しますか? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1608,6 +1690,7 @@ Delivery receipts! + 配信通知! No comment provided by engineer. @@ -1707,16 +1790,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - 表示名 - No comment provided by engineer. - - - Display name: - 表示名: + グループを見つけて参加する No comment provided by engineer. @@ -1851,6 +1925,7 @@ Encrypt stored files & media + 保存されたファイルとメディアを暗号化する No comment provided by engineer. @@ -1898,6 +1973,10 @@ 正しいパスフレーズを入力してください。 No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… 暗証フレーズを入力… @@ -1923,6 +2002,10 @@ ウェルカムメッセージを入力…(オプション) placeholder + + Enter your name… + No comment provided by engineer. + Error エラー @@ -1980,6 +2063,7 @@ Error creating member contact + メンバー連絡先の作成中にエラーが発生 No comment provided by engineer. @@ -2113,6 +2197,7 @@ Error sending member contact invitation + 招待メッセージの送信エラー No comment provided by engineer. @@ -2194,6 +2279,10 @@ 保存せずに閉じる No comment provided by engineer. + + Expand + chat item action + Export database データベースをエキスポート @@ -2339,6 +2428,10 @@ フルネーム: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! 完全に再実装されました - バックグラウンドで動作します! @@ -2359,6 +2452,14 @@ グループ No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name グループ表示の名前 @@ -2706,6 +2807,10 @@ 無効な接続リンク No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! 無効なサーバアドレス! @@ -2797,11 +2902,24 @@ グループに参加 No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito シークレットモードで参加 No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group グループに参加 @@ -3016,6 +3134,10 @@ メッセージ & ファイル No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… データベースのアーカイブを移行しています… @@ -3128,6 +3250,7 @@ New desktop app! + 新しいデスクトップアプリ! No comment provided by engineer. @@ -3346,6 +3469,7 @@ Open + 開く No comment provided by engineer. @@ -3363,6 +3487,10 @@ チャットのコンソールを開く authentication reason + + Open group + No comment provided by engineer. + Open user profiles ユーザープロフィールを開く @@ -3378,11 +3506,6 @@ データベースを開いています… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - ブラウザでリンクを開くと接続のプライバシーとセキュリティが下がる可能性があります。信頼されないSimpleXリンクは読み込まれません。 - No comment provided by engineer. - PING count PING回数 @@ -3573,6 +3696,14 @@ プロフィール画像 No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password プロフィールのパスワード @@ -3817,6 +3948,14 @@ 暗号化を再ネゴシエートしますか? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply 返信 @@ -4083,6 +4222,7 @@ Send direct message to connect + ダイレクトメッセージを送信して接続する No comment provided by engineer. @@ -4380,6 +4520,7 @@ Simplified incognito mode + シークレットモードの簡素化 No comment provided by engineer. @@ -4522,6 +4663,10 @@ ボタンをタップ No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. タップしてプロフィールを有効化する。 @@ -4619,11 +4764,6 @@ It can happen because of some bug or when the connection is compromised.暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - グループは完全分散型で、メンバーしか内容を見れません。 - No comment provided by engineer. - The hash of the previous message is different. 以前のメッセージとハッシュ値が異なります。 @@ -4718,6 +4858,14 @@ It can happen because of some bug or when the connection is compromised.このグループはもう存在しません。 No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. この設定は現在のチャットプロフィール **%@** のメッセージに適用されます。 @@ -4814,6 +4962,18 @@ You will be prompted to complete authentication before this feature is enabled.< 音声メッセージを録音できません No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ 予期しないエラー: %@ @@ -5161,6 +5321,35 @@ To connect, please ask your contact to create another connection link and check すでに %@ に接続されています。 No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. この連絡先から受信するメッセージのサーバに既に接続してます。 @@ -5256,6 +5445,15 @@ To connect, please ask your contact to create another connection link and check 確認できませんでした。 もう一度お試しください。 No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats あなたはチャットがありません @@ -5306,6 +5504,10 @@ To connect, please ask your contact to create another connection link and check グループのホスト端末がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! 連絡先が繋がりリクエストを承認したら、接続されます。後でチェックするか、しばらくお待ちください! @@ -5321,9 +5523,8 @@ To connect, please ask your contact to create another connection link and check 起動時、または非アクティブ状態で30秒が経った後に戻ると、認証する必要となります。 No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - このリンクのグループに参加し、そのメンバーに繋がります。 + + You will connect to all group members. No comment provided by engineer. @@ -5391,11 +5592,6 @@ To connect, please ask your contact to create another connection link and check チャット データベースは暗号化されていません - 暗号化するにはパスフレーズを設定してください。 No comment provided by engineer. - - Your chat profile will be sent to group members - あなたのチャットプロフィールが他のグループメンバーに送られます - No comment provided by engineer. - Your chat profiles あなたのチャットプロフィール @@ -5450,6 +5646,10 @@ You can change it in Settings. あなたのプライバシー No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. あなたのプロファイル **%@** が共有されます。 @@ -5542,6 +5742,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 常に pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) 音声通話 (エンドツーエンド暗号化なし) @@ -5557,6 +5761,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 メッセージのハッシュ値問題 integrity error chat item + + blocked + No comment provided by engineer. + bold 太文字 @@ -5726,6 +5934,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 削除完了 deleted chat item + + deleted contact + rcv direct event chat item + deleted group 削除されたグループ @@ -6010,7 +6222,8 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 off オフ enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6027,11 +6240,6 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 オン group pref value - - or chat with the developers - または開発者とチャットする - No comment provided by engineer. - owner オーナー diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 233b1d0ba1..257b2ff0ce 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ en %@ verbonden @@ -97,6 +101,10 @@ %1$@ bij %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ is verbonden! @@ -122,6 +130,10 @@ %@ wil verbinding maken! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ en %lld andere leden hebben verbinding gemaakt @@ -187,11 +199,27 @@ %lld bestand(en) met een totale grootte van %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld leden No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minuten @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! De berichten worden ALLEEN voor jou verwijderd. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Al uw contacten blijven verbonden. @@ -694,6 +730,14 @@ Al verbonden? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Verbinden via relais @@ -829,6 +873,18 @@ Betere berichten No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Zowel u als uw contact kunnen berichtreacties toevoegen. @@ -1090,24 +1146,27 @@ Verbind server test step - - Connect directly - Verbind direct - No comment provided by engineer. - Connect incognito Verbind incognito No comment provided by engineer. - - Connect via contact link - Verbinden via contact link? + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Verbinden via groep link? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Verbinden via een eenmalige link? No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Verbinden met de server… @@ -1170,11 +1233,6 @@ Contact bestaat al No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Contact en alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. - Contact hidden: Contact verborgen: @@ -1225,6 +1283,10 @@ Core versie: v% @ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Maak @@ -1245,6 +1307,10 @@ Bestand maken server test step + + Create group + No comment provided by engineer. + Create group link Groep link maken @@ -1265,6 +1331,10 @@ Maak een eenmalige uitnodiging link No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Maak een wachtrij @@ -1423,6 +1493,10 @@ Verwijderen chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Verwijder contact @@ -1448,6 +1522,10 @@ Verwijder alle bestanden No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Archief verwijderen @@ -1478,9 +1556,9 @@ Verwijder contact No comment provided by engineer. - - Delete contact? - Verwijder contact? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Ontdek en sluit je aan bij groepen No comment provided by engineer. - - Display name - Weergavenaam - No comment provided by engineer. - - - Display name: - Weergavenaam: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. Gebruik SimpleX NIET voor noodoproepen. @@ -1908,6 +1976,10 @@ Voer het juiste wachtwoord in. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Voer wachtwoord in… @@ -1933,6 +2005,10 @@ Voer welkomst bericht in... (optioneel) placeholder + + Enter your name… + No comment provided by engineer. + Error Fout @@ -1990,6 +2066,7 @@ Error creating member contact + Fout bij aanmaken contact No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Fout bij verzenden van contact uitnodiging No comment provided by engineer. @@ -2206,6 +2284,10 @@ Afsluiten zonder opslaan No comment provided by engineer. + + Expand + chat item action + Export database Database exporteren @@ -2351,6 +2433,10 @@ Volledige naam: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Volledig opnieuw geïmplementeerd - werk op de achtergrond! @@ -2371,6 +2457,14 @@ Groep No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Weergave naam groep @@ -2718,6 +2812,10 @@ Ongeldige verbinding link No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Ongeldig server adres! @@ -2770,7 +2868,7 @@ It can happen when you or your connection used the old database backup. - Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt. + Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt. No comment provided by engineer. @@ -2780,7 +2878,7 @@ 3. The connection was compromised. Het kan gebeuren wanneer: 1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server. -2. Decodering van het bericht is mislukt, omdat u of uw contact een oude databaseback-up heeft gebruikt. +2. Decodering van het bericht is mislukt, omdat u of uw contact een oude database back-up heeft gebruikt. 3. De verbinding is verbroken. No comment provided by engineer. @@ -2809,11 +2907,24 @@ Word lid van groep No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Doe incognito mee No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Deel nemen aan groep @@ -3029,6 +3140,10 @@ Berichten No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Database archief migreren… @@ -3360,6 +3475,7 @@ Open + Open No comment provided by engineer. @@ -3377,6 +3493,10 @@ Chat console openen authentication reason + + Open group + No comment provided by engineer. + Open user profiles Gebruikers profielen openen @@ -3392,11 +3512,6 @@ Database openen… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Het openen van de link in de browser kan de privacy en beveiliging van de verbinding verminderen. Niet vertrouwde SimpleX links worden rood weergegeven. - No comment provided by engineer. - PING count PING count @@ -3587,6 +3702,14 @@ profielfoto No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Profiel wachtwoord @@ -3832,6 +3955,14 @@ Heronderhandelen over versleuteling? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Antwoord @@ -4099,6 +4230,7 @@ Send direct message to connect + Stuur een direct bericht om verbinding te maken No comment provided by engineer. @@ -4546,6 +4678,10 @@ Tik op de knop No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Tik om profiel te activeren. @@ -4643,11 +4779,6 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De versleuteling werkt en de nieuwe versleutelingsovereenkomst is niet vereist. Dit kan leiden tot verbindingsfouten! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - De groep is volledig gedecentraliseerd – het is alleen zichtbaar voor de leden. - No comment provided by engineer. - The hash of the previous message is different. De hash van het vorige bericht is anders. @@ -4743,6 +4874,14 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze groep bestaat niet meer. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Deze instelling is van toepassing op berichten in je huidige chat profiel **%@**. @@ -4840,6 +4979,18 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Kan spraakbericht niet opnemen No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Onverwachte fout: %@ @@ -5187,6 +5338,35 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak U bent al verbonden met %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. @@ -5282,6 +5462,15 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak U kon niet worden geverifieerd; probeer het opnieuw. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Je hebt geen gesprekken @@ -5332,6 +5521,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Je wordt verbonden met de groep wanneer het apparaat van de groep host online is, even geduld a.u.b. of controleer het later! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! U wordt verbonden wanneer uw verbindingsverzoek wordt geaccepteerd, even geduld a.u.b. of controleer later! @@ -5347,9 +5540,8 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak U moet zich authenticeren wanneer u de app na 30 seconden op de achtergrond start of hervat. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - U wordt lid van de groep waar deze link naar verwijst en maakt verbinding met de groepsleden. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Uw chat database is niet versleuteld, stel een wachtwoord in om deze te versleutelen. No comment provided by engineer. - - Your chat profile will be sent to group members - Uw chat profiel wordt verzonden naar de groepsleden - No comment provided by engineer. - Your chat profiles Uw chat profielen @@ -5476,6 +5663,10 @@ U kunt dit wijzigen in Instellingen. Uw privacy No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Uw profiel **%@** wordt gedeeld. @@ -5568,6 +5759,10 @@ SimpleX servers kunnen uw profiel niet zien. altijd pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) audio oproep (niet e2e versleuteld) @@ -5583,6 +5778,10 @@ SimpleX servers kunnen uw profiel niet zien. Onjuiste bericht hash integrity error chat item + + blocked + No comment provided by engineer. + bold vetgedrukt @@ -5655,6 +5854,7 @@ SimpleX servers kunnen uw profiel niet zien. connected directly + direct verbonden rcv group event chat item @@ -5752,6 +5952,10 @@ SimpleX servers kunnen uw profiel niet zien. verwijderd deleted chat item + + deleted contact + rcv direct event chat item + deleted group verwijderde groep @@ -6036,7 +6240,8 @@ SimpleX servers kunnen uw profiel niet zien. off uit enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ SimpleX servers kunnen uw profiel niet zien. aan group pref value - - or chat with the developers - of praat met de ontwikkelaars - No comment provided by engineer. - owner Eigenaar @@ -6120,6 +6320,7 @@ SimpleX servers kunnen uw profiel niet zien. send direct message + stuur een direct bericht No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 2e0e2de446..03ac0250a0 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ i %@ połączeni @@ -97,6 +101,10 @@ %1$@ o %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ jest połączony! @@ -122,6 +130,10 @@ %@ chce się połączyć! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ i %lld innych członków połączeni @@ -187,11 +199,27 @@ %lld plik(i) o całkowitym rozmiarze %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld członków No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minut @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Wszystkie wiadomości zostaną usunięte - nie można tego cofnąć! Wiadomości zostaną usunięte TYLKO dla Ciebie. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Wszystkie Twoje kontakty pozostaną połączone. @@ -694,6 +730,14 @@ Już połączony? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Zawsze używaj przekaźnika @@ -829,6 +873,18 @@ Lepsze wiadomości No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Zarówno Ty, jak i Twój kontakt możecie dodawać reakcje wiadomości. @@ -1090,24 +1146,27 @@ Połącz server test step - - Connect directly - Połącz bezpośrednio - No comment provided by engineer. - Connect incognito Połącz incognito No comment provided by engineer. - - Connect via contact link - Połącz przez link kontaktowy + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Połącz się przez link grupowy? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Połącz przez jednorazowy link No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Łączenie z serwerem… @@ -1170,11 +1233,6 @@ Kontakt już istnieje No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Kontakt i wszystkie wiadomości zostaną usunięte - nie można tego cofnąć! - No comment provided by engineer. - Contact hidden: Kontakt ukryty: @@ -1225,6 +1283,10 @@ Wersja rdzenia: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Utwórz @@ -1245,6 +1307,10 @@ Utwórz plik server test step + + Create group + No comment provided by engineer. + Create group link Utwórz link do grupy @@ -1265,6 +1331,10 @@ Utwórz jednorazowy link do zaproszenia No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Utwórz kolejkę @@ -1423,6 +1493,10 @@ Usuń chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Usuń Kontakt @@ -1448,6 +1522,10 @@ Usuń wszystkie pliki No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Usuń archiwum @@ -1478,9 +1556,9 @@ Usuń kontakt No comment provided by engineer. - - Delete contact? - Usunąć kontakt? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Odkrywaj i dołączaj do grup No comment provided by engineer. - - Display name - Wyświetlana nazwa - No comment provided by engineer. - - - Display name: - Wyświetlana nazwa: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. NIE używaj SimpleX do połączeń alarmowych. @@ -1908,6 +1976,10 @@ Wprowadź poprawne hasło. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Wprowadź hasło… @@ -1933,6 +2005,10 @@ Wpisz wiadomość powitalną… (opcjonalne) placeholder + + Enter your name… + No comment provided by engineer. + Error Błąd @@ -1990,6 +2066,7 @@ Error creating member contact + Błąd tworzenia kontaktu członka No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Błąd wysyłania zaproszenia kontaktu członka No comment provided by engineer. @@ -2206,6 +2284,10 @@ Wyjdź bez zapisywania No comment provided by engineer. + + Expand + chat item action + Export database Eksportuj bazę danych @@ -2351,6 +2433,10 @@ Pełna nazwa: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! W pełni ponownie zaimplementowany - praca w tle! @@ -2371,6 +2457,14 @@ Grupa No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Wyświetlana nazwa grupy @@ -2718,6 +2812,10 @@ Nieprawidłowy link połączenia No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Nieprawidłowy adres serwera! @@ -2809,11 +2907,24 @@ Dołącz do grupy No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Dołącz incognito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Dołączanie do grupy @@ -3029,6 +3140,10 @@ Wiadomości i pliki No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migrowanie archiwum bazy danych… @@ -3360,6 +3475,7 @@ Open + Otwórz No comment provided by engineer. @@ -3377,6 +3493,10 @@ Otwórz konsolę czatu authentication reason + + Open group + No comment provided by engineer. + Open user profiles Otwórz profile użytkownika @@ -3392,11 +3512,6 @@ Otwieranie bazy danych… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Otwarcie łącza w przeglądarce może zmniejszyć prywatność i bezpieczeństwo połączenia. Niezaufane linki SimpleX będą miały kolor czerwony. - No comment provided by engineer. - PING count Liczba PINGÓW @@ -3587,6 +3702,14 @@ Zdjęcie profilowe No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Hasło profilu @@ -3832,6 +3955,14 @@ Renegocjować szyfrowanie? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Odpowiedz @@ -4099,6 +4230,7 @@ Send direct message to connect + Wyślij wiadomość bezpośrednią aby połączyć No comment provided by engineer. @@ -4546,6 +4678,10 @@ Naciśnij przycisk No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Dotknij, aby aktywować profil. @@ -4643,11 +4779,6 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Grupa jest w pełni zdecentralizowana – jest widoczna tylko dla członków. - No comment provided by engineer. - The hash of the previous message is different. Hash poprzedniej wiadomości jest inny. @@ -4743,6 +4874,14 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Ta grupa już nie istnieje. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**. @@ -4840,6 +4979,18 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Nie można nagrać wiadomości głosowej No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Nieoczekiwany błąd: %@ @@ -5187,6 +5338,35 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Jesteś już połączony z %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. @@ -5282,6 +5462,15 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Nie można zweryfikować użytkownika; proszę spróbować ponownie. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Nie masz czatów @@ -5332,6 +5521,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Zostaniesz połączony, gdy Twoje żądanie połączenia zostanie zaakceptowane, proszę czekać lub sprawdzić później! @@ -5347,9 +5540,8 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Uwierzytelnienie będzie wymagane przy uruchamianiu lub wznawianiu aplikacji po 30 sekundach w tle. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Dołączysz do grupy, do której odnosi się ten link i połączysz się z jej członkami. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją zaszyfrować. No comment provided by engineer. - - Your chat profile will be sent to group members - Twój profil czatu zostanie wysłany do członków grupy - No comment provided by engineer. - Your chat profiles Twoje profile czatu @@ -5476,6 +5663,10 @@ Możesz to zmienić w Ustawieniach. Twoja prywatność No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Twój profil **%@** zostanie udostępniony. @@ -5568,6 +5759,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. zawsze pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) połączenie audio (nie szyfrowane e2e) @@ -5583,6 +5778,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. zły hash wiadomości integrity error chat item + + blocked + No comment provided by engineer. + bold pogrubiona @@ -5655,6 +5854,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. connected directly + połącz bezpośrednio rcv group event chat item @@ -5752,6 +5952,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. usunięty deleted chat item + + deleted contact + rcv direct event chat item + deleted group usunięta grupa @@ -6036,7 +6240,8 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. off wyłączony enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. włączone group pref value - - or chat with the developers - lub porozmawiać z deweloperami - No comment provided by engineer. - owner właściciel @@ -6120,6 +6320,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. send direct message + wyślij wiadomość bezpośrednią No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 54841f241e..672a9071c3 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ и %@ соединены @@ -97,6 +101,10 @@ %1$@ в %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! Установлено соединение с %@! @@ -122,6 +130,10 @@ %@ хочет соединиться! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ и %lld других членов соединены @@ -187,11 +199,27 @@ %lld файл(ов) общим размером %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members Членов группы: %lld No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld минуты @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s @@ -589,6 +621,10 @@ Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для Вас. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Все контакты, которые соединились через этот адрес, сохранятся. @@ -694,6 +730,14 @@ Соединение уже установлено? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Всегда соединяться через relay @@ -829,6 +873,18 @@ Улучшенные сообщения No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. И Вы, и Ваш контакт можете добавлять реакции на сообщения. @@ -1090,24 +1146,27 @@ Соединиться server test step - - Connect directly - Соединиться напрямую - No comment provided by engineer. - Connect incognito Соединиться Инкогнито No comment provided by engineer. - - Connect via contact link - Соединиться через ссылку-контакт + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Соединиться через ссылку группы? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Соединиться через одноразовую ссылку No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Устанавливается соединение с сервером… @@ -1170,11 +1233,6 @@ Существующий контакт No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Контакт и все сообщения будут удалены - это действие нельзя отменить! - No comment provided by engineer. - Contact hidden: Контакт скрыт: @@ -1225,6 +1283,10 @@ Версия ядра: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Создать @@ -1245,6 +1307,10 @@ Создание файла server test step + + Create group + No comment provided by engineer. + Create group link Создать ссылку группы @@ -1265,6 +1331,10 @@ Создать ссылку-приглашение No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Создание очереди @@ -1423,6 +1493,10 @@ Удалить chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Удалить контакт @@ -1448,6 +1522,10 @@ Удалить все файлы No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Удалить архив @@ -1478,9 +1556,9 @@ Удалить контакт No comment provided by engineer. - - Delete contact? - Удалить контакт? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Найдите и вступите в группы No comment provided by engineer. - - Display name - Имя профиля - No comment provided by engineer. - - - Display name: - Имя профиля: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. Не используйте SimpleX для экстренных звонков. @@ -1908,6 +1976,10 @@ Введите правильный пароль. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Введите пароль… @@ -1933,6 +2005,10 @@ Введите приветственное сообщение... (опционально) placeholder + + Enter your name… + No comment provided by engineer. + Error Ошибка @@ -2206,6 +2282,10 @@ Выйти без сохранения No comment provided by engineer. + + Expand + chat item action + Export database Экспорт архива чата @@ -2351,6 +2431,10 @@ Полное имя: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Полностью обновлены - работают в фоне! @@ -2371,6 +2455,14 @@ Группа No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Имя группы @@ -2718,6 +2810,10 @@ Ошибка в ссылке контакта No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Ошибка в адресе сервера! @@ -2809,11 +2905,24 @@ Вступить в группу No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Вступить инкогнито No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Вступление в группу @@ -3029,6 +3138,10 @@ Сообщения No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Данные чата перемещаются… @@ -3377,6 +3490,10 @@ Открыть консоль authentication reason + + Open group + No comment provided by engineer. + Open user profiles Открыть профили пользователя @@ -3392,11 +3509,6 @@ Открытие базы данных… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными. - No comment provided by engineer. - PING count Количество PING @@ -3587,6 +3699,14 @@ Аватар No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Пароль профиля @@ -3832,6 +3952,14 @@ Пересогласовать шифрование? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Ответить @@ -4546,6 +4674,10 @@ Нажмите кнопку No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Нажмите, чтобы сделать профиль активным. @@ -4643,11 +4775,6 @@ It can happen because of some bug or when the connection is compromised.Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Группа полностью децентрализована — она видна только членам. - No comment provided by engineer. - The hash of the previous message is different. Хэш предыдущего сообщения отличается. @@ -4743,6 +4870,14 @@ It can happen because of some bug or when the connection is compromised.Эта группа больше не существует. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**. @@ -4840,6 +4975,18 @@ You will be prompted to complete authentication before this feature is enabled.< Невозможно записать голосовое сообщение No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Неожиданная ошибка: %@ @@ -5187,6 +5334,35 @@ To connect, please ask your contact to create another connection link and check Вы уже соединены с контактом %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. @@ -5282,6 +5458,15 @@ To connect, please ask your contact to create another connection link and check Верификация не удалась; пожалуйста, попробуйте ещё раз. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats У Вас нет чатов @@ -5332,6 +5517,10 @@ To connect, please ask your contact to create another connection link and check Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Соединение будет установлено, когда Ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! @@ -5347,9 +5536,8 @@ To connect, please ask your contact to create another connection link and check Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Вы вступите в группу, на которую ссылается эта ссылка, и соединитесь с её членами. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5605,6 @@ To connect, please ask your contact to create another connection link and check База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные. No comment provided by engineer. - - Your chat profile will be sent to group members - Ваш профиль чата будет отправлен членам группы - No comment provided by engineer. - Your chat profiles Ваши профили чата @@ -5476,6 +5659,10 @@ You can change it in Settings. Конфиденциальность No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Будет отправлен Ваш профиль **%@**. @@ -5568,6 +5755,10 @@ SimpleX серверы не могут получить доступ к Ваше всегда pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) аудиозвонок (не e2e зашифрованный) @@ -5583,6 +5774,10 @@ SimpleX серверы не могут получить доступ к Ваше ошибка хэш сообщения integrity error chat item + + blocked + No comment provided by engineer. + bold жирный @@ -5752,6 +5947,10 @@ SimpleX серверы не могут получить доступ к Ваше удалено deleted chat item + + deleted contact + rcv direct event chat item + deleted group удалил(а) группу @@ -6036,7 +6235,8 @@ SimpleX серверы не могут получить доступ к Ваше off нет enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6253,6 @@ SimpleX серверы не могут получить доступ к Ваше да group pref value - - or chat with the developers - или соединитесь с разработчиками - No comment provided by engineer. - owner владелец diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 19681b3150..44342f0885 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -84,6 +84,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected No comment provided by engineer. @@ -93,6 +97,10 @@ %1$@ ที่ %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ เชื่อมต่อสำเร็จ! @@ -118,6 +126,10 @@ %@ อยากเชื่อมต่อ! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected No comment provided by engineer. @@ -182,11 +194,27 @@ %lld ไฟล์ที่มีขนาดรวม %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld สมาชิก No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld นาที @@ -355,6 +383,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -578,6 +610,10 @@ ข้อความทั้งหมดจะถูกลบ - ไม่สามารถยกเลิกได้! ข้อความจะถูกลบสำหรับคุณเท่านั้น. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. ผู้ติดต่อทั้งหมดของคุณจะยังคงเชื่อมต่ออยู่. @@ -683,6 +719,14 @@ เชื่อมต่อสำเร็จแล้ว? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay ใช้รีเลย์เสมอ @@ -817,6 +861,18 @@ ข้อความที่ดีขึ้น No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. ทั้งคุณและผู้ติดต่อของคุณสามารถเพิ่มปฏิกิริยาของข้อความได้ @@ -1077,21 +1133,26 @@ เชื่อมต่อ server test step - - Connect directly - No comment provided by engineer. - Connect incognito No comment provided by engineer. - - Connect via contact link + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - เชื่อมต่อผ่านลิงค์กลุ่ม? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1108,6 +1169,10 @@ Connect via one-time link No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… กำลังเชื่อมต่อกับเซิร์ฟเวอร์… @@ -1153,11 +1218,6 @@ ผู้ติดต่อรายนี้มีอยู่แล้ว No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - ผู้ติดต่อและข้อความทั้งหมดจะถูกลบ - ไม่สามารถยกเลิกได้! - No comment provided by engineer. - Contact hidden: ผู้ติดต่อถูกซ่อน: @@ -1208,6 +1268,10 @@ รุ่นหลัก: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create สร้าง @@ -1228,6 +1292,10 @@ สร้างไฟล์ server test step + + Create group + No comment provided by engineer. + Create group link สร้างลิงค์กลุ่ม @@ -1247,6 +1315,10 @@ สร้างลิงก์เชิญแบบใช้ครั้งเดียว No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue สร้างคิว @@ -1405,6 +1477,10 @@ ลบ chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact ลบผู้ติดต่อ @@ -1430,6 +1506,10 @@ ลบไฟล์ทั้งหมด No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive ลบที่เก็บถาวร @@ -1460,9 +1540,9 @@ ลบผู้ติดต่อ No comment provided by engineer. - - Delete contact? - ลบผู้ติดต่อ? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1698,16 +1778,6 @@ Discover and join groups No comment provided by engineer. - - Display name - ชื่อที่แสดง - No comment provided by engineer. - - - Display name: - ชื่อที่แสดง: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. อย่าใช้ SimpleX สําหรับการโทรฉุกเฉิน @@ -1886,6 +1956,10 @@ ใส่รหัสผ่านที่ถูกต้อง No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… ใส่รหัสผ่าน @@ -1911,6 +1985,10 @@ ใส่ข้อความต้อนรับ… (ไม่บังคับ) placeholder + + Enter your name… + No comment provided by engineer. + Error ผิดพลาด @@ -2183,6 +2261,10 @@ ออกโดยไม่บันทึก No comment provided by engineer. + + Expand + chat item action + Export database ส่งออกฐานข้อมูล @@ -2328,6 +2410,10 @@ ชื่อเต็ม: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! ดำเนินการใหม่อย่างสมบูรณ์ - ทำงานในพื้นหลัง! @@ -2348,6 +2434,14 @@ กลุ่ม No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name ชื่อกลุ่มที่แสดง @@ -2694,6 +2788,10 @@ ลิงค์เชื่อมต่อไม่ถูกต้อง No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! ที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง! @@ -2784,11 +2882,24 @@ เข้าร่วมกลุ่ม No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito เข้าร่วมแบบไม่ระบุตัวตน No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group กำลังจะเข้าร่วมกลุ่ม @@ -3004,6 +3115,10 @@ ข้อความและไฟล์ No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… กำลังย้ายข้อมูลที่เก็บถาวรของฐานข้อมูล… @@ -3349,6 +3464,10 @@ เปิดคอนโซลการแชท authentication reason + + Open group + No comment provided by engineer. + Open user profiles เปิดโปรไฟล์ผู้ใช้ @@ -3364,11 +3483,6 @@ กำลังเปิดฐานข้อมูล… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - การเปิดลิงก์ในเบราว์เซอร์อาจลดความเป็นส่วนตัวและความปลอดภัยของการเชื่อมต่อ ลิงก์ SimpleX ที่ไม่น่าเชื่อถือจะเป็นสีแดง - No comment provided by engineer. - PING count จํานวน PING @@ -3558,6 +3672,14 @@ รูปโปรไฟล์ No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password รหัสผ่านโปรไฟล์ @@ -3801,6 +3923,14 @@ เจรจา enryption ใหม่หรือไม่? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply ตอบ @@ -4510,6 +4640,10 @@ แตะปุ่ม No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. แตะเพื่อเปิดใช้งานโปรไฟล์ @@ -4608,11 +4742,6 @@ It can happen because of some bug or when the connection is compromised.encryption กำลังทำงานและไม่จำเป็นต้องใช้ข้อตกลง encryption ใหม่ อาจทำให้การเชื่อมต่อผิดพลาดได้! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - กลุ่มมีการกระจายอำนาจอย่างเต็มที่ – มองเห็นได้เฉพาะสมาชิกเท่านั้น - No comment provided by engineer. - The hash of the previous message is different. แฮชของข้อความก่อนหน้านี้แตกต่างกัน @@ -4706,6 +4835,14 @@ It can happen because of some bug or when the connection is compromised.ไม่มีกลุ่มนี้แล้ว No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. การตั้งค่านี้ใช้กับข้อความในโปรไฟล์แชทปัจจุบันของคุณ **%@** @@ -4802,6 +4939,18 @@ You will be prompted to complete authentication before this feature is enabled.< ไม่สามารถบันทึกข้อความเสียง No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ ข้อผิดพลาดที่ไม่คาดคิด: %@ @@ -5147,6 +5296,35 @@ To connect, please ask your contact to create another connection link and check คุณได้เชื่อมต่อกับ %@ แล้ว No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ @@ -5242,6 +5420,15 @@ To connect, please ask your contact to create another connection link and check เราไม่สามารถตรวจสอบคุณได้ กรุณาลองอีกครั้ง. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats คุณไม่มีการแชท @@ -5291,6 +5478,10 @@ To connect, please ask your contact to create another connection link and check คุณจะเชื่อมต่อกับกลุ่มเมื่ออุปกรณ์โฮสต์ของกลุ่มออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! คุณจะเชื่อมต่อเมื่อคำขอเชื่อมต่อของคุณได้รับการยอมรับ โปรดรอหรือตรวจสอบในภายหลัง! @@ -5306,9 +5497,8 @@ To connect, please ask your contact to create another connection link and check คุณจะต้องตรวจสอบสิทธิ์เมื่อคุณเริ่มหรือกลับมาใช้แอปพลิเคชันอีกครั้งหลังจากผ่านไป 30 วินาทีในพื้นหลัง No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - คุณจะเข้าร่วมกลุ่มที่ลิงก์นี้อ้างถึงและเชื่อมต่อกับสมาชิกในกลุ่ม + + You will connect to all group members. No comment provided by engineer. @@ -5376,11 +5566,6 @@ To connect, please ask your contact to create another connection link and check ฐานข้อมูลการแชทของคุณไม่ได้ถูก encrypt - ตั้งรหัสผ่านเพื่อ encrypt No comment provided by engineer. - - Your chat profile will be sent to group members - โปรไฟล์การแชทของคุณจะถูกส่งไปยังสมาชิกในกลุ่ม - No comment provided by engineer. - Your chat profiles โปรไฟล์แชทของคุณ @@ -5435,6 +5620,10 @@ You can change it in Settings. ความเป็นส่วนตัวของคุณ No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. No comment provided by engineer. @@ -5526,6 +5715,10 @@ SimpleX servers cannot see your profile. เสมอ pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) การโทรด้วยเสียง (ไม่ได้ encrypt จากต้นจนจบ) @@ -5541,6 +5734,10 @@ SimpleX servers cannot see your profile. แฮชข้อความไม่ดี integrity error chat item + + blocked + No comment provided by engineer. + bold ตัวหนา @@ -5710,6 +5907,10 @@ SimpleX servers cannot see your profile. ลบแล้ว deleted chat item + + deleted contact + rcv direct event chat item + deleted group กลุ่มที่ถูกลบ @@ -5992,7 +6193,8 @@ SimpleX servers cannot see your profile. off ปิด enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6009,11 +6211,6 @@ SimpleX servers cannot see your profile. เปิด group pref value - - or chat with the developers - หรือแชทกับนักพัฒนาแอป - No comment provided by engineer. - owner เจ้าของ diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 4947bf80c5..b4ce70f7df 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ і %@ підключено @@ -97,6 +101,10 @@ %1$@ за %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ підключено! @@ -122,6 +130,10 @@ %@ хоче підключитися! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ та %lld інші підключені учасники @@ -187,11 +199,27 @@ %lld файл(и) загальним розміром %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld учасників No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld хвилин @@ -360,6 +388,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s @@ -585,6 +617,10 @@ Всі повідомлення будуть видалені - це неможливо скасувати! Повідомлення будуть видалені ТІЛЬКИ для вас. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Всі ваші контакти залишаться на зв'язку. @@ -690,6 +726,14 @@ Вже підключено? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Завжди використовуйте реле @@ -824,6 +868,18 @@ Кращі повідомлення No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Реакції на повідомлення можете додавати як ви, так і ваш контакт. @@ -1084,24 +1140,27 @@ Підключіться server test step - - Connect directly - Підключіться безпосередньо - No comment provided by engineer. - Connect incognito Підключайтеся інкогніто No comment provided by engineer. - - Connect via contact link - Підключіться за контактним посиланням + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Підключитися за груповим посиланням? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1178,10 @@ Під'єднатися за одноразовим посиланням No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Підключення до сервера… @@ -1164,11 +1227,6 @@ Контакт вже існує No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Контакт і всі повідомлення будуть видалені - це неможливо скасувати! - No comment provided by engineer. - Contact hidden: Контакт приховано: @@ -1219,6 +1277,10 @@ Основна версія: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Створити @@ -1239,6 +1301,10 @@ Створити файл server test step + + Create group + No comment provided by engineer. + Create group link Створити групове посилання @@ -1258,6 +1324,10 @@ Створіть одноразове посилання-запрошення No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Створити чергу @@ -1416,6 +1486,10 @@ Видалити chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Видалити контакт @@ -1441,6 +1515,10 @@ Видалити всі файли No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Видалити архів @@ -1471,9 +1549,9 @@ Видалити контакт No comment provided by engineer. - - Delete contact? - Видалити контакт? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1710,16 +1788,6 @@ Discover and join groups No comment provided by engineer. - - Display name - Відображуване ім'я - No comment provided by engineer. - - - Display name: - Відображуване ім'я: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. НЕ використовуйте SimpleX для екстрених викликів. @@ -1898,6 +1966,10 @@ Введіть правильну парольну фразу. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Введіть пароль… @@ -1923,6 +1995,10 @@ Введіть вітальне повідомлення... (необов'язково) placeholder + + Enter your name… + No comment provided by engineer. + Error Помилка @@ -2195,6 +2271,10 @@ Вихід без збереження No comment provided by engineer. + + Expand + chat item action + Export database Експорт бази даних @@ -2340,6 +2420,10 @@ Повне ім'я: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Повністю перероблено - робота у фоновому режимі! @@ -2360,6 +2444,14 @@ Група No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Назва групи для відображення @@ -2707,6 +2799,10 @@ Неправильне посилання для підключення No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Неправильна адреса сервера! @@ -2798,11 +2894,24 @@ Приєднуйтесь до групи No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Приєднуйтесь інкогніто No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Приєднання до групи @@ -3018,6 +3127,10 @@ Повідомлення та файли No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Перенесення архіву бази даних… @@ -3365,6 +3478,10 @@ Відкрийте консоль чату authentication reason + + Open group + No comment provided by engineer. + Open user profiles Відкрити профілі користувачів @@ -3380,11 +3497,6 @@ Відкриття бази даних… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Відкриття посилання в браузері може знизити конфіденційність і безпеку з'єднання. Ненадійні посилання SimpleX будуть червоного кольору. - No comment provided by engineer. - PING count Кількість PING @@ -3575,6 +3687,14 @@ Зображення профілю No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Пароль до профілю @@ -3820,6 +3940,14 @@ Переузгодьте шифрування? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Відповісти @@ -4533,6 +4661,10 @@ Натисніть кнопку No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Натисніть, щоб активувати профіль. @@ -4630,11 +4762,6 @@ It can happen because of some bug or when the connection is compromised.Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з'єднання! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Група повністю децентралізована - її бачать лише учасники. - No comment provided by engineer. - The hash of the previous message is different. Хеш попереднього повідомлення відрізняється. @@ -4730,6 +4857,14 @@ It can happen because of some bug or when the connection is compromised.Цієї групи більше не існує. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**. @@ -4826,6 +4961,18 @@ You will be prompted to complete authentication before this feature is enabled.< Не вдається записати голосове повідомлення No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Неочікувана помилка: %@ @@ -5173,6 +5320,35 @@ To connect, please ask your contact to create another connection link and check Ви вже підключені до %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту. @@ -5268,6 +5444,15 @@ To connect, please ask your contact to create another connection link and check Вас не вдалося верифікувати, спробуйте ще раз. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats У вас немає чатів @@ -5318,6 +5503,10 @@ To connect, please ask your contact to create another connection link and check Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Ви будете підключені, коли ваш запит на підключення буде прийнято, будь ласка, зачекайте або перевірте пізніше! @@ -5333,9 +5522,8 @@ To connect, please ask your contact to create another connection link and check Вам потрібно буде пройти автентифікацію при запуску або відновленні програми після 30 секунд роботи у фоновому режимі. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Ви приєднаєтеся до групи, на яку посилається це посилання, і з'єднаєтеся з її учасниками. + + You will connect to all group members. No comment provided by engineer. @@ -5403,11 +5591,6 @@ To connect, please ask your contact to create another connection link and check Ваша база даних чату не зашифрована - встановіть ключову фразу, щоб зашифрувати її. No comment provided by engineer. - - Your chat profile will be sent to group members - Ваш профіль у чаті буде надіслано учасникам групи - No comment provided by engineer. - Your chat profiles Ваші профілі чату @@ -5462,6 +5645,10 @@ You can change it in Settings. Ваша конфіденційність No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Ваш профіль **%@** буде опублікований. @@ -5554,6 +5741,10 @@ SimpleX servers cannot see your profile. завжди pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) аудіовиклик (без шифрування e2e) @@ -5569,6 +5760,10 @@ SimpleX servers cannot see your profile. невірний хеш повідомлення integrity error chat item + + blocked + No comment provided by engineer. + bold жирний @@ -5738,6 +5933,10 @@ SimpleX servers cannot see your profile. видалено deleted chat item + + deleted contact + rcv direct event chat item + deleted group видалено групу @@ -6022,7 +6221,8 @@ SimpleX servers cannot see your profile. off вимкнено enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6039,11 +6239,6 @@ SimpleX servers cannot see your profile. увімкнено group pref value - - or chat with the developers - або поспілкуйтеся з розробниками - No comment provided by engineer. - owner власник diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 9fcd6d0bf2..fba15d68cf 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ 和%@ 以建立连接 @@ -97,6 +101,10 @@ @ %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ 已连接! @@ -122,6 +130,10 @@ %@ 要连接! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ 和 %lld 个成员 @@ -187,11 +199,27 @@ %lld 总文件大小 %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld 成员 No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld 分钟 @@ -336,6 +364,9 @@ - connect to [directory service](simplex:/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) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - 连接 [目录服务](simplex:/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) (BETA)! +- 发送回执(最多20成员)。 +- 更快并且更稳固。 No comment provided by engineer. @@ -361,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0秒 @@ -586,6 +621,10 @@ 所有聊天记录和消息将被删除——这一行为无法撤销!只有您的消息会被删除。 No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. 所有联系人会保持连接。 @@ -691,6 +730,14 @@ 已连接? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay 一直使用中继 @@ -713,6 +760,7 @@ App encrypts new local files (except videos). + 应用程序为新的本地文件(视频除外)加密。 No comment provided by engineer. @@ -825,6 +873,18 @@ 更好的消息 No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. 您和您的联系人都可以添加消息回应。 @@ -852,6 +912,7 @@ Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + 保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. @@ -1085,24 +1146,27 @@ 连接 server test step - - Connect directly - 直接连接 - No comment provided by engineer. - Connect incognito 在隐身状态下连接 No comment provided by engineer. - - Connect via contact link - 通过联系人链接进行连接 + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - 通过群组链接连接? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1120,6 +1184,10 @@ 通过一次性链接连接 No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… 连接服务器中…… @@ -1165,11 +1233,6 @@ 联系人已存在 No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - 联系人和所有的消息都将被删除——这是不可逆回的! - No comment provided by engineer. - Contact hidden: 联系人已隐藏: @@ -1220,6 +1283,10 @@ 核心版本: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create 创建 @@ -1240,6 +1307,10 @@ 创建文件 server test step + + Create group + No comment provided by engineer. + Create group link 创建群组链接 @@ -1252,6 +1323,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + 在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻 No comment provided by engineer. @@ -1259,6 +1331,10 @@ 创建一次性邀请链接 No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue 创建队列 @@ -1417,6 +1493,10 @@ 删除 chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact 删除联系人 @@ -1442,6 +1522,10 @@ 删除所有文件 No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive 删除档案 @@ -1472,9 +1556,9 @@ 删除联系人 No comment provided by engineer. - - Delete contact? - 删除联系人? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1709,16 +1793,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - 显示名称 - No comment provided by engineer. - - - Display name: - 显示名: + 发现和加入群组 No comment provided by engineer. @@ -1853,6 +1928,7 @@ Encrypt stored files & media + 为存储的文件和媒体加密 No comment provided by engineer. @@ -1900,6 +1976,10 @@ 输入正确密码。 No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… 输入密码…… @@ -1925,6 +2005,10 @@ 输入欢迎消息……(可选) placeholder + + Enter your name… + No comment provided by engineer. + Error 错误 @@ -1982,6 +2066,7 @@ Error creating member contact + 创建成员联系人时出错 No comment provided by engineer. @@ -2116,6 +2201,7 @@ Error sending member contact invitation + 发送成员联系人邀请错误 No comment provided by engineer. @@ -2198,6 +2284,10 @@ 退出而不保存 No comment provided by engineer. + + Expand + chat item action + Export database 导出数据库 @@ -2343,6 +2433,10 @@ 全名: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! 完全重新实现 - 在后台工作! @@ -2363,6 +2457,14 @@ 群组 No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name 群组显示名称 @@ -2710,6 +2812,10 @@ 无效的连接链接 No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! 无效的服务器地址! @@ -2801,11 +2907,24 @@ 加入群组 No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito 加入隐身聊天 No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group 加入群组中 @@ -3021,6 +3140,10 @@ 消息 No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… 迁移数据库档案中… @@ -3133,6 +3256,7 @@ New desktop app! + 全新桌面应用! No comment provided by engineer. @@ -3351,6 +3475,7 @@ Open + 打开 No comment provided by engineer. @@ -3368,6 +3493,10 @@ 打开聊天控制台 authentication reason + + Open group + No comment provided by engineer. + Open user profiles 打开用户个人资料 @@ -3383,11 +3512,6 @@ 打开数据库中…… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - 在浏览器中打开链接可能会降低连接的隐私和安全性。SimpleX 上不受信任的链接将显示为红色。 - No comment provided by engineer. - PING count PING 次数 @@ -3578,6 +3702,14 @@ 资料图片 No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password 个人资料密码 @@ -3823,6 +3955,14 @@ 重新协商加密? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply 回复 @@ -4090,6 +4230,7 @@ Send direct message to connect + 发送私信来连接 No comment provided by engineer. @@ -4394,6 +4535,7 @@ Simplified incognito mode + 简化的隐身模式 No comment provided by engineer. @@ -4536,6 +4678,10 @@ 点击按钮 No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. 点击以激活个人资料。 @@ -4633,11 +4779,6 @@ It can happen because of some bug or when the connection is compromised.加密正在运行,不需要新的加密协议。这可能会导致连接错误! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - 该小组是完全分散式的——它只对成员可见。 - No comment provided by engineer. - The hash of the previous message is different. 上一条消息的散列不同。 @@ -4733,6 +4874,14 @@ It can happen because of some bug or when the connection is compromised.该群组已不存在。 No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. 此设置适用于您当前聊天资料 **%@** 中的消息。 @@ -4792,6 +4941,7 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. + 在连接时切换隐身模式。 No comment provided by engineer. @@ -4829,6 +4979,18 @@ You will be prompted to complete authentication before this feature is enabled.< 无法录制语音消息 No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ 意外错误: %@ @@ -5176,6 +5338,35 @@ To connect, please ask your contact to create another connection link and check 您已经连接到 %@。 No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. 您已连接到用于接收该联系人消息的服务器。 @@ -5271,6 +5462,15 @@ To connect, please ask your contact to create another connection link and check 您的身份无法验证,请再试一次。 No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats 您没有聊天记录 @@ -5321,6 +5521,10 @@ To connect, please ask your contact to create another connection link and check 您将在组主设备上线时连接到该群组,请稍等或稍后再检查! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! 当您的连接请求被接受后,您将可以连接,请稍等或稍后检查! @@ -5336,9 +5540,8 @@ To connect, please ask your contact to create another connection link and check 当您启动应用或在应用程序驻留后台超过30 秒后,您将需要进行身份验证。 No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - 您将加入此链接指向的群组并连接到其群组成员。 + + You will connect to all group members. No comment provided by engineer. @@ -5406,11 +5609,6 @@ To connect, please ask your contact to create another connection link and check 您的聊天数据库未加密——设置密码来加密。 No comment provided by engineer. - - Your chat profile will be sent to group members - 您的聊天资料将被发送给群组成员 - No comment provided by engineer. - Your chat profiles 您的聊天资料 @@ -5465,6 +5663,10 @@ You can change it in Settings. 您的隐私设置 No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. 您的个人资料 **%@** 将被共享。 @@ -5557,6 +5759,10 @@ SimpleX 服务器无法看到您的资料。 始终 pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) 语音通话(非端到端加密) @@ -5572,6 +5778,10 @@ SimpleX 服务器无法看到您的资料。 错误消息散列 integrity error chat item + + blocked + No comment provided by engineer. + bold 加粗 @@ -5644,6 +5854,7 @@ SimpleX 服务器无法看到您的资料。 connected directly + 已直连 rcv group event chat item @@ -5741,6 +5952,10 @@ SimpleX 服务器无法看到您的资料。 已删除 deleted chat item + + deleted contact + rcv direct event chat item + deleted group 已删除群组 @@ -6025,7 +6240,8 @@ SimpleX 服务器无法看到您的资料。 off 关闭 enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6042,11 +6258,6 @@ SimpleX 服务器无法看到您的资料。 开启 group pref value - - or chat with the developers - 或与开发者聊天 - No comment provided by engineer. - owner 群主 @@ -6109,6 +6320,7 @@ SimpleX 服务器无法看到您的资料。 send direct message + 发送私信 No comment provided by engineer. diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index c01e3d7e66..5a704457d1 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Свързване"; -/* No comment provided by engineer. */ -"Connect directly" = "Свързване директно"; - /* No comment provided by engineer. */ "Connect incognito" = "Свързване инкогнито"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "свържете се с разработчиците на SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Свързване чрез линк на контакта"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Свързване чрез групов линк?"; - /* No comment provided by engineer. */ "Connect via link" = "Свърване чрез линк"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "свързан"; +/* rcv group event chat item */ +"connected directly" = "свързан директно"; + /* No comment provided by engineer. */ "connecting" = "свързване"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Контактът вече съществува"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Контактът и всички съобщения ще бъдат изтрити - това не може да бъде отменено!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "контактът има e2e криптиране"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Изтрий контакт"; -/* No comment provided by engineer. */ -"Delete contact?" = "Изтрий контакт?"; - /* No comment provided by engineer. */ "Delete database" = "Изтрий базата данни"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Открийте и се присъединете към групи"; -/* No comment provided by engineer. */ -"Display name" = "Показвано Име"; - -/* No comment provided by engineer. */ -"Display name:" = "Показвано име:"; - /* No comment provided by engineer. */ "Do it later" = "Отложи"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Грешка при създаване на групов линк"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Грешка при създаване на контакт с член"; + /* No comment provided by engineer. */ "Error creating profile!" = "Грешка при създаване на профил!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Грешка при изпращане на имейл"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Грешка при изпращане на съобщение за покана за контакт"; + /* No comment provided by engineer. */ "Error sending message" = "Грешка при изпращане на съобщение"; @@ -2227,7 +2215,8 @@ "observer" = "наблюдател"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "изключено"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Само вашият контакт може да изпраща гласови съобщения."; +/* No comment provided by engineer. */ +"Open" = "Отвори"; + /* No comment provided by engineer. */ "Open chat" = "Отвори чат"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Отваряне на база данни…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Отварянето на линка в браузъра може да намали поверителността и сигурността на връзката. Несигурните SimpleX линкове ще бъдат червени."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "или пишете на разработчиците"; - /* member role */ "owner" = "собственик"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Изпращайте потвърждениe за доставка на"; +/* No comment provided by engineer. */ +"send direct message" = "изпрати лично съобщение"; + /* No comment provided by engineer. */ "Send direct message" = "Изпрати лично съобщение"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Изпрати лично съобщение за свързване"; + /* No comment provided by engineer. */ "Send disappearing message" = "Изпрати изчезващо съобщение"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Криптирането работи и новото споразумение за криптиране не е необходимо. Това може да доведе до грешки при свързване!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Групата е напълно децентрализирана – видима е само за членовете."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хешът на предишното съобщение е различен."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Ще трябва да се идентифицирате, когато стартирате или възобновите приложението след 30 секунди във фонов режим."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Ще се присъедините към групата, към която този линк препраща, и ще се свържете с нейните членове."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Все още ще получавате обаждания и известия от заглушени профили, когато са активни."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата чат база данни не е криптирана - задайте парола, за да я криптирате."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Вашият чат профил ще бъде изпратен на членовете на групата"; - /* No comment provided by engineer. */ "Your chat profiles" = "Вашите чат профили"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 3d7d5f8fe2..26469bea98 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_kurzíva_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/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) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- připojit k [adresářová služba](simplex:/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.cibule) (BETA)!\n- doručenky (až 20 členů).\n- Rychlejší a stabilnější."; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- více stabilní doručování zpráv.\n- o trochu lepší skupiny.\n- a více!"; @@ -181,6 +184,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld minut"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%d nové jazyky rozhraní"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld vteřin"; @@ -443,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "Sestavení aplikace: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "Aplikace šifruje nové místní soubory (s výjimkou videí)."; + /* No comment provided by engineer. */ "App icon" = "Ikona aplikace"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Hlasové zprávy můžete posílat vy i váš kontakt."; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulharský, finský, thajský a ukrajinský - díky uživatelům a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +720,12 @@ /* server test step */ "Connect" = "Připojit"; -/* No comment provided by engineer. */ -"Connect directly" = "Připojit přímo"; - /* No comment provided by engineer. */ "Connect incognito" = "Spojit se inkognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "připojit se k vývojářům SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Připojit se přes odkaz"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Připojit se přes odkaz skupiny?"; - /* No comment provided by engineer. */ "Connect via link" = "Připojte se prostřednictvím odkazu"; @@ -735,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "připojeno"; +/* rcv group event chat item */ +"connected directly" = "připojeno přímo"; + /* No comment provided by engineer. */ "connecting" = "připojování"; @@ -789,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Kontakt již existuje"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Kontakt a všechny zprávy budou smazány - nelze to vzít zpět!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "kontakt má šifrování e2e"; @@ -843,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "Vytvořit odkaz"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Vytvořit jednorázovou pozvánku"; @@ -993,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Smazat kontakt"; -/* No comment provided by engineer. */ -"Delete contact?" = "Smazat kontakt?"; - /* No comment provided by engineer. */ "Delete database" = "Odstranění databáze"; @@ -1125,6 +1128,9 @@ /* authentication reason */ "Disable SimpleX Lock" = "Vypnutí zámku SimpleX"; +/* No comment provided by engineer. */ +"disabled" = "vypnut"; + /* No comment provided by engineer. */ "Disappearing message" = "Mizící zpráva"; @@ -1147,10 +1153,7 @@ "Disconnect" = "Odpojit"; /* No comment provided by engineer. */ -"Display name" = "Zobrazované jméno"; - -/* No comment provided by engineer. */ -"Display name:" = "Zobrazované jméno:"; +"Discover and join groups" = "Objevte a připojte skupiny"; /* No comment provided by engineer. */ "Do it later" = "Udělat později"; @@ -1242,6 +1245,12 @@ /* No comment provided by engineer. */ "Encrypt database?" = "Šifrovat databázi?"; +/* No comment provided by engineer. */ +"Encrypt local files" = "Šifrovat místní soubory"; + +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "Šifrovat uložené soubory a média"; + /* No comment provided by engineer. */ "Encrypted database" = "Zašifrovaná databáze"; @@ -1350,9 +1359,15 @@ /* No comment provided by engineer. */ "Error creating group link" = "Chyba při vytváření odkazu skupiny"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Chyba vytvoření kontaktu člena"; + /* No comment provided by engineer. */ "Error creating profile!" = "Chyba při vytváření profilu!"; +/* No comment provided by engineer. */ +"Error decrypting file" = "Chyba dešifrování souboru"; + /* No comment provided by engineer. */ "Error deleting chat database" = "Chyba při mazání databáze chatu"; @@ -1425,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Chyba odesílání e-mailu"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Chyba odeslání pozvánky kontaktu"; + /* No comment provided by engineer. */ "Error sending message" = "Chyba při odesílání zprávy"; @@ -2115,6 +2133,9 @@ /* No comment provided by engineer. */ "New database archive" = "Archiv nové databáze"; +/* No comment provided by engineer. */ +"New desktop app!" = "Nová desktopová aplikace!"; + /* No comment provided by engineer. */ "New display name" = "Nově zobrazované jméno"; @@ -2151,6 +2172,9 @@ /* No comment provided by engineer. */ "No contacts to add" = "Žádné kontakty k přidání"; +/* No comment provided by engineer. */ +"No delivery information" = "Žádné informace o dodání"; + /* No comment provided by engineer. */ "No device token!" = "Žádný token zařízení!"; @@ -2188,7 +2212,8 @@ "observer" = "pozorovatel"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "vypnuto"; /* No comment provided by engineer. */ @@ -2269,6 +2294,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Hlasové zprávy může odesílat pouze váš kontakt."; +/* No comment provided by engineer. */ +"Open" = "Otevřít"; + /* No comment provided by engineer. */ "Open chat" = "Otevřete chat"; @@ -2287,12 +2315,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Otvírání databáze…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Otevření odkazu v prohlížeči může snížit soukromí a bezpečnost připojení. Nedůvěryhodné odkazy SimpleX budou červené."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "nebo chat s vývojáři"; - /* member role */ "owner" = "vlastník"; @@ -2482,6 +2504,9 @@ /* No comment provided by engineer. */ "Read more in our GitHub repository." = "Další informace najdete v našem repozitáři GitHub."; +/* No comment provided by engineer. */ +"Receipts are disabled" = "Informace o dodání jsou zakázány"; + /* No comment provided by engineer. */ "received answer…" = "obdržel odpověď…"; @@ -2740,9 +2765,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Potvrzení o doručení zasílat na"; +/* No comment provided by engineer. */ +"send direct message" = "odeslat přímou zprávu"; + /* No comment provided by engineer. */ "Send direct message" = "Odeslat přímou zprávu"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Odeslat přímou zprávu pro připojení"; + /* No comment provided by engineer. */ "Send disappearing message" = "Poslat mizící zprávu"; @@ -2785,9 +2816,15 @@ /* No comment provided by engineer. */ "Sending receipts is disabled for %lld contacts" = "Odesílání potvrzení o doručení je vypnuto pro %lld kontakty"; +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld groups" = "Odesílání potvrzení o doručení vypnuto pro %lld skupiny"; + /* No comment provided by engineer. */ "Sending receipts is enabled for %lld contacts" = "Odesílání potvrzení o doručení je povoleno pro %lld kontakty"; +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld groups" = "Odesílání potvrzení o doručení povoleno pro %lld skupiny"; + /* No comment provided by engineer. */ "Sending via" = "Odesílání přes"; @@ -2872,6 +2909,9 @@ /* No comment provided by engineer. */ "Show developer options" = "Zobrazit možnosti vývojáře"; +/* No comment provided by engineer. */ +"Show last messages" = "Zobrazit poslední zprávy"; + /* No comment provided by engineer. */ "Show preview" = "Zobrazení náhledu"; @@ -2914,12 +2954,18 @@ /* simplex link type */ "SimpleX one-time invitation" = "Jednorázová pozvánka SimpleX"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "Zjednodušený inkognito režim"; + /* No comment provided by engineer. */ "Skip" = "Přeskočit"; /* No comment provided by engineer. */ "Skipped messages" = "Přeskočené zprávy"; +/* No comment provided by engineer. */ +"Small groups (max 20)" = "Malé skupiny (max. 20)"; + /* No comment provided by engineer. */ "SMP servers" = "SMP servery"; @@ -3058,9 +3104,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Šifrování funguje a nové povolení šifrování není vyžadováno. To může vyvolat chybu v připojení!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Skupina je plně decentralizovaná - je viditelná pouze pro členy."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Hash předchozí zprávy se liší."; @@ -3118,6 +3161,9 @@ /* notification title */ "this contact" = "tento kontakt"; +/* No comment provided by engineer. */ +"This group has over %lld members, delivery receipts are not sent." = "Tato skupina má více než %lld členů, potvrzení o doručení nejsou odesílány."; + /* No comment provided by engineer. */ "This group no longer exists." = "Tato skupina již neexistuje."; @@ -3154,6 +3200,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Chcete-li ověřit koncové šifrování u svého kontaktu, porovnejte (nebo naskenujte) kód na svých zařízeních."; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "Změnit inkognito režim při připojení."; + /* No comment provided by engineer. */ "Transport isolation" = "Izolace transportu"; @@ -3262,12 +3311,18 @@ /* No comment provided by engineer. */ "Use chat" = "Použijte chat"; +/* No comment provided by engineer. */ +"Use current profile" = "Použít aktuální profil"; + /* No comment provided by engineer. */ "Use for new connections" = "Použít pro nová připojení"; /* No comment provided by engineer. */ "Use iOS call interface" = "Použít rozhraní volání iOS"; +/* No comment provided by engineer. */ +"Use new incognito profile" = "Použít nový inkognito profil"; + /* No comment provided by engineer. */ "Use server" = "Použít server"; @@ -3541,9 +3596,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Při spuštění nebo obnovení aplikace po 30 sekundách na pozadí budete požádáni o ověření."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Připojíte se ke skupině, na kterou tento odkaz odkazuje, a spojíte se s jejími členy."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Stále budete přijímat volání a upozornění od umlčených profilů pokud budou aktivní."; @@ -3574,9 +3626,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Vaše chat databáze není šifrována – nastavte přístupovou frázi pro její šifrování."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Váš chat profil bude zaslán členům skupiny"; - /* No comment provided by engineer. */ "Your chat profiles" = "Vaše chat profily"; @@ -3610,6 +3659,9 @@ /* No comment provided by engineer. */ "Your privacy" = "Vaše soukromí"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "Váš profil **%@** bude sdílen."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty.\nServery SimpleX nevidí váš profil."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 111ce0d916..2fe8d87b69 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_kursiv_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/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) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- Verbinden mit dem [Directory-Service](simplex:/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) (BETA)!\n- Empfangsbestätigungen (für bis zu 20 Mitglieder).\n- Schneller und stabiler."; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilere Zustellung von Nachrichten.\n- ein bisschen verbesserte Gruppen.\n- und mehr!"; @@ -181,6 +184,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld Minuten"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lld neue Sprachen für die Bedienoberfläche"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld Sekunde(n)"; @@ -443,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "App Build: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt."; + /* No comment provided by engineer. */ "App icon" = "App-Icon"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden."; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarisch, Finnisch, Thailändisch und Ukrainisch - Dank der Nutzer und [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +720,12 @@ /* server test step */ "Connect" = "Verbinden"; -/* No comment provided by engineer. */ -"Connect directly" = "Direkt verbinden"; - /* No comment provided by engineer. */ "Connect incognito" = "Inkognito verbinden"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "Mit den SimpleX Chat-Entwicklern verbinden."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Über den Kontakt-Link verbinden"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Über den Gruppen-Link verbinden?"; - /* No comment provided by engineer. */ "Connect via link" = "Über einen Link verbinden"; @@ -735,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "Verbunden"; +/* rcv group event chat item */ +"connected directly" = "Direkt miteinander verbunden"; + /* No comment provided by engineer. */ "connecting" = "verbinde"; @@ -789,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Der Kontakt ist bereits vorhanden"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Der Kontakt und alle Nachrichten werden gelöscht - dies kann nicht rückgängig gemacht werden!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "Kontakt nutzt E2E-Verschlüsselung"; @@ -843,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "Link erzeugen"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Einmal-Einladungslink erstellen"; @@ -993,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Kontakt löschen"; -/* No comment provided by engineer. */ -"Delete contact?" = "Kontakt löschen?"; - /* No comment provided by engineer. */ "Delete database" = "Datenbank löschen"; @@ -1150,10 +1153,7 @@ "Disconnect" = "Trennen"; /* No comment provided by engineer. */ -"Display name" = "Angezeigter Name"; - -/* No comment provided by engineer. */ -"Display name:" = "Angezeigter Name:"; +"Discover and join groups" = "Gruppen entdecken und ihnen beitreten"; /* No comment provided by engineer. */ "Do it later" = "Später wiederholen"; @@ -1248,6 +1248,9 @@ /* No comment provided by engineer. */ "Encrypt local files" = "Lokale Dateien verschlüsseln"; +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "Gespeicherte Dateien & Medien verschlüsseln"; + /* No comment provided by engineer. */ "Encrypted database" = "Verschlüsselte Datenbank"; @@ -1356,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fehler beim Erzeugen des Gruppen-Links"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Fehler beim Anlegen eines Mitglied-Kontaktes"; + /* No comment provided by engineer. */ "Error creating profile!" = "Fehler beim Erstellen des Profils!"; @@ -1434,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Fehler beim Senden der eMail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Fehler beim Senden einer Mitglied-Kontakt-Einladung"; + /* No comment provided by engineer. */ "Error sending message" = "Fehler beim Senden der Nachricht"; @@ -2127,6 +2136,9 @@ /* No comment provided by engineer. */ "New database archive" = "Neues Datenbankarchiv"; +/* No comment provided by engineer. */ +"New desktop app!" = "Neue Desktop-App!"; + /* No comment provided by engineer. */ "New display name" = "Neuer Anzeigename"; @@ -2203,7 +2215,8 @@ "observer" = "Beobachter"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "Aus"; /* No comment provided by engineer. */ @@ -2284,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Nur Ihr Kontakt kann Sprachnachrichten versenden."; +/* No comment provided by engineer. */ +"Open" = "Öffnen"; + /* No comment provided by engineer. */ "Open chat" = "Chat öffnen"; @@ -2302,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Öffne Datenbank …"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden Rot sein."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "oder chatten Sie mit den Entwicklern"; - /* member role */ "owner" = "Eigentümer"; @@ -2758,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Empfangsbestätigungen senden an"; +/* No comment provided by engineer. */ +"send direct message" = "Direktnachricht senden"; + /* No comment provided by engineer. */ "Send direct message" = "Direktnachricht senden"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Eine Direktnachricht zum Verbinden senden"; + /* No comment provided by engineer. */ "Send disappearing message" = "Verschwindende Nachricht senden"; @@ -2941,6 +2957,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX-Einmal-Einladung"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "Vereinfachter Inkognito-Modus"; + /* No comment provided by engineer. */ "Skip" = "Überspringen"; @@ -3088,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Die Verschlüsselung funktioniert und ein neues Verschlüsselungsabkommen ist nicht erforderlich. Es kann zu Verbindungsfehlern kommen!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Die Gruppe ist vollständig dezentralisiert – sie ist nur für Mitglieder sichtbar."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Der Hash der vorherigen Nachricht unterscheidet sich."; @@ -3187,6 +3203,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen."; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "Inkognito beim Verbinden einschalten."; + /* No comment provided by engineer. */ "Transport isolation" = "Transport-Isolation"; @@ -3580,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Sie müssen sich authentifizieren, wenn Sie die im Hintergrund befindliche App nach 30 Sekunden starten oder fortsetzen."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Sie werden der Gruppe beitreten, auf die sich dieser Link bezieht und sich mit deren Gruppenmitgliedern verbinden."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind."; @@ -3613,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Ihre Chat-Datenbank ist nicht verschlüsselt. Bitte legen Sie ein Passwort fest, um sie zu schützen."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Ihr Chat-Profil wird an Gruppenmitglieder gesendet"; - /* No comment provided by engineer. */ "Your chat profiles" = "Meine Chat-Profile"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index c4180ea153..d9b14eddbd 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_italic_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/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) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- conexión al [servicio de directorio](simplex:/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) (BETA)!\n- confirmaciones de entrega (hasta 20 miembros).\n- mayor rapidez y estabilidad."; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- entrega de mensajes más estable.\n- grupos un poco mejores.\n- ¡y más!"; @@ -181,6 +184,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld minutos"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lld idiomas de interfaz nuevos"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld segundo(s)"; @@ -209,10 +215,10 @@ "%lldw" = "%lldw"; /* No comment provided by engineer. */ -"%u messages failed to decrypt." = "%u mensajes no pudieron ser descifrados."; +"%u messages failed to decrypt." = "%u mensaje(s) no ha(n) podido ser descifrado(s)."; /* No comment provided by engineer. */ -"%u messages skipped." = "%u mensajes omitidos."; +"%u messages skipped." = "%u mensaje(s) omitido(s)."; /* No comment provided by engineer. */ "`a + b`" = "\\`a + b`"; @@ -443,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "Compilación app: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "Cifrado de los nuevos archivos locales (excepto vídeos)."; + /* No comment provided by engineer. */ "App icon" = "Icono aplicación"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Tanto tú como tu contacto podéis enviar mensajes de voz."; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Búlgaro, Finlandés, Tailandés y Ucraniano - gracias a los usuarios y [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Mediante perfil (por defecto) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +720,12 @@ /* server test step */ "Connect" = "Conectar"; -/* No comment provided by engineer. */ -"Connect directly" = "Conectar directamente"; - /* No comment provided by engineer. */ "Connect incognito" = "Conectar incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "contacta con los desarrolladores de SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Conectar mediante enlace de contacto"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "¿Conectar mediante enlace de grupo?"; - /* No comment provided by engineer. */ "Connect via link" = "Conectar mediante enlace"; @@ -735,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "conectado"; +/* rcv group event chat item */ +"connected directly" = "conectado directamente"; + /* No comment provided by engineer. */ "connecting" = "conectando"; @@ -789,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "El contácto ya existe"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "El contacto y todos los mensajes serán eliminados. ¡No podrá deshacerse!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "el contacto dispone de cifrado de extremo a extremo"; @@ -843,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "Crear enlace"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Crea enlace de invitación de un uso"; @@ -993,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Eliminar contacto"; -/* No comment provided by engineer. */ -"Delete contact?" = "Eliminar contacto?"; - /* No comment provided by engineer. */ "Delete database" = "Eliminar base de datos"; @@ -1150,10 +1153,7 @@ "Disconnect" = "Desconectar"; /* No comment provided by engineer. */ -"Display name" = "Nombre mostrado"; - -/* No comment provided by engineer. */ -"Display name:" = "Nombre mostrado:"; +"Discover and join groups" = "Descubre y únete a grupos"; /* No comment provided by engineer. */ "Do it later" = "Hacer más tarde"; @@ -1245,6 +1245,12 @@ /* No comment provided by engineer. */ "Encrypt database?" = "¿Cifrar base de datos?"; +/* No comment provided by engineer. */ +"Encrypt local files" = "Cifra archivos locales"; + +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "Cifra archivos almacenados y multimedia"; + /* No comment provided by engineer. */ "Encrypted database" = "Base de datos cifrada"; @@ -1353,9 +1359,15 @@ /* No comment provided by engineer. */ "Error creating group link" = "Error al crear enlace de grupo"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Error al establecer contacto con el miembro"; + /* No comment provided by engineer. */ "Error creating profile!" = "¡Error al crear perfil!"; +/* No comment provided by engineer. */ +"Error decrypting file" = "Error al descifrar el archivo"; + /* No comment provided by engineer. */ "Error deleting chat database" = "Error al eliminar base de datos"; @@ -1428,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Error al enviar email"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Error al enviar mensaje de invitación al contacto"; + /* No comment provided by engineer. */ "Error sending message" = "Error al enviar mensaje"; @@ -1645,10 +1660,10 @@ "Group welcome message" = "Mensaje de bienvenida en grupos"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "El grupo se eliminará para todos los miembros. ¡No podrá deshacerse!"; +"Group will be deleted for all members - this cannot be undone!" = "El grupo será eliminado para todos los miembros. ¡No podrá deshacerse!"; /* No comment provided by engineer. */ -"Group will be deleted for you - this cannot be undone!" = "El grupo se eliminará para tí. ¡No podrá deshacerse!"; +"Group will be deleted for you - this cannot be undone!" = "El grupo será eliminado para tí. ¡No podrá deshacerse!"; /* No comment provided by engineer. */ "Help" = "Ayuda"; @@ -2121,6 +2136,9 @@ /* No comment provided by engineer. */ "New database archive" = "Nuevo archivo de bases de datos"; +/* No comment provided by engineer. */ +"New desktop app!" = "Nueva aplicación para PC!"; + /* No comment provided by engineer. */ "New display name" = "Nuevo nombre mostrado"; @@ -2197,7 +2215,8 @@ "observer" = "observador"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "desactivado"; /* No comment provided by engineer. */ @@ -2278,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sólo tu contacto puede enviar mensajes de voz."; +/* No comment provided by engineer. */ +"Open" = "Abrir"; + /* No comment provided by engineer. */ "Open chat" = "Abrir chat"; @@ -2296,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Abriendo base de datos…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces SimpleX que no son de confianza aparecerán en rojo."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "o contacta mediante Chat con los desarrolladores"; - /* member role */ "owner" = "propietario"; @@ -2752,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Enviar confirmaciones de entrega a"; +/* No comment provided by engineer. */ +"send direct message" = "Enviar mensaje directo"; + /* No comment provided by engineer. */ "Send direct message" = "Enviar mensaje directo"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Enviar mensaje directo para conectar"; + /* No comment provided by engineer. */ "Send disappearing message" = "Enviar mensaje temporal"; @@ -2935,6 +2957,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "Invitación SimpleX de un uso"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "Modo incógnito simplificado"; + /* No comment provided by engineer. */ "Skip" = "Omitir"; @@ -3082,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "El cifrado funciona y un cifrado nuevo no es necesario. ¡Podría dar lugar a errores de conexión!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "El grupo está totalmente descentralizado y sólo es visible para los miembros."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "El hash del mensaje anterior es diferente."; @@ -3181,6 +3203,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Para comprobar el cifrado de extremo a extremo con tu contacto compara (o escanea) el código en tus dispositivos."; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "Activa incógnito al conectar."; + /* No comment provided by engineer. */ "Transport isolation" = "Aislamiento de transporte"; @@ -3574,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Se te pedirá identificarte cuándo inicies o continues usando la aplicación tras 30 segundos en segundo plano."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Te unirás al grupo al que hace referencia este enlace y te conectarás con sus miembros."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Seguirás recibiendo llamadas y notificaciones de los perfiles silenciados cuando estén activos."; @@ -3607,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "La base de datos no está cifrada - establece una contraseña para cifrarla."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Tu perfil será enviado a los miembros del grupo"; - /* No comment provided by engineer. */ "Your chat profiles" = "Mis perfiles"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index a917c4a0b4..fad3a54b8b 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -181,6 +181,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld minuuttia"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lld uutta käyttöliittymän kieltä"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld sekunti(a)"; @@ -708,21 +711,12 @@ /* server test step */ "Connect" = "Yhdistä"; -/* No comment provided by engineer. */ -"Connect directly" = "Yhdistä suoraan"; - /* No comment provided by engineer. */ "Connect incognito" = "Yhdistä Incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "ole yhteydessä SimpleX Chat -kehittäjiin."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Yhdistä kontaktilinkillä"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Yhdistetäänkö ryhmälinkin kautta?"; - /* No comment provided by engineer. */ "Connect via link" = "Yhdistä linkin kautta"; @@ -789,9 +783,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Kontakti on jo olemassa"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Kontakti ja kaikki viestit poistetaan - tätä ei voi perua!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "kontaktilla on e2e-salaus"; @@ -843,6 +834,9 @@ /* No comment provided by engineer. */ "Create link" = "Luo linkki"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Luo kertakutsulinkki"; @@ -993,9 +987,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Poista kontakti"; -/* No comment provided by engineer. */ -"Delete contact?" = "Poista kontakti?"; - /* No comment provided by engineer. */ "Delete database" = "Poista tietokanta"; @@ -1150,10 +1141,7 @@ "Disconnect" = "Katkaise"; /* No comment provided by engineer. */ -"Display name" = "Näyttönimi"; - -/* No comment provided by engineer. */ -"Display name:" = "Näyttönimi:"; +"Discover and join groups" = "Löydä ryhmiä ja liity niihin"; /* No comment provided by engineer. */ "Do it later" = "Tee myöhemmin"; @@ -2203,7 +2191,8 @@ "observer" = "tarkkailija"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "pois"; /* No comment provided by engineer. */ @@ -2302,12 +2291,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Avataan tietokantaa…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "tai keskustele kehittäjien kanssa"; - /* member role */ "owner" = "omistaja"; @@ -3088,9 +3071,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Ryhmä on täysin hajautettu - se näkyy vain jäsenille."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Edellisen viestin tarkiste on erilainen."; @@ -3580,9 +3560,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Sinun on tunnistauduttava, kun käynnistät sovelluksen tai jatkat sen käyttöä 30 sekunnin tauon jälkeen."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia."; @@ -3613,9 +3590,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Keskustelut-tietokantasi ei ole salattu - aseta tunnuslause sen salaamiseksi."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Keskusteluprofiilisi lähetetään ryhmän jäsenille"; - /* No comment provided by engineer. */ "Your chat profiles" = "Keskusteluprofiilisi"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index f9f7703382..52c114a9de 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Se connecter"; -/* No comment provided by engineer. */ -"Connect directly" = "Se connecter directement"; - /* No comment provided by engineer. */ "Connect incognito" = "Se connecter incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "se connecter aux developpeurs de SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Se connecter via un lien de contact"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Se connecter via le lien du groupe ?"; - /* No comment provided by engineer. */ "Connect via link" = "Se connecter via un lien"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "connecté"; +/* rcv group event chat item */ +"connected directly" = "s'est connecté.e de manière directe"; + /* No comment provided by engineer. */ "connecting" = "connexion"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Contact déjà existant"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Le contact et tous les messages seront supprimés - impossible de revenir en arrière !"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "Ce contact a le chiffrement de bout en bout"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Supprimer le contact"; -/* No comment provided by engineer. */ -"Delete contact?" = "Supprimer le contact ?"; - /* No comment provided by engineer. */ "Delete database" = "Supprimer la base de données"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Découvrir et rejoindre des groupes"; -/* No comment provided by engineer. */ -"Display name" = "Nom affiché"; - -/* No comment provided by engineer. */ -"Display name:" = "Nom affiché :"; - /* No comment provided by engineer. */ "Do it later" = "Faites-le plus tard"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Erreur lors de la création du lien du groupe"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Erreur lors de la création du contact du membre"; + /* No comment provided by engineer. */ "Error creating profile!" = "Erreur lors de la création du profil !"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Erreur lors de l'envoi de l'e-mail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Erreur lors de l'envoi de l'invitation de contact d'un membre"; + /* No comment provided by engineer. */ "Error sending message" = "Erreur lors de l'envoi du message"; @@ -2227,7 +2215,8 @@ "observer" = "observateur"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "off"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Seul votre contact peut envoyer des messages vocaux."; +/* No comment provided by engineer. */ +"Open" = "Ouvrir"; + /* No comment provided by engineer. */ "Open chat" = "Ouvrir le chat"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Ouverture de la base de données…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "ou ici pour discuter avec les développeurs"; - /* member role */ "owner" = "propriétaire"; @@ -2783,7 +2769,13 @@ "Send delivery receipts to" = "Envoyer les accusés de réception à"; /* No comment provided by engineer. */ -"Send direct message" = "Envoi de message direct"; +"send direct message" = "envoyer un message direct"; + +/* No comment provided by engineer. */ +"Send direct message" = "Envoyer un message direct"; + +/* No comment provided by engineer. */ +"Send direct message to connect" = "Envoyer un message direct pour vous connecter"; /* No comment provided by engineer. */ "Send disappearing message" = "Envoyer un message éphémère"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Le chiffrement fonctionne et le nouvel accord de chiffrement n'est pas nécessaire. Cela peut provoquer des erreurs de connexion !"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Le groupe est entièrement décentralisé – il n'est visible que par ses membres."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Le hash du message précédent est différent."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l'application après 30 secondes en arrière-plan."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu'ils sont actifs."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Votre base de données de chat n'est pas chiffrée - définisez une phrase secrète."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Votre profil de chat sera envoyé aux membres du groupe"; - /* No comment provided by engineer. */ "Your chat profiles" = "Vos profils de chat"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index a9a663dfdf..ac2cfc4964 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Connetti"; -/* No comment provided by engineer. */ -"Connect directly" = "Connetti direttamente"; - /* No comment provided by engineer. */ "Connect incognito" = "Connetti in incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "connettiti agli sviluppatori di SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Connetti via link del contatto"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Connettere via link del gruppo?"; - /* No comment provided by engineer. */ "Connect via link" = "Connetti via link"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "connesso/a"; +/* rcv group event chat item */ +"connected directly" = "si è connesso/a direttamente"; + /* No comment provided by engineer. */ "connecting" = "in connessione"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Il contatto esiste già"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Il contatto e tutti i messaggi verranno eliminati, non è reversibile!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "il contatto ha la crittografia e2e"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Elimina contatto"; -/* No comment provided by engineer. */ -"Delete contact?" = "Eliminare il contatto?"; - /* No comment provided by engineer. */ "Delete database" = "Elimina database"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Scopri ed unisciti ai gruppi"; -/* No comment provided by engineer. */ -"Display name" = "Nome da mostrare"; - -/* No comment provided by engineer. */ -"Display name:" = "Nome da mostrare:"; - /* No comment provided by engineer. */ "Do it later" = "Fallo dopo"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Errore nella creazione del link del gruppo"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Errore di creazione del contatto"; + /* No comment provided by engineer. */ "Error creating profile!" = "Errore nella creazione del profilo!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Errore nell'invio dell'email"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Errore di invio dell'invito al contatto"; + /* No comment provided by engineer. */ "Error sending message" = "Errore nell'invio del messaggio"; @@ -2026,7 +2014,7 @@ "Member" = "Membro"; /* rcv group event chat item */ -"member connected" = "è connesso/a"; +"member connected" = "si è connesso/a"; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Il ruolo del membro verrà cambiato in \"%@\". Tutti i membri del gruppo verranno avvisati."; @@ -2227,7 +2215,8 @@ "observer" = "osservatore"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "off"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Solo il tuo contatto può inviare messaggi vocali."; +/* No comment provided by engineer. */ +"Open" = "Apri"; + /* No comment provided by engineer. */ "Open chat" = "Apri chat"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Apertura del database…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Aprire il link nel browser può ridurre la privacy e la sicurezza della connessione. I link SimpleX non fidati saranno in rosso."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "o scrivi agli sviluppatori"; - /* member role */ "owner" = "proprietario"; @@ -2600,13 +2586,13 @@ "Remove passphrase from keychain?" = "Rimuovere la password dal portachiavi?"; /* No comment provided by engineer. */ -"removed" = "ha rimosso"; +"removed" = "rimosso"; /* rcv group event chat item */ "removed %@" = "ha rimosso %@"; /* rcv group event chat item */ -"removed you" = "sei stato/a rimosso/a"; +"removed you" = "ti ha rimosso/a"; /* No comment provided by engineer. */ "Renegotiate" = "Rinegoziare"; @@ -2654,7 +2640,7 @@ "Reveal" = "Rivela"; /* No comment provided by engineer. */ -"Revert" = "Annulla"; +"Revert" = "Ripristina"; /* No comment provided by engineer. */ "Revoke" = "Revoca"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Invia ricevute di consegna a"; +/* No comment provided by engineer. */ +"send direct message" = "invia messaggio diretto"; + /* No comment provided by engineer. */ "Send direct message" = "Invia messaggio diretto"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Invia messaggio diretto per connetterti"; + /* No comment provided by engineer. */ "Send disappearing message" = "Invia messaggio a tempo"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Il gruppo è completamente decentralizzato: è visibile solo ai membri."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "L'hash del messaggio precedente è diverso."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Dovrai autenticarti quando avvii o riapri l'app dopo 30 secondi in secondo piano."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Continuerai a ricevere chiamate e notifiche da profili silenziati quando sono attivi."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Il tuo database della chat non è crittografato: imposta la password per crittografarlo."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Il tuo profilo di chat verrà inviato ai membri del gruppo"; - /* No comment provided by engineer. */ "Your chat profiles" = "I tuoi profili di chat"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 6fadf87590..ff97d71daf 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -181,6 +181,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld 分"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lldつの新しいインターフェース言語"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld 秒"; @@ -443,6 +446,9 @@ /* No comment provided by engineer. */ "App build: %@" = "アプリのビルド: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "アプリは新しいローカルファイル(ビデオを除く)を暗号化します。"; + /* No comment provided by engineer. */ "App icon" = "アプリのアイコン"; @@ -536,6 +542,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "あなたと連絡相手が音声メッセージを送信できます。"; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "ブルガリア語、フィンランド語、タイ語、ウクライナ語 - ユーザーと [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)に感謝します!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "チャット プロファイル経由 (デフォルト) または [接続経由](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +717,12 @@ /* server test step */ "Connect" = "接続"; -/* No comment provided by engineer. */ -"Connect directly" = "直接接続する"; - /* No comment provided by engineer. */ "Connect incognito" = "シークレットモードで接続"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "SimpleX Chat 開発者に接続します。"; -/* No comment provided by engineer. */ -"Connect via contact link" = "連絡先リンク経由で接続しますか?"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "グループリンク経由で接続しますか?"; - /* No comment provided by engineer. */ "Connect via link" = "リンク経由で接続"; @@ -789,9 +789,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "連絡先に既に存在します"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "連絡先と全メッセージが削除されます (※元に戻せません※)!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "連絡先はエンドツーエンド暗号化があります"; @@ -843,6 +840,9 @@ /* No comment provided by engineer. */ "Create link" = "リンクを生成する"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "[デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "使い捨ての招待リンクを生成する"; @@ -993,9 +993,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "連絡先を削除"; -/* No comment provided by engineer. */ -"Delete contact?" = "連絡先を削除しますか?"; - /* No comment provided by engineer. */ "Delete database" = "データベースを削除"; @@ -1080,6 +1077,9 @@ /* No comment provided by engineer. */ "Delivery receipts are disabled!" = "Delivery receipts are disabled!"; +/* No comment provided by engineer. */ +"Delivery receipts!" = "配信通知!"; + /* No comment provided by engineer. */ "Description" = "説明"; @@ -1147,10 +1147,7 @@ "Disconnect" = "切断"; /* No comment provided by engineer. */ -"Display name" = "表示名"; - -/* No comment provided by engineer. */ -"Display name:" = "表示名:"; +"Discover and join groups" = "グループを見つけて参加する"; /* No comment provided by engineer. */ "Do it later" = "後で行う"; @@ -1245,6 +1242,9 @@ /* No comment provided by engineer. */ "Encrypt local files" = "ローカルファイルを暗号化する"; +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "保存されたファイルとメディアを暗号化する"; + /* No comment provided by engineer. */ "Encrypted database" = "暗号化済みデータベース"; @@ -1353,6 +1353,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "グループリンク生成にエラー発生"; +/* No comment provided by engineer. */ +"Error creating member contact" = "メンバー連絡先の作成中にエラーが発生"; + /* No comment provided by engineer. */ "Error creating profile!" = "プロフィール作成にエラー発生!"; @@ -1428,6 +1431,9 @@ /* No comment provided by engineer. */ "Error sending email" = "メールの送信にエラー発生"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "招待メッセージの送信エラー"; + /* No comment provided by engineer. */ "Error sending message" = "メッセージ送信にエラー発生"; @@ -2115,6 +2121,9 @@ /* No comment provided by engineer. */ "New database archive" = "新しいデータベースのアーカイブ"; +/* No comment provided by engineer. */ +"New desktop app!" = "新しいデスクトップアプリ!"; + /* No comment provided by engineer. */ "New display name" = "新たな表示名"; @@ -2191,7 +2200,8 @@ "observer" = "オブザーバー"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "オフ"; /* No comment provided by engineer. */ @@ -2272,6 +2282,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "音声メッセージを送れるのはあなたの連絡相手だけです。"; +/* No comment provided by engineer. */ +"Open" = "開く"; + /* No comment provided by engineer. */ "Open chat" = "チャットを開く"; @@ -2290,12 +2303,6 @@ /* No comment provided by engineer. */ "Opening database…" = "データベースを開いています…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "ブラウザでリンクを開くと接続のプライバシーとセキュリティが下がる可能性があります。信頼されないSimpleXリンクは読み込まれません。"; - -/* No comment provided by engineer. */ -"or chat with the developers" = "または開発者とチャットする"; - /* member role */ "owner" = "オーナー"; @@ -2743,6 +2750,9 @@ /* No comment provided by engineer. */ "Send direct message" = "ダイレクトメッセージを送信"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "ダイレクトメッセージを送信して接続する"; + /* No comment provided by engineer. */ "Send disappearing message" = "消えるメッセージを送信"; @@ -2902,6 +2912,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX使い捨て招待リンク"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "シークレットモードの簡素化"; + /* No comment provided by engineer. */ "Skip" = "スキップ"; @@ -3049,9 +3062,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "グループは完全分散型で、メンバーしか内容を見れません。"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "以前のメッセージとハッシュ値が異なります。"; @@ -3538,9 +3548,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "起動時、または非アクティブ状態で30秒が経った後に戻ると、認証する必要となります。"; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "このリンクのグループに参加し、そのメンバーに繋がります。"; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "ミュートされたプロフィールがアクティブな場合でも、そのプロフィールからの通話や通知は引き続き受信します。"; @@ -3571,9 +3578,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "チャット データベースは暗号化されていません - 暗号化するにはパスフレーズを設定してください。"; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "あなたのチャットプロフィールが他のグループメンバーに送られます"; - /* No comment provided by engineer. */ "Your chat profiles" = "あなたのチャットプロフィール"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index a218eeeff3..5c53c0c34e 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Verbind"; -/* No comment provided by engineer. */ -"Connect directly" = "Verbind direct"; - /* No comment provided by engineer. */ "Connect incognito" = "Verbind incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "maak verbinding met SimpleX Chat-ontwikkelaars."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Verbinden via contact link?"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Verbinden via groep link?"; - /* No comment provided by engineer. */ "Connect via link" = "Maak verbinding via link"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "verbonden"; +/* rcv group event chat item */ +"connected directly" = "direct verbonden"; + /* No comment provided by engineer. */ "connecting" = "Verbinden"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Contact bestaat al"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Contact en alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "contact heeft e2e-codering"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Verwijder contact"; -/* No comment provided by engineer. */ -"Delete contact?" = "Verwijder contact?"; - /* No comment provided by engineer. */ "Delete database" = "Database verwijderen"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; -/* No comment provided by engineer. */ -"Display name" = "Weergavenaam"; - -/* No comment provided by engineer. */ -"Display name:" = "Weergavenaam:"; - /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fout bij maken van groep link"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Fout bij aanmaken contact"; + /* No comment provided by engineer. */ "Error creating profile!" = "Fout bij aanmaken van profiel!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Fout bij het verzenden van e-mail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Fout bij verzenden van contact uitnodiging"; + /* No comment provided by engineer. */ "Error sending message" = "Fout bij verzenden van bericht"; @@ -1894,10 +1882,10 @@ "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Het maakt het mogelijk om veel anonieme verbindingen te hebben zonder enige gedeelde gegevens tussen hen in een enkel chat profiel."; /* No comment provided by engineer. */ -"It can happen when you or your connection used the old database backup." = "Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt."; +"It can happen when you or your connection used the old database backup." = "Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt."; /* No comment provided by engineer. */ -"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Het kan gebeuren wanneer:\n1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server.\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude databaseback-up heeft gebruikt.\n3. De verbinding is verbroken."; +"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Het kan gebeuren wanneer:\n1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server.\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude database back-up heeft gebruikt.\n3. De verbinding is verbroken."; /* No comment provided by engineer. */ "It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Het lijkt erop dat u al bent verbonden via deze link. Als dit niet het geval is, is er een fout opgetreden (%@)."; @@ -2227,7 +2215,8 @@ "observer" = "Waarnemer"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "uit"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Alleen uw contact kan spraak berichten verzenden."; +/* No comment provided by engineer. */ +"Open" = "Open"; + /* No comment provided by engineer. */ "Open chat" = "Gesprekken openen"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Database openen…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Het openen van de link in de browser kan de privacy en beveiliging van de verbinding verminderen. Niet vertrouwde SimpleX links worden rood weergegeven."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "of praat met de ontwikkelaars"; - /* member role */ "owner" = "Eigenaar"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Stuur ontvangstbewijzen naar"; +/* No comment provided by engineer. */ +"send direct message" = "stuur een direct bericht"; + /* No comment provided by engineer. */ "Send direct message" = "Direct bericht sturen"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Stuur een direct bericht om verbinding te maken"; + /* No comment provided by engineer. */ "Send disappearing message" = "Stuur een verdwijnend bericht"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "De versleuteling werkt en de nieuwe versleutelingsovereenkomst is niet vereist. Dit kan leiden tot verbindingsfouten!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "De groep is volledig gedecentraliseerd – het is alleen zichtbaar voor de leden."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "De hash van het vorige bericht is anders."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "U moet zich authenticeren wanneer u de app na 30 seconden op de achtergrond start of hervat."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "U wordt lid van de groep waar deze link naar verwijst en maakt verbinding met de groepsleden."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "U ontvangt nog steeds oproepen en meldingen van gedempte profielen wanneer deze actief zijn."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Uw chat database is niet versleuteld, stel een wachtwoord in om deze te versleutelen."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Uw chat profiel wordt verzonden naar de groepsleden"; - /* No comment provided by engineer. */ "Your chat profiles" = "Uw chat profielen"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index d80ff67d1f..1c57568f90 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Połącz"; -/* No comment provided by engineer. */ -"Connect directly" = "Połącz bezpośrednio"; - /* No comment provided by engineer. */ "Connect incognito" = "Połącz incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "połącz się z deweloperami SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Połącz przez link kontaktowy"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Połącz się przez link grupowy?"; - /* No comment provided by engineer. */ "Connect via link" = "Połącz się przez link"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "połączony"; +/* rcv group event chat item */ +"connected directly" = "połącz bezpośrednio"; + /* No comment provided by engineer. */ "connecting" = "łączenie"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Kontakt już istnieje"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Kontakt i wszystkie wiadomości zostaną usunięte - nie można tego cofnąć!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "kontakt posiada szyfrowanie e2e"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Usuń Kontakt"; -/* No comment provided by engineer. */ -"Delete contact?" = "Usunąć kontakt?"; - /* No comment provided by engineer. */ "Delete database" = "Usuń bazę danych"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Odkrywaj i dołączaj do grup"; -/* No comment provided by engineer. */ -"Display name" = "Wyświetlana nazwa"; - -/* No comment provided by engineer. */ -"Display name:" = "Wyświetlana nazwa:"; - /* No comment provided by engineer. */ "Do it later" = "Zrób to później"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Błąd tworzenia linku grupy"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Błąd tworzenia kontaktu członka"; + /* No comment provided by engineer. */ "Error creating profile!" = "Błąd tworzenia profilu!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Błąd wysyłania e-mail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Błąd wysyłania zaproszenia kontaktu członka"; + /* No comment provided by engineer. */ "Error sending message" = "Błąd wysyłania wiadomości"; @@ -2227,7 +2215,8 @@ "observer" = "obserwator"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "wyłączony"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Tylko Twój kontakt może wysyłać wiadomości głosowe."; +/* No comment provided by engineer. */ +"Open" = "Otwórz"; + /* No comment provided by engineer. */ "Open chat" = "Otwórz czat"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Otwieranie bazy danych…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Otwarcie łącza w przeglądarce może zmniejszyć prywatność i bezpieczeństwo połączenia. Niezaufane linki SimpleX będą miały kolor czerwony."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "lub porozmawiać z deweloperami"; - /* member role */ "owner" = "właściciel"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Wyślij potwierdzenia dostawy do"; +/* No comment provided by engineer. */ +"send direct message" = "wyślij wiadomość bezpośrednią"; + /* No comment provided by engineer. */ "Send direct message" = "Wyślij wiadomość bezpośrednią"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Wyślij wiadomość bezpośrednią aby połączyć"; + /* No comment provided by engineer. */ "Send disappearing message" = "Wyślij znikającą wiadomość"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Grupa jest w pełni zdecentralizowana – jest widoczna tylko dla członków."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Hash poprzedniej wiadomości jest inny."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Uwierzytelnienie będzie wymagane przy uruchamianiu lub wznawianiu aplikacji po 30 sekundach w tle."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Dołączysz do grupy, do której odnosi się ten link i połączysz się z jej członkami."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Nadal będziesz otrzymywać połączenia i powiadomienia z wyciszonych profili, gdy są one aktywne."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją zaszyfrować."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Twój profil czatu zostanie wysłany do członków grupy"; - /* No comment provided by engineer. */ "Your chat profiles" = "Twoje profile czatu"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 7857870472..73f06afeee 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Соединиться"; -/* No comment provided by engineer. */ -"Connect directly" = "Соединиться напрямую"; - /* No comment provided by engineer. */ "Connect incognito" = "Соединиться Инкогнито"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "соединитесь с разработчиками."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Соединиться через ссылку-контакт"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Соединиться через ссылку группы?"; - /* No comment provided by engineer. */ "Connect via link" = "Соединиться через ссылку"; @@ -801,9 +792,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Существующий контакт"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Контакт и все сообщения будут удалены - это действие нельзя отменить!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "у контакта есть e2e шифрование"; @@ -1008,9 +996,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Удалить контакт"; -/* No comment provided by engineer. */ -"Delete contact?" = "Удалить контакт?"; - /* No comment provided by engineer. */ "Delete database" = "Удалить данные чата"; @@ -1167,12 +1152,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Найдите и вступите в группы"; -/* No comment provided by engineer. */ -"Display name" = "Имя профиля"; - -/* No comment provided by engineer. */ -"Display name:" = "Имя профиля:"; - /* No comment provided by engineer. */ "Do it later" = "Отложить"; @@ -2227,7 +2206,8 @@ "observer" = "читатель"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "нет"; /* No comment provided by engineer. */ @@ -2326,12 +2306,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Открытие базы данных…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "или соединитесь с разработчиками"; - /* member role */ "owner" = "владелец"; @@ -3115,9 +3089,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Группа полностью децентрализована — она видна только членам."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хэш предыдущего сообщения отличается."; @@ -3610,9 +3581,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Вы вступите в группу, на которую ссылается эта ссылка, и соединитесь с её членами."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Вы все равно получите звонки и уведомления в профилях без звука, когда они активные."; @@ -3643,9 +3611,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Ваш профиль чата будет отправлен членам группы"; - /* No comment provided by engineer. */ "Your chat profiles" = "Ваши профили чата"; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index d886ece0d6..eb47218422 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -690,9 +690,6 @@ /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "เชื่อมต่อกับนักพัฒนา SimpleX Chat"; -/* No comment provided by engineer. */ -"Connect via group link?" = "เชื่อมต่อผ่านลิงค์กลุ่ม?"; - /* No comment provided by engineer. */ "Connect via link" = "เชื่อมต่อผ่านลิงก์"; @@ -756,9 +753,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "ผู้ติดต่อรายนี้มีอยู่แล้ว"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "ผู้ติดต่อและข้อความทั้งหมดจะถูกลบ - ไม่สามารถยกเลิกได้!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "ผู้ติดต่อมีการ encrypt จากต้นจนจบ"; @@ -960,9 +954,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "ลบผู้ติดต่อ"; -/* No comment provided by engineer. */ -"Delete contact?" = "ลบผู้ติดต่อ?"; - /* No comment provided by engineer. */ "Delete database" = "ลบฐานข้อมูล"; @@ -1110,12 +1101,6 @@ /* server test step */ "Disconnect" = "ตัดการเชื่อมต่อ"; -/* No comment provided by engineer. */ -"Display name" = "ชื่อที่แสดง"; - -/* No comment provided by engineer. */ -"Display name:" = "ชื่อที่แสดง:"; - /* No comment provided by engineer. */ "Do it later" = "ทำในภายหลัง"; @@ -2143,7 +2128,8 @@ "observer" = "ผู้สังเกตการณ์"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "ปิด"; /* No comment provided by engineer. */ @@ -2242,12 +2228,6 @@ /* No comment provided by engineer. */ "Opening database…" = "กำลังเปิดฐานข้อมูล…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "การเปิดลิงก์ในเบราว์เซอร์อาจลดความเป็นส่วนตัวและความปลอดภัยของการเชื่อมต่อ ลิงก์ SimpleX ที่ไม่น่าเชื่อถือจะเป็นสีแดง"; - -/* No comment provided by engineer. */ -"or chat with the developers" = "หรือแชทกับนักพัฒนาแอป"; - /* member role */ "owner" = "เจ้าของ"; @@ -3007,9 +2987,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "encryption กำลังทำงานและไม่จำเป็นต้องใช้ข้อตกลง encryption ใหม่ อาจทำให้การเชื่อมต่อผิดพลาดได้!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "กลุ่มมีการกระจายอำนาจอย่างเต็มที่ – มองเห็นได้เฉพาะสมาชิกเท่านั้น"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "แฮชของข้อความก่อนหน้านี้แตกต่างกัน"; @@ -3484,9 +3461,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "คุณจะต้องตรวจสอบสิทธิ์เมื่อคุณเริ่มหรือกลับมาใช้แอปพลิเคชันอีกครั้งหลังจากผ่านไป 30 วินาทีในพื้นหลัง"; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "คุณจะเข้าร่วมกลุ่มที่ลิงก์นี้อ้างถึงและเชื่อมต่อกับสมาชิกในกลุ่ม"; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "คุณจะยังได้รับสายเรียกเข้าและการแจ้งเตือนจากโปรไฟล์ที่ปิดเสียงเมื่อโปรไฟล์ของเขามีการใช้งาน"; @@ -3517,9 +3491,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "ฐานข้อมูลการแชทของคุณไม่ได้ถูก encrypt - ตั้งรหัสผ่านเพื่อ encrypt"; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "โปรไฟล์การแชทของคุณจะถูกส่งไปยังสมาชิกในกลุ่ม"; - /* No comment provided by engineer. */ "Your chat profiles" = "โปรไฟล์แชทของคุณ"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index a9213527c6..aabd1a7101 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -708,21 +708,12 @@ /* server test step */ "Connect" = "Підключіться"; -/* No comment provided by engineer. */ -"Connect directly" = "Підключіться безпосередньо"; - /* No comment provided by engineer. */ "Connect incognito" = "Підключайтеся інкогніто"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "зв'язатися з розробниками SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Підключіться за контактним посиланням"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Підключитися за груповим посиланням?"; - /* No comment provided by engineer. */ "Connect via link" = "Підключіться за посиланням"; @@ -789,9 +780,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Контакт вже існує"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Контакт і всі повідомлення будуть видалені - це неможливо скасувати!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "контакт має шифрування e2e"; @@ -993,9 +981,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Видалити контакт"; -/* No comment provided by engineer. */ -"Delete contact?" = "Видалити контакт?"; - /* No comment provided by engineer. */ "Delete database" = "Видалити базу даних"; @@ -1149,12 +1134,6 @@ /* server test step */ "Disconnect" = "Від'єднати"; -/* No comment provided by engineer. */ -"Display name" = "Відображуване ім'я"; - -/* No comment provided by engineer. */ -"Display name:" = "Відображуване ім'я:"; - /* No comment provided by engineer. */ "Do it later" = "Зробіть це пізніше"; @@ -2197,7 +2176,8 @@ "observer" = "спостерігач"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "вимкнено"; /* No comment provided by engineer. */ @@ -2296,12 +2276,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Відкриття бази даних…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Відкриття посилання в браузері може знизити конфіденційність і безпеку з'єднання. Ненадійні посилання SimpleX будуть червоного кольору."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "або поспілкуйтеся з розробниками"; - /* member role */ "owner" = "власник"; @@ -3082,9 +3056,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з'єднання!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Група повністю децентралізована - її бачать лише учасники."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хеш попереднього повідомлення відрізняється."; @@ -3574,9 +3545,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вам потрібно буде пройти автентифікацію при запуску або відновленні програми після 30 секунд роботи у фоновому режимі."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Ви приєднаєтеся до групи, на яку посилається це посилання, і з'єднаєтеся з її учасниками."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні."; @@ -3607,9 +3575,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Ваша база даних чату не зашифрована - встановіть ключову фразу, щоб зашифрувати її."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Ваш профіль у чаті буде надіслано учасникам групи"; - /* No comment provided by engineer. */ "Your chat profiles" = "Ваші профілі чату"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 25eadf44d4..c106e27f23 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/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) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- 连接 [目录服务](simplex:/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) (BETA)!\n- 发送回执(最多20成员)。\n- 更快并且更稳固。"; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- 更稳定的传输!\n- 更好的社群!\n- 以及更多!"; @@ -446,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "应用程序构建:%@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "应用程序为新的本地文件(视频除外)加密。"; + /* No comment provided by engineer. */ "App icon" = "应用程序图标"; @@ -539,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "您和您的联系人都可以发送语音消息。"; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。"; @@ -711,21 +720,12 @@ /* server test step */ "Connect" = "连接"; -/* No comment provided by engineer. */ -"Connect directly" = "直接连接"; - /* No comment provided by engineer. */ "Connect incognito" = "在隐身状态下连接"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "连接到 SimpleX Chat 开发者。"; -/* No comment provided by engineer. */ -"Connect via contact link" = "通过联系人链接进行连接"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "通过群组链接连接?"; - /* No comment provided by engineer. */ "Connect via link" = "通过链接连接"; @@ -738,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "已连接"; +/* rcv group event chat item */ +"connected directly" = "已直连"; + /* No comment provided by engineer. */ "connecting" = "连接中"; @@ -792,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "联系人已存在"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "联系人和所有的消息都将被删除——这是不可逆回的!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "联系人具有端到端加密"; @@ -846,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "创建链接"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "创建一次性邀请链接"; @@ -996,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "删除联系人"; -/* No comment provided by engineer. */ -"Delete contact?" = "删除联系人?"; - /* No comment provided by engineer. */ "Delete database" = "删除数据库"; @@ -1153,10 +1153,7 @@ "Disconnect" = "断开连接"; /* No comment provided by engineer. */ -"Display name" = "显示名称"; - -/* No comment provided by engineer. */ -"Display name:" = "显示名:"; +"Discover and join groups" = "发现和加入群组"; /* No comment provided by engineer. */ "Do it later" = "稍后再做"; @@ -1251,6 +1248,9 @@ /* No comment provided by engineer. */ "Encrypt local files" = "加密本地文件"; +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "为存储的文件和媒体加密"; + /* No comment provided by engineer. */ "Encrypted database" = "加密数据库"; @@ -1359,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "创建群组链接错误"; +/* No comment provided by engineer. */ +"Error creating member contact" = "创建成员联系人时出错"; + /* No comment provided by engineer. */ "Error creating profile!" = "创建资料错误!"; @@ -1437,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "发送电邮错误"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "发送成员联系人邀请错误"; + /* No comment provided by engineer. */ "Error sending message" = "发送消息错误"; @@ -2130,6 +2136,9 @@ /* No comment provided by engineer. */ "New database archive" = "新数据库存档"; +/* No comment provided by engineer. */ +"New desktop app!" = "全新桌面应用!"; + /* No comment provided by engineer. */ "New display name" = "新显示名"; @@ -2206,7 +2215,8 @@ "observer" = "观察者"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "关闭"; /* No comment provided by engineer. */ @@ -2287,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "只有您的联系人可以发送语音消息。"; +/* No comment provided by engineer. */ +"Open" = "打开"; + /* No comment provided by engineer. */ "Open chat" = "打开聊天"; @@ -2305,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "打开数据库中……"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "在浏览器中打开链接可能会降低连接的隐私和安全性。SimpleX 上不受信任的链接将显示为红色。"; - -/* No comment provided by engineer. */ -"or chat with the developers" = "或与开发者聊天"; - /* member role */ "owner" = "群主"; @@ -2761,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "将送达回执发送给"; +/* No comment provided by engineer. */ +"send direct message" = "发送私信"; + /* No comment provided by engineer. */ "Send direct message" = "发送私信"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "发送私信来连接"; + /* No comment provided by engineer. */ "Send disappearing message" = "发送限时消息中"; @@ -2944,6 +2957,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX 一次性邀请"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "简化的隐身模式"; + /* No comment provided by engineer. */ "Skip" = "跳过"; @@ -3091,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "加密正在运行,不需要新的加密协议。这可能会导致连接错误!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "该小组是完全分散式的——它只对成员可见。"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "上一条消息的散列不同。"; @@ -3190,6 +3203,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "要与您的联系人验证端到端加密,请比较(或扫描)您设备上的代码。"; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "在连接时切换隐身模式。"; + /* No comment provided by engineer. */ "Transport isolation" = "传输隔离"; @@ -3583,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "当您启动应用或在应用程序驻留后台超过30 秒后,您将需要进行身份验证。"; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "您将加入此链接指向的群组并连接到其群组成员。"; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "当静音配置文件处于活动状态时,您仍会收到来自静音配置文件的电话和通知。"; @@ -3616,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "您的聊天数据库未加密——设置密码来加密。"; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "您的聊天资料将被发送给群组成员"; - /* No comment provided by engineer. */ "Your chat profiles" = "您的聊天资料"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 65767087be..23d71515af 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -163,7 +163,7 @@ بحث غير مفعّل غيرت دورك إلى %s - جهات الاتصال المفضلة + تفضيلات جهة الاتصال مفعّل مفعّلة لك يمكن لجهات الاتصال تحديد الرسائل لحذفها؛ ستتمكن من مشاهدتها. @@ -211,7 +211,7 @@ إحباط سيتم إحباط تغيير العنوان. سيتم استخدام عنوان الاستلام القديم. مسح الدردشة؟ - وحدة التحكم الدردشة + وحدة تحكم الدردشة ضبط خوادم ICE الاتصال ملف تعريف الدردشة @@ -287,7 +287,7 @@ مساهمة تغيير الدور أدخل كلمة المرور في البحث - سماح الاتصال + تسمح جهة الاتصال تأكيد جهة الاتصال ليست متصلة بعد! اتصال @@ -317,7 +317,7 @@ تواصل عبر الرابط / رمز QR إنشاء رابط دعوة لمرة واحدة تحقق من عنوان الخادم وحاول مرة أخرى. - مسج التَحَقّق + مسح التَحَقُّق أنشئ عنوانًا للسماح للأشخاص بالتواصل معك. أدخل الخادم يدويًا ملون @@ -397,7 +397,7 @@ خطأ في فك الترميز حذف جهة الاتصال؟ حذف بالنسبة لي - الوقت المخصص + وقت مخصّص لامركزي عبارة مرور قاعدة البيانات عبارة المرور الحالية… @@ -474,7 +474,6 @@ سيتم حذف الملف من الخوادم. سيتم استلام الملف عند اكتمال تحميل جهة الاتصال الخاصة بك. المساعدة - الاسم الكامل (اختياري) الملف: %s إصلاح إصلاح الاتصال @@ -484,7 +483,7 @@ تفضيلات المجموعة سريع ولا تنتظر حتى يصبح المرسل متصلاً بالإنترنت! إخفاء - كيفية استخدامها + كيفية الاستخدام كيف يعمل SimpleX التخفي عبر رابط عنوان جهة الاتصال رمز الحماية غير صحيحة! @@ -696,7 +695,7 @@ خطأ في تصدير قاعدة بيانات الدردشة ستتم إزالة العضو من المجموعة - لا يمكن التراجع عن هذا! اجعل الملف الشخصي خاصًا! - فلترة الدردشات غير المقروءة والمفضلة. + تصفية الدردشات غير المقروءة والمفضلة. البحث عن الدردشات بشكل أسرع تفعيل حتى عندما يتم تعطيله في المحادثة. @@ -761,8 +760,7 @@ متوفرة %s متوفرة %s: %2s سيتم تسليم الإشعارات فقط حتى يتوقف التطبيق! - لا توجد محادثات مفلترة - لا يوجد مسافات + لا توجد محادثات مُصفاة لا توجد ملفات مستلمة أو مرسلة ستتوقف الإشعارات عن العمل حتى تعيد تشغيل التطبيق رمز مرور جديد @@ -802,22 +800,22 @@ عبارة مرور جديدة… يرجى الانتظار كلمة المرور مطلوبة - ألصق الرابط المستلم - فقط أصحاب المجموعة يمكنهم تفعيل الملفات والوسائط. - فقط أصحاب المجموعة يمكنهم تفعيل الرسائل الصوتية. + ألصق الرابط المُستلَم + فقط مالكي المجموعة يمكنهم تفعيل الملفات والوسائط. + فقط مالكي المجموعة يمكنهم تفعيل الرسائل الصوتية. (يخزن فقط بواسطة أعضاء المجموعة) كلمة المرور تم تعيين كلمة المرور! - صاحب - فقط جهة اتصالك يمكنها إرسال رسائل مؤقتة. + المالك + فقط جهة اتصالك يمكنها إرسال رسائل تختفي. جهة اتصالك فقط يمكنها إضافة تفاعلات على الرسالة - فقط أصحاب المجموعة يمكنهم تغيير إعداداتها. + فقط مالكي المجموعة يمكنهم تغيير تفضيلات المجموعة. جهة اتصالك فقط يمكنها حذف الرسائل نهائيا (يمكنك تعليم الرسالة للحذف). أنت فقط يمكنك إرسال رسائل صوتية. افتح لم يتم تغيير كلمة المرور! تم تغيير كلمة المرور - يتم فتح قاعدة البيانات… + جارِ فتح قاعدة البيانات… جهة اتصالك فقط يمكنها إرسال رسائل صوتية. ألصق كلمة المرور غير موجودة في مخزن المفاتيح، يرجى إدخالها يدوياً. قد يحدث هذا إذا قمت باستعادة ملفات التطبيق باستخدام أداة استرجاع بيانات. إذا لم يكن الأمر كذلك، تواصل مع المبرمجين رجاء @@ -825,15 +823,15 @@ فتح الرابط في المتصفح قد يقلل خصوصية وحماية اتصالك. الروابط غير الموثوقة من SimpleX ستكون باللون الأحمر أنت فقط يمكنك إضافة تفاعل على الرسالة. أنت فقط يمكنك حذف الرسائل نهائيا (يمكن للمستلم تعليمها للحذف) - أنت فقط يمكنك إرسال رسائل مؤقتة + أنت فقط يمكنك إرسال رسائل تختفي أنت فقط يمكنك إجراء المكالمات. فقط جهة اتصالك يمكنها إجراء المكالمات. افتح وحدة تحكم الدردشة إدخال كلمة المرور - "قم بفتح SimpleX Chat للرد على المكالمة" + افتح SimpleX Chat للرد على المكالمة بروتوكول وكود مفتوح المصدر - يمكن لأي شخص تشغيل الخوادم. كلمة المرور للإظهار - ند لند + ندّ لِندّ يمكن للناس التواصل معك فقط عبر الرابط الذي تقوم بمشاركته مكالمة في الانتظار تشفير ثنائي الطبقات من بين الطريفين.]]> @@ -863,7 +861,7 @@ حفظ إعدادات القبول التلقائي إعادة تعريف الخصوصية الرجاء الإبلاغ للمطورين. - الخصوصية و الأمان + الخصوصية والأمان إزالة إزالة عبارة المرور من Keystore؟ الرجاء إدخال عبارة المرور الحالية الصحيحة. @@ -908,7 +906,7 @@ حفظ وإشعار جهات الاتصال حفظ وتحديث ملف تعريف المجموعة عدد البينج - استلمت رسالة + رسالة مُستلَمة الواجهة البولندية تشغيل الدردشة استعادة النسخة الاحتياطية لقاعدة البيانات @@ -961,14 +959,14 @@ كلمة مرور التدمير الذاتي إرسال الملفات غير مدعوم بعد قام المرسل بإلغاء إرسال الملف - (امسح أو ألصق) + (امسح أو ألصق من الحافظة) ثانية قد يكون المرسل قد ألغى طلب الاتصال مسح رمز الاستجابة السريعة أرسل لنا بريداً مسح رمز الأمان من تطبيق جهة الاتصال مشاركة رابط ذو استخدام واحد - إرسال الملف سوف يتوقف. + سيتم إيقاف إرسال الملف. إرسال رسالة إرسال إرسال رسالة حية @@ -984,7 +982,7 @@ تم إرساله في: %s %s (الحالي) رسالة مرسلة - تعيين إعدادت المجموهة + عيّن تفضيلات المجموعة عيينها بدلا من توثيق النظام مشاركة إرسال @@ -993,12 +991,12 @@ تعيين يوم واحد ثواني رسالة مرسلة - أرسل رسالة مؤقتة + أرسل رسالة تختفي حفظ كلمة مرور الحساب تدمير ذاتي مسح الكود إرسال أسئلة وأفكار - مشاركة العنوان مع جهات الاتصال + مشاركة العنوان مع جهات الاتصال؟ مشاركة العنوان حفظ رسالة الترحيب؟ حفظ السيرفرات @@ -1011,7 +1009,7 @@ تم إرساله في اختيار إرسال تقارير الاستلام سيتم تفعيله لجميع جهات الاتصال. - إرسال تقارير الاستلام سيتم تفعيله لجميع جهات الاتصال ذات حسابات الدردشة الظاهرة + سيتم تفعيل إرسال تقارير الاستلام لجميع جهات الاتصال ذات حسابات دردشة ظاهرة قائمة انتظار آمنة فشل الإرسال تم الإرسال @@ -1019,8 +1017,8 @@ إرسال رسالة حية - سيتم تحديثها للمستلم مع كتابتك لها تعيين اسم جهة الاتصال الإعدادات - حفظ السيرفرات؟ - مسح رمز الاستجابة السريعة للسيرفر + حفظ الخوادم؟ + مسح رمز QR الخادم رمز الأمان حفظ الإعدادات؟ حفظ الإعدادات؟ @@ -1097,7 +1095,7 @@ النظام السمة لبدء محادثة جديدة - للتحقق من التشفير من طرف إلى طرف مع جهة الاتصال الخاصة بك، قارن (أو امسح) الرمز الموجود على أجهزتك. + للتحقق من التشفير بين الطريفين مع جهة اتصالك، قارن (أو امسح) الرمز الموجود على أجهزتك. المجموعة لامركزية بالكامل - فهي مرئية فقط للأعضاء. النظام فشل الاختبار في الخطوة %s. @@ -1353,7 +1351,7 @@ لا توجد دردشة محددة إرسال الإيصالات مُعطَّلة لـ%d مجموعات مجموعات صغيرة (الحد الأقصى 20) - الاتصال مباشرةً؟ + تواصل مباشرةً؟ سيتم إرسال طلب الاتصال لعضو المجموعة هذا. اتصال متخفي استخدم ملف التعريف الحالي @@ -1361,7 +1359,7 @@ افتح إعدادات التطبيق لا يمكن تشغيل SimpleX في الخلفية. ستتلقى الإشعارات فقط عندما يكون التطبيق قيد التشغيل. سيتم مشاركة ملف تعريف عشوائي جديد. - الصق الرابط الذي تلقيته للتواصل مع جهة الاتصال الخاصة بك… + ألصق الرابط المُستلَم للتواصل مع جهة اتصالك… ستتم مشاركة ملفك الشخصي %1$s. قد يغلق التطبيق بعد دقيقة واحدة في الخلفية. سماح @@ -1399,4 +1397,11 @@ - الاتصال بخدمة الدليل (تجريبي)! \n- إيصالات التسليم (ما يصل إلى 20 عضوا). \n- أسرع وأكثر استقرارًا. + افتح + حدث خطأ أثناء إنشاء جهة اتصال للعضو + أرسل رسالة مباشرة للاتصال + وضع التخفي اصبح أسهل + فعّل وضع التخفي عند الاتصال. + أرسل رسالة مباشرة + متصل مباشرةً \ No newline at end of file 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 fa2782531d..8630e37e86 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1230,7 +1230,7 @@ Error updating group link Error deleting group link Error creating member contact - Sending message contact invitation + Error sending invitation Only group owners can change group preferences. Address Share address diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 39b7bceee7..3bc721174a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -581,7 +581,6 @@ Изчезващи съобщения Въведи съобщение при посрещане…(незадължително) Съобщение при посрещане - Пълно име (незадължително) Грешка при свързване със сървъра С незадължително съобщение при посрещане. Грешка при изтриване на чат базата данни @@ -815,7 +814,6 @@ Няма се използват Onion хостове. Нека да поговорим в SimpleX Chat Парола за показване - Без интервали! GitHub хранилище.]]> Когато приложението работи Периодично @@ -1400,4 +1398,9 @@ - свържете се с директория за услуги (БЕТА)! \n- потвърждениe за доставка (до 20 члена). \n- по-бързо и по-стабилно. + Отвори + Грешка при създаване на контакт с член + Изпрати лично съобщение за свързване + изпрати лично съобщение + свързан директно \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 2afdb7a690..e733201a74 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -5,7 +5,7 @@ Hlasové zprávy povoleny. Správci mohou vytvářet odkazy pro připojení ke skupinám. Přijmout - Přidejte přednastavené servery + Přidat přednastavené servery Pokročilá nastavení sítě Přijmout Přidat server… @@ -24,7 +24,7 @@ Přidat profil Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět! Povolte mizící zprávy, pouze pokud to váš kontakt povolí. - Přidejte servery skenováním QR kódů. + Přidat servery skenováním QR kódů. 1 měsíci 1 týdnu přijatý hovor @@ -328,7 +328,7 @@ Zabezpečit frontu Okamžitá oznámení! V nastavení ji lze vypnout - oznámení se budou zobrazovat pokud aplikace běží.]]> - vypněte optimalizaci baterie pro SimpleX v dalším dialogu. V opačném případě budou oznámení vypnuta.]]> + povolte pro SimpleX běh na pozadí v dalším dialogu. Jinak budou oznámení vypnuta.]]> Aplikace pravidelně načítá nové zprávy - denně spotřebuje několik procent baterie. Aplikace nepoužívá push oznámení - data ze zařízení nejsou odesílána na servery. Je vyžadována přístupová fráze Chcete-li dostávat oznámení, zadejte přístupovou frázi do databáze. @@ -386,7 +386,8 @@ Zaslat otázky a nápady Test serveru Servery ICE (jeden na řádek) - Pro připojení budou vyžadováni Onion hostitelé. + Pro připojení budou vyžadováni Onion hostitelé. +\nVezměte prosím na vědomí: nebudete mít možnost připojení k serverům bez adresy .onion. Aktualizovat režim dopravní izolace\? Sestavení aplikace: %s Sdílet odkaz @@ -659,7 +660,6 @@ Na serverech neukládáme žádné vaše kontakty ani zprávy (po doručení). Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení. Zobrazované jméno - Celé Jméno (volitelné) Vytvořit Jak používat markdown K formátování zpráv můžete použít markdown: @@ -890,8 +890,8 @@ Tmavé Téma Kontakt povolil - zapnuto - vypnout + zaplé + vyplé Chat předvolby Předvolby kontaktu Předvolby skupiny @@ -1093,7 +1093,6 @@ %1$d zprývy přeskočeny. Nahlaste to prosím vývojářům. Může se to stát, když vy nebo vaše připojení použijete starou zálohu databáze. - Žádné mezery! Soubor bude smazán ze serverů. Příjem souboru bude zastaven. Povolte hovory, pouze pokud je váš kontakt povolí. @@ -1302,7 +1301,7 @@ Odesílání potvrzení o doručení je povoleno pro %d kontakty Vypnout potvrzení\? Povolit potvrzení\? - Mohou být přepsány v nastavení kontaktů + Mohou být přepsány v nastavení kontaktů a skupin. šifrování ok povoluji šifrování… šifrování ok pro %s @@ -1353,7 +1352,7 @@ Odesílání doručenky je zakázáno pro %d skupin Vypnout pro všechny skupiny vypnut - Receipts jsou zakázány + Potvrzení jsou zakázána Již brzy! Použít aktuální profil Použít nový incognito profil @@ -1370,24 +1369,41 @@ Malé skupiny (max. 20) %s: %s %s a %s připojen - %s, %s a %s připojeni - %s, %s a %d dalších členů připojeno + %s, %s a %s připojen + %s, %s a %d další členové připojeni Rozepsáno Zobrazit poslední zprávy Tato skupina má více než %1$d členů, doručenky nejsou odeslány. Tato funkce zatím není podporována. Vyzkoušejte další vydání. Databáze bude zašifrována a heslo bude uloženo v klíčence. - Všimněte si prosím: zprávy a relé souborů jsou spojeny prostřednictvím proxy SOCKS. Volání a odesílání náhledů odkazů pomocí přímého připojení.]]> + Všimněte si prosím: relé zpráv a souborů jsou připojeny prostřednictvím proxy SOCKS. Volání a odesílání náhledů odkazů používá přímé připojení.]]> Šifrovat místní soubory - Náhodné heslo je uloženo v nastavení jako prostý text. -\nMůžete jej změnit později. + Náhodná přístupová fráze je uložena v nastavení jako prostý text. +\nMůžete ji později změnit. Heslo pro šifrování databáze bude aktualizováno a uloženo v klíčence. - Odebrat heslo z nastavení\? - Použít náhodné heslo - Uložit heslo v nastavení - Nastavení hesla databáze - Nastavit heslo databáze - Otevřete složku databáze - Heslo bude uloženo v nastavení jako prostý text až jej změníte nebo po restartu aplikace. - Heslo je uloženo v nastavení jako prostý text. + Odebrat přístupovou frázi z nastavení\? + Použít náhodnou přístupovou frázi + Uložit přístupovou frázi v nastavení + Nastavení přístupové fráze databáze + Nastavit přístupovou frázi databáze + Otevřít složku databáze + Přístupová fráze bude uložena v nastavení jako prostý text až ji změníte nebo po restartu aplikace. + Přístupová fráze je uložena v nastavení jako prostý text. + 6 nových jazyků pro rozhraní + Aplikace šifruje nové místní soubory (kromě videí) + Arabština, bulharština, finština, hebrejština, thajština a ukrajinština - díky uživatelům a Weblate. + propojeno napřímo + Otevřít + Šifrování uložených souborů a médií + Chyba vytváření kontaktu + Nová desktopová aplikace! + Odeslat přímou zprávu pro připojení + Objevte a připojte se ke skupinám + Zjednodušený režim inkognito + Vytvořit nový profil v desktopové aplikaci. 💻 + Změnit inkognito při připojování. + - připojit k adresáři skupin (BETA)! +\n- doručenky (až 20 členů). +\n- rychlejší a stabilnější. + odeslat přímou zprávu \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 6fad3dc0ac..63c3d59cad 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -438,7 +438,6 @@ Das Profil wird nur mit Ihren Kontakten geteilt. Der angezeigte Name darf keine Leerzeichen enthalten. Angezeigter Name - Vollständiger Name (optional) Erstellen Über SimpleX @@ -1190,7 +1189,6 @@ Das Senden der Datei wird beendet. Datei beenden Die Datei wird von den Servern gelöscht. - Keine Leerzeichen! Widerrufen Datei widerrufen Datei widerrufen\? @@ -1472,4 +1470,21 @@ Das Passwort wurde in Klartext in den Einstellungen gespeichert. Bitte beachten Sie: Die Nachrichten- und Dateirelais sind per SOCKS Proxy verbunden. Anrufe und gesendete Link-Vorschaubilder nutzen eine direkte Verbindung.]]> Lokale Dateien verschlüsseln + Öffnen + Gespeicherte Dateien & Medien verschlüsseln + Fehler beim Anlegen eines Mitglied-Kontaktes + Neue Desktop-App! + 6 neue Sprachen für die Bedienoberfläche + Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt. + Eine Direktnachricht zum Verbinden senden + Gruppen entdecken und ihnen beitreten + Vereinfachter Inkognito-Modus + Arabisch, Bulgarisch, Finnisch, Hebräisch, Thailändisch und Ukrainisch - Dank der Nutzer und Weblate. + Erstellen eines neuen Profils in der Desktop-App. 💻 + Inkognito beim Verbinden einschalten. + - Verbindung mit dem Directory-Service (BETA)! +\n- Empfangsbestätigungen (für bis zu 20 Mitglieder). +\n- Schneller und stabiler. + Direktnachricht senden + Direkt miteinander verbunden \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 7b2aa4e685..4bcc51663f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -321,7 +321,7 @@ Voltear la cámara Invitación de grupo caducada La invitación al grupo ya no es válida, ha sido eliminada por el remitente. - El grupo se eliminará para tí. ¡No podrá deshacerse! + El grupo será eliminado para tí. ¡No podrá deshacerse! Cómo usar la sintaxis markdown en modo incógnito mediante enlace de un solo uso Dirección de contacto SimpleX @@ -403,7 +403,6 @@ Imagen Archivo no encontrado Guía de uso - Nombre completo (opcional) finalizado AYUDA Exportar base de datos @@ -421,7 +420,7 @@ Ocultar pantalla de aplicaciones en aplicaciones recientes. Cifrar Ampliar la selección de roles - El grupo se eliminará para todos los miembros. ¡No podrá deshacerse! + El grupo será eliminado para todos los miembros. ¡No podrá deshacerse! Activar TCP keep-alive activado para tí error @@ -438,7 +437,7 @@ ayuda Compartir enlace Cómo funciona - El mensaje se eliminará. ¡No podrá deshacerse! + El mensaje será eliminado. ¡No podrá deshacerse! El modo incógnito protege tu privacidad creando un perfil aleatorio por cada contacto. permite que SimpleX se ejecute en segundo plano en el siguiente cuadro de diálogo. De lo contrario las notificaciones se desactivarán.]]> Instalar terminal para SimpleX Chat @@ -513,7 +512,8 @@ ¡Archivo grande! Silenciar Asegúrate de que las direcciones del servidor WebRTC ICE tienen el formato correcto, están separadas por líneas y no duplicadas. - Se requieren hosts .onion para la conexión + Se requieren hosts .onion para la conexión +\nAtención: no podrás conectarte a servidores que no tengan dirección .onion. Se usarán hosts .onion si están disponibles. Inmune a spam y abuso si SimpleX no tiene identificadores de usuario, ¿cómo puede entregar los mensajes\?]]> @@ -1089,13 +1089,12 @@ El ID del siguiente mensaje es incorrecto (menor o igual que el anterior). \nPuede ocurrir por algún bug o cuando la conexión está comprometida. Por favor, informa a los desarrolladores. - %1$d mensajes omitidos. + %1$d mensaje(s) omitido(s). Hash de mensaje incorrecto ID de mensaje incorrecto Puede ocurrir cuando tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos. El hash del mensaje anterior es diferente. - %1$d mensajes no han podido ser descifrados. - ¡Sin espacios! + %1$d mensaje(s) no ha(n) podido ser descifrado(s). Detener archivo El archivo será eliminado de los servidores. Se detendrá la recepción del archivo. @@ -1389,4 +1388,24 @@ Abrir carpeta base de datos La contraseña se almacenará en configuración como texto plano después de cambiarla o reiniciar la aplicación. La contraseña está almacenada en configuración como texto plano. + Abrir + Cifra archivos almacenados y multimedia + Error al establecer contacto con el miembro + Atención: los servidores de retransmisión están conectados mediante SOCKS proxy. Las llamadas y las previsualizaciones de enlaces usan conexión directa.]]> + Cifra archivos locales + Nueva aplicación para PC! + 6 idiomas nuevos para el interfaz + Cifrado de los nuevos archivos locales (excepto vídeos). + Enviar mensaje directo para conectar + Descubre y únete a grupos + Modo incógnito simplificado + Árabe, Búlgaro, Finlandés, Hebreo, Tailandés y Ucraniano - gracias a los usuarios y Weblate. + Crea perfil nuevo en la aplicación para PC. 💻 + Error al enviar invitación + Activa incógnito al conectar. + - conexión al servicio de directorio (BETA)! +\n- confirmaciones de entrega (hasta 20 miembros). +\n- mayor rapidez y estabilidad. + Enviar mensaje directo + conectado directamente \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index b0280d84ae..ce8692130f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -573,7 +573,6 @@ Vanhentunut ryhmäkutsu kutsuttu %1$s Ryhmän koko nimi: - Koko nimi (valinnainen) kursivoitu Avainnipun virhe muokattu @@ -938,7 +937,6 @@ Liity napauttamalla poistettu omistaja - Ei välilyöntejä! hylätty puhelu Näytä Pysäytä tiedosto @@ -1389,4 +1387,9 @@ Tunnuslause on tallennettu asetuksiin selkokielisenä. Huomioi : Viesti- ja tiedostovälittimet yhdistetään SOCKS-proxyn kautta. Puhelut ja linkin esikatselut käyttävät suoraa yhteyttä.]]> Salaa paikalliset tiedostot + Avaa + Uusi työpöytäsovellus! + 6 uutta käyttöliittymän kieltä + Löydä ryhmiä ja liity niihin + Luo uusi profiili työpöytäsovelluksessa. 💻 \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 9ac692cf70..590ba49db8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -363,7 +363,6 @@ Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil. Le profil n\'est partagé qu\'avec vos contacts. Le nom d\'affichage ne peut pas contenir d\'espace. - Nom complet (optionnel) Créer À propos de SimpleX Vous pouvez utiliser le format markdown pour mettre en forme les messages : @@ -751,7 +750,7 @@ Vous essayez d\'inviter un contact avec lequel vous avez partagé un profil incognito à rejoindre le groupe dans lequel vous utilisez votre profil principal ID de base de données Retirer le membre - Envoi de message direct + Envoyer un message direct Ce membre sera retiré du groupe - impossible de revenir en arrière ! Rôle Changer le rôle @@ -1086,7 +1085,6 @@ Erreur de déchiffrement Cela peut se produire lorsque vous ou votre contact avez utilisé une ancienne sauvegarde de base de données. Mauvais hash de message - Pas d\'espace ! Autoriser les appels que si votre contact les autorise. Autorise vos contacts à vous appeler. Appels audio/vidéo @@ -1403,4 +1401,9 @@ - connexion au service d\'annuaire (BETA) ! \n- accusés de réception (jusqu\'à 20 membres). \n- plus rapide et plus stable. + Ouvrir + Erreur lors de la création du contact du membre + Envoyer un message direct pour vous connecter + envoyer un message direct + s\'est connecté.e de manière directe \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 846cd931a2..10af1170b1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -351,7 +351,6 @@ Modifica immagine Esci senza salvare Nome completo: - Nome completo (facoltativo) Come usare il markdown chiamata audio chiamata audio (non crittografata e2e) @@ -420,7 +419,7 @@ cambiato il tuo ruolo in %s cambio indirizzo… cambio indirizzo per %s… - connesso/a + si è connesso/a connesso/a in connessione (accettato) in connessione (annunciato) @@ -771,8 +770,8 @@ membro proprietario ha rimosso - rimosso %1$s - sei stato/a rimosso/a + ha rimosso %1$s + ti ha rimosso/a Tocca per entrare Toccare per entrare in incognito Questo gruppo non esiste più. @@ -820,7 +819,7 @@ Scadenza del protocollo Ricezione via Ripristina i predefiniti - Annulla + Ripristina Salva Salva il profilo del gruppo sec @@ -1105,7 +1104,6 @@ Revocare il file\? Ferma Fermare la ricezione del file\? - Niente spazi! Consenti le chiamate solo se il tuo contatto le consente. Le chiamate audio/video sono vietate. Sia tu che il tuo contatto potete effettuare chiamate. @@ -1403,4 +1401,9 @@ - connessione al servizio directory (BETA)! \n- ricevute di consegna (fino a 20 membri). \n- più veloce e più stabile. + Apri + Errore di creazione del contatto + Invia messaggio diretto per connetterti + invia messaggio diretto + si è connesso/a direttamente \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 8467199bf4..6599a30cc0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -454,7 +454,6 @@ עבור כולם קובץ מתוך גלריה - שם מלא (אופציונלי) קבצים ומדיה קבוצה נמחקה עבור מסוף הצ׳אט @@ -623,7 +622,6 @@ מרקדאון בהודעות רשת ושרתים הגדרות רשת - בלי רווחים! ארכיון מסד נתונים חדש הודעות חבר קבוצה @@ -677,7 +675,8 @@ סמן מאומת לא ייעשה שימוש במארחי Onion כאשר יהיו זמינים. - מארחי Onion יידרשו לחיבור. + יידרשו מארחי onion לחיבור. +\nשימו לב: לא תוכלו להתחבר לשרתים ללא כתובת .onion. לא ייעשה שימוש במארחי Onion. לא ייעשה שימוש במארחי Onion. שיחה שלא נענתה @@ -1377,4 +1376,35 @@ שימוש בסוללה באפליקציה / ללא הגבלה בהגדרות האפליקציה.]]> להתחבר ישירות\? בקשת חיבור תישלח לחבר קבוצה זה. + מסד הנתונים יוצפן וביטוי הסיסמה יאוחסן בהגדרות. + פתח + הצפנת קבצים ומדיה מאוחסנים + שגיאה ביצירת איש קשר + שימו לב: ממסרי הודעות וקבצים מחוברים דרך פרוקסי SOCKS. שיחות ושליחת תצוגות מקדימות של קישורים משתמשים בחיבור ישיר.]]> + הצפין קבצים מקומיים + אפליקציית שולחן עבודה חדשה! + 6 שפות ממשק חדשות + האפליקציה מצפינה קבצים מקומיים חדשים (למעט סרטונים). + ביטוי סיסמה אקראי מאוחסן בהגדרות כטקסט רגיל. +\nאתה יכול לשנות את זה מאוחר יותר. + שלח הודעה ישירה כדי להתחבר + גלה והצטרף לקבוצות + ביטוי הסיסמה להצפנת מסד הנתונים יעודכן ויישמר בהגדרות. + להסיר את ביטוי הסיסמה מההגדרות\? + השתמש בביטוי סיסמה אקראי + שמור את ביטוי הסיסמה בהגדרות + מצב זהות נסתרת מפושט + הגדרת סיסמת מסד נתונים + הגדר את ביטוי הסיסמה של מסד הנתונים + פתח את תיקיית מסד הנתונים + ערבית, בולגרית, פינית, עברית, תאילנדית ואוקראינית - הודות למשתמשים ול-Weblate. + צור פרופיל חדש באפליקציית שולחן העבודה. 💻 + ביטוי הסיסמה יישמר בהגדרות כטקסט רגיל לאחר שתשנה אותו או תפעיל מחדש את האפליקציה. + החלף מצב זהות נסתרת בעת חיבור. + - התחבר לשירות ספריות (ביטא)! +\n- קבלות משלוח (עד 20 חברים). +\n- מהיר ויציב יותר. + ביטוי הסיסמה מאוחסן בהגדרות כטקסט רגיל. + שלח הודעה ישירה + מחובר ישירות \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 8b955ceb8f..94a35dbd8b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -538,7 +538,6 @@ フルネーム: 作成 表示の名前 - フルネーム (任意): 色付き 応答 分散型 @@ -1109,7 +1108,6 @@ ダウングレードしてチャットを開く パスワードを覚えるか、安全に保管してください。失われたパスワードを回復する方法はありません。 ファイルをダウンロード - 空き容量がありません ユーザープロフィールを非表示またはミュートすることができます(メニューを長押し)。 グループのモデレーション こんにちは! @@ -1391,4 +1389,18 @@ パスフレーズは平文として設定に保存されます。 注意: メッセージとファイルのリレーは SOCKS プロキシ経由で接続されます。 通話とリンク プレビューの送信には直接接続が使用されます。]]> ローカルファイルを暗号化する + 開く + 保存されたファイルとメディアを暗号化する + メンバー連絡先の作成中にエラーが発生 + 新しいデスクトップアプリ! + 6つの新しいインターフェース言語 + アプリは新しいローカルファイル(ビデオを除く)を暗号化します。 + ダイレクトメッセージを送信して接続する + グループを見つけて参加する + シークレットモードの簡素化 + アラビア語、ブルガリア語、フィンランド語、ヘブライ語、タイ語、ウクライナ語 - ユーザーとWeblateに感謝します。 + デスクトップアプリで新しいプロファイルを作成します。 💻 + 接続時にシークレットモードを切り替えます。 + ダイレクトメッセージを送る + 直接接続中 \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index bee70ddce6..3c7554c972 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -520,7 +520,6 @@ 그룹 멤버는 보낸 메시지를 영구 삭제할 수 있어요. 그룹 멤버는 음성 메시지를 보낼 수 있어요. 숨긴 프로필 비밀번호 - 이름 (선택 사항) 작동 방식 숨기기 : 보냄 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml index ac903ff708..92bd3e381a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml @@ -54,7 +54,6 @@ സ്വീകരിക്കുക സ്വീകരിക്കുക നിരസിക്കുക - മുഴുവൻ പേര് (ഇഷ്ടാനുസൃതമായ) ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! @@ -113,7 +112,6 @@ സെർവർ ചേർക്കുക… മറ്റൊരു ഉപകരണത്തിലേക്ക് ചേർക്കുക സ്വയമേവ സ്വീകരിക്കുക - സ്ഥലമില്ല! സ്ഥിരീകരണത്തിനായി കാത്തിരിക്കുന്നു… അവഗണിക്കുക തുറക്കുക diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index aaa79b9a71..7cbb9bf757 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -315,7 +315,6 @@ Email Bewerk afbeelding Afsluiten zonder opslaan - Volledige naam (optioneel) e2e versleuteld video gesprek Schakel oproepen vanaf het vergrendelscherm in via Instellingen. Ophangen @@ -479,7 +478,7 @@ lid worden als %s Het kan gebeuren wanneer: \n1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server. -\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude databaseback-up heeft gebruikt. +\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude database back-up heeft gebruikt. \n3. De verbinding is verbroken. Deel nemen aan groep Verlaten @@ -1086,14 +1085,13 @@ Decodering fout De hash van het vorige bericht is anders. %1$d berichten overgeslagen. - Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt. + Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt. Meld het alsjeblieft aan de ontwikkelaars. Onjuiste bericht hash Onjuiste bericht-ID De ID van het volgende bericht is onjuist (minder of gelijk aan het vorige). \nHet kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. %1$d berichten konden niet worden ontsleuteld. - Geen spaties! Het ontvangen van het bestand wordt gestopt. Intrekken Bestand intrekken @@ -1401,4 +1399,9 @@ - maak verbinding met de directoryservice (BETA)! \n- ontvangst bevestiging (tot 20 leden). \n- sneller en stabieler. + Open + Fout bij aanmaken contact + Stuur een direct bericht om verbinding te maken + stuur een direct bericht + direct verbonden \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 35630919e6..4e14345e94 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -411,7 +411,6 @@ Utwórz Zdecentralizowane zakończona - Pełna nazwa (opcjonalna) Jak korzystać z markdown Odporność na spam i nadużycia kursywa @@ -1099,7 +1098,6 @@ Plik zostanie usunięty z serwerów. Tylko Ty możesz wykonywać połączenia. Odbieranie pliku zostanie przerwane. - Bez spacji! Zezwalaj na połączenia tylko wtedy, gdy Twój kontakt na to pozwala. Zezwól swoim kontaktom na połączenia do Ciebie. Połączenia audio/wideo @@ -1403,4 +1401,9 @@ - połącz się z usługą katalogową (BETA)! \n- potwierdzenia dostaw (do 20 członków). \n- szybszy i stabilniejszy. + Otwórz + Błąd tworzenia kontaktu członka + Wyślij wiadomość bezpośrednią aby połączyć + wyślij wiadomość bezpośrednią + połącz bezpośrednio \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 07836dc98a..0cb5b872b0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -405,7 +405,6 @@ Se você optar por rejeitar o remetente NÃO será notificado. Código de segurança incorreto! Instale o SimpleX para terminal - Nome Completo (opcional) Como funciona Imune a spam e abuso Vire a câmera @@ -1097,7 +1096,6 @@ Parar O arquivo será excluído dos servidores. Revogar arquivo\? - Sem espaços! %1$d mensagens ignoradas Chamadas de áudio/vídeo Chamadas de áudio/vídeo são proibidas. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 3de938828f..f083705545 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -635,7 +635,6 @@ encriptado ponta a ponta desligado Ler código QR.]]> - Sem espaços! contato não tem encriptação ponta a ponta oferecido %s: %2s desligado diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index d2b4465ec4..f28ef14d14 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -436,7 +436,6 @@ Профиль отправляется только Вашим контактам. Имя профиля не может содержать пробелы. Имя профиля - Полное имя (не обязательно) Создать О SimpleX @@ -1190,7 +1189,6 @@ Только Вы можете совершать звонки. Только Ваш контакт может совершать звонки. Запретить аудио/видео звонки. - Без пробелов! Быстрые и не нужно ждать, когда отправитель онлайн! Польский интерфейс Установите код вместо системной аутентификации. @@ -1485,4 +1483,10 @@ \n- отчеты о доставке (до 20 членов). \n- быстрее и стабильнее. Пароль хранится в настройках, как открытый текст. + Открыть + Ошибка при создании контакта + Послать прямое сообщение контакту + Ошибка отправки приглашения + Послать прямое сообщение + соединен напрямую \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index ad9cff0cc5..b31b2a3a24 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -378,7 +378,6 @@ รหัสผ่านโปรไฟล์ที่ซ่อนอยู่ ซ่อนโปรไฟล์ เกิดข้อผิดพลาดในการบันทึกรหัสผ่านผู้ใช้ - ชื่อเต็ม (ไม่บังคับ) ชื่อที่แสดง ชื่อที่แสดงต้องไม่มีช่องว่าง วิธีใช้มาร์กดาวน์ @@ -675,7 +674,6 @@ โฮสต์หัวหอมจะไม่ถูกใช้ โฮสต์หัวหอมจะไม่ถูกใช้ รหัสผ่านที่จะแสดง - ไม่มีช่องว่าง! สายที่ไม่ได้รับ ผู้คนสามารถเชื่อมต่อกับคุณผ่านลิงก์ที่คุณแบ่งปันเท่านั้น โปรโตคอลและโค้ดโอเพ่นซอร์ส – ใคร ๆ ก็สามารถเปิดใช้เซิร์ฟเวอร์ได้ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index ab4a04a373..1eb396c787 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -593,7 +593,6 @@ Kaydetmeden çık Profili gizle Kullanıcı parolası kaydedilirken hata oluştu - Ad ve Soyad (isteğe bağlı) Gizli profil parolası Markdown nasıl kullanılır Nasıl çalışıyor @@ -934,7 +933,6 @@ Çoklu sohbet profilleri Daha fazla gelişme yakında geliyor! ay - Boşluk yok! Yeni veri tabanı arşivi Eski veri tabanı arşivi Alınan veya gönderilen dosya yok diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 1c3ec176d4..fbf62d4f35 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -660,7 +660,6 @@ Прихований пароль профілю Профіль доступний лише вашим контактам. Ім\'я не може містити пробілів. - Повне ім\'я (необов\'язково) Як використовувати націнку Ви можете використовувати розмітку для форматування повідомлень: Створіть свій профіль @@ -1207,7 +1206,6 @@ SimpleX Адреса Показати QR-код Приєднання до групи - Ніяких пробілів! курсив Сервери WebRTC ICE Увімкніть дзвінки з екрана блокування через Налаштування. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index bf0fe570cc..1d266beaf4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -67,7 +67,7 @@ 通过群组链接连接? 通过群组链接/二维码连接 总是通过中继连接 - 允许您的联系人永久删除已发送消息。 + 允许您的联系人不可撤回地删除已发送消息。 联系人允许 仅有您的联系人许可后才允许语音消息。 您: %1$s @@ -359,7 +359,6 @@ 显示名: 全名: 显示名 - 全名(可选) 已结束 群组已删除 将为所有成员删除群组——此操作无法撤消! @@ -1095,7 +1094,6 @@ 上一条消息的散列不同。 下一条消息的 ID 不正确(小于或等于上一条)。 \n它可能是由于某些错误或连接被破坏才发生。 - 没有空格! 停止文件 停止发送文件? 即将停止发送文件。 @@ -1403,4 +1401,9 @@ - 连接到目录服务(BETA)! \n- 发送回执(至多20名成员)。 \n- 更快,更稳定。 + 打开 + 创建成员联系人时出错 + 发送私信来连接 + 发送私信 + 已直连 \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 24b02e64e3..b1d988e465 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -72,7 +72,6 @@ 顯示的名稱中不能有空白。 儲存設定? 顯示名稱 - 全名(可選擇的) 通話出錯 正在撥打… 通話中 @@ -1085,7 +1084,6 @@ 錯誤的訊息 ID 當你或你的連結在用舊的數據庫備份時會發生。 上一則訊息的雜奏則是不同的。 - 沒有空位! 應用程式密碼 迅速以及不用等待發送者在線! 波蘭文界面 From 227007c8f611b62c7b5400d43f8cf54c52ce7469 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 10 Nov 2023 19:49:23 +0200 Subject: [PATCH 047/174] add /switch remote host (#3342) * Add SwitchRemoteHost * Add message test * Match remote prefix and the rest of the line * Move prefix match to utils --- src/Simplex/Chat.hs | 2 ++ src/Simplex/Chat/Controller.hs | 6 ++-- src/Simplex/Chat/Remote.hs | 11 +++++++ src/Simplex/Chat/View.hs | 6 ++++ tests/ChatTests/Utils.hs | 3 ++ tests/RemoteTests.hs | 52 ++++++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 664661603b..dad00fcefa 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1953,6 +1953,7 @@ processChatCommand = \case p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_ ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts + SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ StartRemoteHost rh_ -> withUser_ $ do (remoteHost_, inv) <- startRemoteHost' rh_ pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} @@ -5977,6 +5978,7 @@ chatCommandP = "/set device name " *> (SetLocalDeviceName <$> textP), -- "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, + "/switch remote host " *> (SwitchRemoteHost <$> ("local" $> Nothing <|> (Just <$> A.decimal))), "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False))))), "/stop remote host " *> (StopRemoteHost <$> ("new" $> RHNew <|> RHId <$> A.decimal)), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6832bb5621..a9950372bc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -425,7 +425,7 @@ data ChatCommand -- | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host - -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host + | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RHKey -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} @@ -456,7 +456,7 @@ allowRemoteCommand = \case QuitChat -> False ListRemoteHosts -> False StartRemoteHost _ -> False - -- SwitchRemoteHost {} -> False + SwitchRemoteHost {} -> False StoreRemoteFile {} -> False GetRemoteFile {} -> False StopRemoteHost _ -> False @@ -644,6 +644,7 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} + | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} @@ -1051,6 +1052,7 @@ throwDBError = throwError . ChatErrorDatabase -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteHostError = RHEMissing -- ^ No remote session matches this identifier + | RHEInactive -- ^ A session exists, but not active | RHEBusy -- ^ A session is already running | RHEBadState -- ^ Illegal state transition | RHEBadVersion {appVersion :: AppVersion} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 53b63d72b1..ef5589e5aa 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -248,6 +248,17 @@ listRemoteHosts = do rhInfo active rh@RemoteHost {remoteHostId} = remoteHostInfo rh (M.member (RHId remoteHostId) active) +switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo) +switchRemoteHost rhId_ = do + rhi_ <- forM rhId_ $ \rhId -> do + let rhKey = RHId rhId + rhi <- withError (const $ ChatErrorRemoteHost rhKey RHEMissing) $ (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) + active <- chatReadVar remoteHostSessions + case M.lookup rhKey active of + Just RHSessionConnected {} -> pure rhi + _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive + rhi_ <$ chatWriteVar currentRemoteHost rhId_ + -- XXX: replacing hostPairing replaced with sessionActive, could be a ($>) remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo remoteHostInfo RemoteHost {remoteHostId, storePath, hostName} sessionActive = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ca47cb15e5..177f3400dd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -276,6 +276,12 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] + CRCurrentRemoteHost rhi_ -> + [ maybe + "Using local profile" + (\RemoteHostInfo {remoteHostId = rhId, hostName} -> "Using remote host " <> sShow rhId <> " (" <> plain hostName <> ")") + rhi_ + ] CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostStarted {remoteHost_, invitation} -> [ maybe "new remote host started" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " started") remoteHost_, diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index a2aff4bf54..83b0d507b9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -327,6 +327,9 @@ cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line ($<#) :: HasCallStack => (TestCC, String) -> String -> Expectation (cc, uName) $<# line = (dropTime . dropUser uName <$> getTermLine cc) `shouldReturn` line +(^<#) :: HasCallStack => (TestCC, String) -> String -> Expectation +(cc, p) ^<# line = (dropTime . dropStrPrefix p <$> getTermLine cc) `shouldReturn` line + (⩗) :: HasCallStack => TestCC -> String -> Expectation cc ⩗ line = (dropTime . dropReceipt <$> getTermLine cc) `shouldReturn` line diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index b35e54032f..9c135a81a5 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -41,6 +41,8 @@ remoteTests = describe "Remote" $ do describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest it "should send files from CLI wihtout /store" remoteCLIFileTest + it "switches remote hosts" switchRemoteHostTest + it "indicates remote hosts" indicateRemoteHostTest -- * Chat commands @@ -323,6 +325,56 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile 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 + startRemote mobile desktop + contactBob desktop bob + + desktop ##> "/contacts" + desktop <## "bob (Bob)" + + desktop ##> "/switch remote host local" + desktop <## "Using local profile" + desktop ##> "/contacts" + + desktop ##> "/switch remote host 1" + desktop <## "Using remote host 1 (Mobile)" + desktop ##> "/contacts" + desktop <## "bob (Bob)" + + desktop ##> "/switch remote host 123" + desktop <## "remote host 123 error: RHEMissing" + + stopDesktop mobile desktop + desktop ##> "/contacts" + desktop ##> "/switch remote host 1" + desktop <## "remote host 1 error: RHEInactive" + desktop ##> "/contacts" + +indicateRemoteHostTest :: FilePath -> IO () +indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do + connectUsers desktop cath + startRemote mobile desktop + contactBob desktop bob + -- remote contact -> remote host + bob #> "@alice hi" + desktop <#. "bob> hi" + -- local -> remote + cath #> "@alice_desktop hello" + (desktop, "[local] ") ^<# "cath> hello" + -- local -> local + desktop ##> "/switch remote host local" + desktop <## "Using local profile" + desktop <##> cath + -- local -> remote + bob #> "@alice what's up?" + (desktop, "[remote: 1] ") ^<# "bob> what's up?" + + -- local -> local after disconnect + stopDesktop mobile desktop + desktop <##> cath + cath <##> desktop + -- * Utils startRemote :: TestCC -> TestCC -> IO () From 9cc232054c9cf620b52af05403783eccb8529743 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 10 Nov 2023 17:57:02 +0000 Subject: [PATCH 048/174] website: translations (#3345) * core: notify contact about contact deletion * Translated using Weblate (French) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ * Translated using Weblate (Italian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Arabic) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Hebrew) Currently translated at 34.1% (86 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Spanish) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ * Translated using Weblate (Hebrew) Currently translated at 41.2% (104 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 74.6% (188 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Czech) Currently translated at 92.0% (232 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/cs/ * Translated using Weblate (Arabic) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Hebrew) Currently translated at 90.4% (228 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 94.8% (239 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (German) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: M1K4 Co-authored-by: Random Co-authored-by: jonnysemon Co-authored-by: ItaiShek Co-authored-by: No name Co-authored-by: Eric Co-authored-by: zenobit Co-authored-by: mlanp --- website/langs/ar.json | 11 ++- website/langs/cs.json | 2 +- website/langs/de.json | 9 +- website/langs/es.json | 9 +- website/langs/fr.json | 9 +- website/langs/he.json | 180 ++++++++++++++++++++++++++++++++++++- website/langs/it.json | 9 +- website/langs/nl.json | 9 +- website/langs/zh_Hans.json | 17 ++-- 9 files changed, 240 insertions(+), 15 deletions(-) diff --git a/website/langs/ar.json b/website/langs/ar.json index 3fe698a3fe..09a8a268ac 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -132,7 +132,7 @@ "donate-here-to-help-us": "تبرّع هنا لمساعدتنا", "sign-up-to-receive-our-updates": "اشترك للحصول على آخر مستجداتنا", "enter-your-email-address": "أدخل عنوان بريدك الإلكتروني", - "get-simplex": "احصل على SimpleX desktop app", + "get-simplex": "احصل على تطبيق سطح المكتب SimpleX", "why-simplex-is": "لماذا SimpleX", "unique": "فريد من نوعه", "learn-more": "اقرأ أكثر", @@ -243,5 +243,12 @@ "releases-to-this-repo-are-done-1-2-days-later": "يتم إصدار الإصدارات إلى هذا المستودع بعد يوم أو يومين", "f-droid-page-simplex-chat-repo-section-text": "لإضافته إلى عميل F-Droid، امسح رمز QR أو استخدم عنوان URL هذا:", "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يرجى تصدير قاعدة بيانات الدردشة وإعادة تثبيت التطبيق.", - "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تشفير بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق" + "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تشفير بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق", + "hero-overlay-3-title": "التقييم الأمني", + "hero-overlay-card-3-p-2": "قامت Trail of Bits بمراجعة مكونات التشفير والشبكات الخاصة بمنصة SimpleX في نوفمبر 2022.", + "hero-overlay-card-3-p-3": "اقرأ المزيد في الإعلان.", + "jobs": "انضم للفريق", + "hero-overlay-3-textlink": "التقييم الأمني", + "hero-overlay-card-3-p-1": "Trail of Bits هي شركة رائدة في مجال الاستشارات الأمنية والتكنولوجية، ومن بين عملائها شركات التكنولوجيا الكبرى والوكالات الحكومية ومشاريع blockchain الكبرى.", + "docs-dropdown-9": "التنزيلات" } diff --git a/website/langs/cs.json b/website/langs/cs.json index 93aed0c9b2..d087fd5bec 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -2,7 +2,7 @@ "simplex-private-card-10-point-2": "Umožňuje doručovat zprávy bez identifikátoru uživatelských profilů, což poskytuje lepší soukromí metadat než alternativy.", "simplex-unique-4-overlay-1-title": "Plně decentralizované — uživatelé vlastní síť SimpleX", "hero-overlay-card-1-p-6": "Přečtěte si více v SimpleX whitepaper.", - "hero-overlay-card-1-p-2": "Pro doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními platformami dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení - neexistují žádné dlouhodobé identifikátory.", + "hero-overlay-card-1-p-2": "Pro doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními platformami dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení — neexistují žádné dlouhodobé identifikátory.", "hero-overlay-card-1-p-3": "Definujete, které servery se mají používat k přijímání zpráv, vašich kontaktů — servery, které používáte k odesílání zpráv. Každá konverzace bude pravděpodobně používat dva různé servery.", "hero-overlay-card-2-p-3": "I v těch nejsoukromějších aplikacích, které používají služby Tor v3, pokud mluvíte se dvěma různými kontakty prostřednictvím stejného profilu, může být prokázáno, že jsou spojeni se stejnou osobou.", "simplex-network-overlay-card-1-p-1": "P2P protokoly a aplikace pro zasílání zpráv mají různé problémy, které je činí méně spolehlivými než SimpleX, složitějšími na analýzu a zranitelnými vůči několika typům útoků.", diff --git a/website/langs/de.json b/website/langs/de.json index 052b73235a..5eb432720e 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -243,5 +243,12 @@ "simplex-chat-repo": "SimpleX Chat Repository", "stable-and-beta-versions-built-by-developers": "Von den Entwicklern erstellte stabile und Beta-Versionen", "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen scannen Sie den QR-Code oder nutzen Sie diese URL:", - "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen" + "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen", + "hero-overlay-3-title": "Sicherheits-Gutachten", + "hero-overlay-card-3-p-2": "Trail of Bits untersuchte im November 2022 die kryptografischen und Netzwerk-Komponenten der SimpleX-Plattform.", + "hero-overlay-card-3-p-3": "Lesen Sie mehr dazu in der Ankündigung.", + "jobs": "Treten Sie dem Team bei", + "hero-overlay-3-textlink": "Sicherheits-Gutachten", + "hero-overlay-card-3-p-1": "Trail of Bits ist eine führende Security- und Technologie-Unternehmensberatung, deren Kunden aus den Bereichen Big-Tech, Regierungsbehörden und großen Blockchain-Projekten stammen.", + "docs-dropdown-9": "Downloads" } diff --git a/website/langs/es.json b/website/langs/es.json index dfbb95d725..7c418b7e25 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -243,5 +243,12 @@ "f-droid-page-f-droid-org-repo-section-text": "Los repositorios de SimpleX Chat y F-Droid.org firman con distinto certificado. Para cambiar, por favor exportar la base de datos y reinstala la aplicación.", "signing-key-fingerprint": "Huella digital de la clave de firma (SHA-256)", "releases-to-this-repo-are-done-1-2-days-later": "Las versiones aparecen 1-2 días más tarde en este repositorio", - "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo" + "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo", + "hero-overlay-3-title": "Evaluación de la seguridad", + "hero-overlay-card-3-p-2": "Trail of Bits revisó la criptografía y los componentes de red de la plataforma SimpleX en noviembre de 2022.", + "hero-overlay-card-3-p-3": "Más información en la noticia.", + "jobs": "Únete al equipo", + "hero-overlay-3-textlink": "Evaluación de la seguridad", + "hero-overlay-card-3-p-1": "Trail of Bits es una consultora de seguridad y tecnología líder cuyos clientes incluyen grandes tecnológicas, agencias gubernamentales e importantes proyectos de blockchain.", + "docs-dropdown-9": "Descargas" } diff --git a/website/langs/fr.json b/website/langs/fr.json index 37dc6d5a14..b4d0ea17af 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -244,5 +244,12 @@ "signing-key-fingerprint": "Empreinte de signature numérique (SHA-256)", "f-droid-org-repo": "Dépot F-Droid.org", "stable-versions-built-by-f-droid-org": "Versions stables créées par F-Droid.org", - "comparison-section-list-point-4a": "Les relais SimpleX ne peuvent pas compromettre le chiffrement e2e. Vérifier le code de sécurité pour limiter les attaques sur le canal hors bande" + "comparison-section-list-point-4a": "Les relais SimpleX ne peuvent pas compromettre le chiffrement e2e. Vérifier le code de sécurité pour limiter les attaques sur le canal hors bande", + "hero-overlay-3-title": "Évaluation de sécurité", + "hero-overlay-card-3-p-2": "Trail of Bits a examiné les composants cryptographiques et réseau de la plateforme SimpleX en novembre 2022.", + "hero-overlay-card-3-p-3": "En savoir plus sur l'annonce.", + "jobs": "Rejoignez notre équipe", + "hero-overlay-3-textlink": "Évaluation de sécurité", + "hero-overlay-card-3-p-1": "Trail of Bits est un cabinet leader dans le secteur de la sécurité et des technologies qui compte parmi ses clients des grandes entreprises de la tech, des agences gouvernementales et d'importants projets de blockchain.", + "docs-dropdown-9": "Téléchargements" } diff --git a/website/langs/he.json b/website/langs/he.json index c814e405c0..f4e776c67f 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -33,7 +33,7 @@ "simplex-private-card-2-point-1": "שכבה נוספת של הצפנת שרת למסירה לנמען, כדי למנוע קורלציה בין תעבורת השרת המתקבלת ונשלחת במקרה שאבטחת TLS נפגעה.", "simplex-private-card-3-point-1": "עבור חיבורי שרת-לקוח, נעשה שימוש רק ב-TLS 1.2/1.3 עם אלגוריתמים חזקים.", "simplex-private-card-3-point-2": "טביעת אצבע של שרת ואיגוד ערוצים מונעים התקפת אדם בתווך (MITM) והתקפת שליחה מחדש (Replay attack).", - "simplex-private-card-5-point-1": "SimpleX משתמש בריפוד תוכן עבור כל שכבת הצפנה כדי לסכל התקפות בגודל הודעה.", + "simplex-private-card-5-point-1": "SimpleX משתמש בריווח התוכן עבור כל שכבת הצפנה כדי לסכל התקפות על גודל הודעה.", "simplex-private-card-5-point-2": "זה גורם להודעות בגדלים שונים להיראות זהים לשרתים ולמשקיפים ברשת.", "simplex-private-card-6-point-1": "פלטפורמות תקשורת רבות חשופות להתקפות אדם בתווך (MITM) על ידי שרתים או ספקי רשת.", "simplex-private-card-9-point-2": "זה מפחית את וקטורי ההתקפה, בהשוואה למתווכי הודעות מסורתיים, ואת המטא-נתונים הזמינים.", @@ -74,5 +74,181 @@ "privacy-matters-1-overlay-1-title": "פרטיות חוסכת לכם כסף", "privacy-matters-2-overlay-1-linkText": "פרטיות מעניקה לכם עוצמה", "privacy-matters-3-overlay-1-linkText": "פרטיות מגנה על החופש שלכם", - "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות." + "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות.", + "hero-overlay-3-title": "הערכת אבטחה", + "simplex-unique-1-overlay-1-title": "פרטיות מלאה של הזהות, הפרופיל, אנשי הקשר והמטא נתונים שלך", + "simplex-unique-3-overlay-1-title": "בעלות, שליטה ואבטחה של הנתונים שלך", + "simplex-unique-2-overlay-1-title": "ההגנה הטובה ביותר מפני ספאם וניצול לרעה", + "simplex-unique-3-title": "אתה שולט בנתונים שלך", + "hero-overlay-3-textlink": "הערכת אבטחה", + "simplex-unique-4-overlay-1-title": "מבוזר לחלוטין — המשתמשים הם הבעלים של רשת SimpleX", + "simplex-unique-2-title": "אתה מוגן
מפני ספאם וניצול לרעה", + "simplex-unique-4-title": "רשת SimpleX בבעלותך", + "simplex-unique-1-title": "יש לך פרטיות מלאה", + "simplex-private-card-10-point-1": "SimpleX משתמש בכתובות ואישורים אנונימיים זמניים בזוגות עבור כל איש קשר של משתמש או חבר בקבוצה.", + "privacy-matters-overlay-card-1-p-1": "הרבה חברות גדולות משתמשות במידע עם מי אתה בקשר כדי להעריך את ההכנסה שלך, למכור לך מוצרים שאתה לא באמת צריך ולקבוע את המחירים.", + "hero-overlay-card-3-p-2": "Trail of Bits סקרה את רכיבי ההצפנה והרשת של פלטפורמת SimpleX בנובמבר 2022.", + "hero-overlay-card-2-p-4": "SimpleX מגן מפני התקפות אלה בכך שאין בעיצובו מזהי משתמש. ואם אתה משתמש במצב זהות נסתרת, יהיה לך שם תצוגה שונה לכל איש קשר, תוך הימנעות מכל מידע משותף ביניהם.", + "hero-overlay-card-2-p-1": "כאשר למשתמשים יש זהויות מתמשכות, גם אם זה רק מספר אקראי, כמו מזהה הפעלה, קיים סיכון שהספק או התוקף יוכלו לראות כיצד המשתמשים מחוברים וכמה הודעות הם שולחים.", + "hero-overlay-card-1-p-5": "רק מכשירי לקוח מאחסנים פרופילי משתמשים, אנשי קשר וקבוצות; ההודעות נשלחות עם הצפנה דו-שכבתית מקצה לקצה.", + "privacy-matters-overlay-card-1-p-2": "קמעונאים מקוונים יודעים שאנשים עם הכנסה נמוכה יותר נוטים יותר לבצע רכישות דחופות, ולכן הם עשויים לגבות מחירים גבוהים יותר או להסיר הנחות.", + "hero-overlay-card-3-p-3": "קרא עוד בהודעה.", + "hero-overlay-card-1-p-2": "כדי להעביר הודעות, במקום מזהי משתמש המשמשים את כל הפלטפורמות האחרות, SimpleX משתמש במזהים אנונימיים זמניים זוגיים של תורי הודעות, נפרדים עבור כל אחד מהחיבורים שלך — אין מזהים לטווח ארוך.", + "hero-overlay-card-2-p-2": "לאחר מכן הם יוכלו לקשר מידע זה עם הרשתות החברתיות הציבוריות הקיימות, ולקבוע כמה זהויות אמיתיות.", + "privacy-matters-overlay-card-1-p-3": "חלק מחברות פיננסיות וביטוח משתמשות בגרפים חברתיים כדי לקבוע שיעורי ריבית ופרמיות. לעתים קרובות זה גורם לאנשים עם הכנסה נמוכה יותר לשלם יותר — זה ידוע בתור 'פרמיית עוני'.", + "hero-overlay-card-1-p-4": "עיצוב זה מונע דליפה כלשהי של משתמשים' מטא נתונים ברמת האפליקציה. כדי לשפר עוד יותר את הפרטיות ולהגן על כתובת ה-IP שלך, תוכל להתחבר לשרתי הודעות באמצעות Tor.", + "hero-overlay-card-2-p-3": "אפילו עם האפליקציות הפרטיות ביותר המשתמשות בשירותי Tor v3, אם אתה מדבר עם שני אנשי קשר שונים דרך אותו פרופיל הם יכולים להוכיח שהם מחוברים לאותו אדם.", + "hero-overlay-card-3-p-1": "Trail of Bits היא חברת ייעוץ מובילה בתחום אבטחה וטכנולוגיה שלקוחותיה כוללים ביג טק, סוכנויות ממשלתיות ופרויקטי בלוקצ'יין גדולים.", + "hero-overlay-card-1-p-1": "משתמשים רבים שאלו: אם ל-SimpleX אין מזהי משתמש, איך הוא יכול לדעת לאן להעביר הודעות?", + "simplex-network-overlay-card-1-li-2": "לעיצוב של SimpleX, בניגוד לרוב רשתות ה-P2P, אין מזהי משתמש גלובלי מכל סוג שהוא, אפילו זמני, והוא משתמש רק במזהים זמניים זוגיים, המספקים אנונימיות טובה יותר והגנה על מטא נתונים.", + "hero-overlay-card-1-p-6": "קרא עוד בסקירה הטכנית של SimpleX.", + "simplex-network-overlay-card-1-p-1": "לפרוטוקולי הודעות ואפליקציות P2P יש בעיות שונות שהופכות אותן לפחות אמינות מ-SimpleX, מורכבות יותר לניתוח, ופגיעות למספר סוגי התקפות.", + "hero-overlay-card-1-p-3": "אתה מגדיר באילו שרתים להשתמש כדי לקבל את ההודעות, אנשי הקשר שלך — השרתים שבהם אתה משתמש כדי לשלוח את ההודעות אליהם. סביר שכל שיחה תהשתמש בשני שרתים שונים.", + "copy-the-command-below-text": "העתיקו את הפקודה למטה והשתמש בה בצ'אט:", + "contact-hero-p-1": "המפתחות הציבוריים וכתובת תור ההודעות בקישור זה אינם נשלחים דרך הרשת כאשר אתם צופים בדף זה — הם כלולים בקטע הגיבוב (hash) של כתובת הקישור.", + "scan-the-qr-code-with-the-simplex-chat-app": "סרקו את קוד ה-QR באפליקציית SimpleX Chat", + "simplex-unique-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", + "simplex-unique-overlay-card-3-p-3": "שלא כמו שרתי רשת מאוחדים (דוא\"ל, XMPP או Matrix), שרתי SimpleX אינם מאחסנים חשבונות משתמשים, הם רק מעבירים הודעות, ומגנים על הפרטיות של שני הצדדים.", + "privacy-matters-overlay-card-3-p-2": "אחד הסיפורים המזעזעים ביותר הוא החוויה של מוחמד אולד סלאחי המתוארת בספר הזיכרונות שלו ומוצגת בסרט \"המאוריטני\". הוא הוכנס למתקן המעצר בגואנטנמו, ללא משפט, ועונה שם במשך 15 שנים לאחר שיחת טלפון לקרוב משפחתו באפגניסטן, בחשד שהיה מעורב בפיגועים ב-11 בספטמבר, למרות שחי בגרמניה בעשר השנים הקודמות.", + "simplex-network-2-desc": "שרתי ממסר SimpleX אינם מאחסנים פרופילי משתמש, אנשי קשר והודעות שנמסרו, אינם מתחברים זה לזה, ואין ספריית שרתים.", + "installing-simplex-chat-to-terminal": "התקנת SimpleX Chat למסוף שורת הפקודה", + "use-this-command": "השתמשו בפקודה הזו:", + "to-make-a-connection": "כדי ליצור חיבור:", + "enter-your-email-address": "הזינו את כתובת הדוא\"ל שלכם", + "tap-to-close": "הקישו כדי לסגור", + "simplex-unique-card-4-p-2": "אתם יכולים להשתמש ב-SimpleX עם השרתים שלכם או עם השרתים שסופקו על ידינו — ועדיין להתחבר לכל משתמש.", + "comparison-point-4-text": "רשת בודדת או מרכזית", + "privacy-matters-overlay-card-2-p-2": "כדי להיות אובייקטיבי ולקבל החלטות עצמאיות אתם צריכים להיות בשליטה על מרחב המידע שלכם. זה אפשרי רק אם אתם משתמשים בפלטפורמת תקשורת פרטית שאין לה גישה לגרף החברתי שלכם.", + "simplex-unique-overlay-card-4-p-1": "אתם יכולים להשתמש ב-SimpleX עם השרתים שלכם ועדיין לתקשר עם אנשים שמשתמשים בשרתים המוגדרים מראש שסופקו על ידינו.", + "simplex-chat-for-the-terminal": "SimpleX Chat עבור מסוף שורת הפקודה", + "simplex-network-overlay-card-1-li-3": "P2P אינו פותר את בעיית התקפת MITM, ורוב ההטמעות הקיימות אינן משתמשות בהודעות out-of-band עבור החלפת המפתחות הראשונית. SimpleX משתמש בהודעות out-of-band או, במקרים מסוימים, בחיבורים מאובטחים ומהימנים קיימים מראש לצורך החלפת המפתחות הראשונית.", + "the-instructions--source-code": "ההוראות כיצד להוריד או לקמפל אותו מקוד המקור.", + "simplex-network-section-desc": "Simplex Chat מספק את הפרטיות הטובה ביותר על ידי שילוב היתרונות של P2P ורשתות מאוחדות.", + "privacy-matters-section-subheader": "שמירה על פרטיות המטא נתונים שלכם — עם מי אתם מדברים — מגנה עליכם מפני:", + "if-you-already-installed": "אם כבר התקנתם", + "join": "הצטרפו", + "privacy-matters-section-header": "מדוע פרטיות חשובה", + "protocol-3-text": "פרוטוקולי P2P", + "contact-hero-header": "קיבלת כתובת להתחבר ב-SimpleX Chat", + "contact-hero-subheader": "סרקו את קוד ה-QR עם אפליקציית SimpleX Chat בטלפון או בטאבלט שלכם.", + "join-us-on-GitHub": "הצטרפו אלינו ב-GitHub", + "comparison-section-header": "השוואה לפרוטוקולים אחרים", + "invitation-hero-header": "קיבלת קישור חד-פעמי להתחבר ב-SimpleX Chat", + "simplex-network-1-header": "בניגוד לרשתות P2P", + "hide-info": "הסתר מידע", + "comparison-point-1-text": "דורש זהות גלובלית", + "comparison-point-3-text": "תלות ב-DNS", + "install-simplex-app": "התקינו את אפליקציית SimpleX", + "comparison-point-2-text": "אפשרות של התקפת MITM", + "scan-the-qr-code-with-the-simplex-chat-app-description": "המפתחות הציבוריים וכתובת תור ההודעות בקישור זה אינם נשלחים דרך הרשת כאשר אתם צופים בדף זה —
הם כלולים בקטע הגיבוב (hash) של כתובת הקישור.", + "open-simplex-app": "פתחו את אפליקציית Simplex", + "see-simplex-chat": "ראו SimpleX Chat", + "github-repository": "מאגר GitHub", + "connect-in-app": "התחברו באפליקציה", + "privacy-matters-3-title": "העמדה לדין בשל קשר תמים", + "donate-here-to-help-us": "תרמו כאן כדי לעזור לנו", + "simplex-unique-overlay-card-2-p-2": "אפילו עם כתובת המשתמש האופציונלית, בעוד שניתן להשתמש בה לשליחת בקשות ליצירת קשר דואר זבל, אתה יכול לשנות או למחוק אותה לחלוטין מבלי לאבד אף אחד מהחיבורים שלך.", + "simplex-network-2-header": "בניגוד לרשתות מאוחדות", + "simplex-unique-card-3-p-1": "SimpleX מאחסן את כל נתוני המשתמש במכשירי הלקוח בפורמט מסד נתונים מוצפן נייד — ניתן להעביר אותו למכשיר אחר.", + "contact-hero-p-3": "השתמשו בקישורים למטה כדי להוריד את האפליקציה.", + "simplex-network-1-desc": "כל ההודעות נשלחות דרך השרתים, שמספקים פרטיות טובה יותר של מטא נתונים וגם מסירת הודעות אסינכרונית אמינה, תוך הימנעות מהרבה", + "simplex-unique-overlay-card-1-p-2": "על מנת להעביר הודעות SimpleX משתמש בכתובות אנונימיות בזוגות של תורי הודעות חד-כיווניים, נפרדים עבור הודעות שהתקבלו ונשלחו, בדרך כלל דרך שרתים שונים . השימוש ב-SimpleX דומה לשימוש בדוא\"ל או טלפון ”חד-פעמי“ עבור כל איש קשר, וללא טרחה לנהל אותם.", + "simplex-unique-overlay-card-3-p-4": "אין מזהים או טקסט מוצפן משותף בין תעבורת שרת שנשלחה ומתקבלת — אם מישהו צופה בזה, הם לא יכולים לקבוע בקלות מי מתקשר עם מי, גם אם ה-TLS נפרץ.", + "get-simplex": "הורידו את אפליקציית שולחן העבודה SimpleX", + "privacy-matters-overlay-card-3-p-1": "לכולם צריך להיות אכפת מהפרטיות והאבטחה של התקשורת שלהם — שיחות לא מזיקות עלולות להעמיד אתכם בסכנה, גם אם אין לכם מה להסתיר.", + "simplex-unique-overlay-card-4-p-3": "אם אתם שוקלים לפתח עבור פלטפורמת SimpleX, למשל, את הצ'אט בוט עבור משתמשי אפליקציית SimpleX, או שילוב של ספריית SimpleX Chat באפליקציות לנייד שלכם, אנא צרו קשר לכל עצה ותמיכה.", + "simplex-network-3-header": "רשת SimpleX", + "simplex-unique-card-2-p-1": "מכיוון שאין לכם מזהה או כתובת קבועה בפלטפורמת SimpleX, אף אחד לא יכול ליצור איתכם קשר אלא אם כן אתם חולקים כתובת משתמש חד פעמית או זמנית, כקוד QR או קישור.", + "join-the-REDDIT-community": "הצטרפו לקהילת REDDIT", + "privacy-matters-overlay-card-3-p-3": "אנשים רגילים נעצרים על מה שהם משתפים באינטרנט, אפילו דרך החשבונות ה'אנונימיים' שלהם, אפילו במדינות דמוקרטיות.", + "simplex-unique-overlay-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", + "simplex-unique-overlay-card-4-p-2": "פלטפורמת SimpleX משתמשת בפרוטוקול פתוח ומספקת SDK ליצירת צ'אט בוטים, המאפשר הטמעה של שירותים שמשתמשים יכולים לתקשר איתם באמצעות אפליקציות SimpleX Chat — אנחנו ממש מצפים לראות אילו שירותי SimpleX אתם יכולים לבנות.", + "contact-hero-p-2": "עדיין לא הורדתם את ה-SimpleX Chat?", + "why-simplex-is": "מדוע SimpleX הוא", + "simplex-network-section-header": "רשת SimpleX", + "tap-the-connect-button-in-the-app": "הקישו על הלחצן 'התחבר' באפליקציה", + "unique": "ייחודי", + "simplex-network-1-overlay-linktext": "בעיות של רשתות P2P", + "protocol-2-text": "XMPP, Matrix", + "simplex-network-overlay-card-1-li-4": "יישומי P2P יכולים להיחסם על ידי ספקי אינטרנט מסוימים (כמו BitTorrent). SimpleX הוא אגנוסטי לתעבורה - הוא יכול לעבוד על פרוטוקולי אינטרנט סטנדרטיים, למשל WebSockets.", + "privacy-matters-overlay-card-2-p-1": "לפני זמן לא רב ראינו את הבחירות הראשיות שעברו מניפולציות על ידי חברת ייעוץ מכובדת שהשתמשה בגרפים החברתיים שלנו כדי לעוות את השקפתנו על העולם האמיתי ולתמרן את ההצבעות שלנו.", + "privacy-matters-overlay-card-2-p-3": "SimpleX היא הפלטפורמה הראשונה שאין לה מזהי משתמש על פי עיצובה , בדרך זו מגינה על גרף החיבורים שלך טוב יותר מכל חלופה ידועה.", + "learn-more": "למדו עוד", + "scan-qr-code-from-mobile-app": "סרקו קוד QR באפליקציה לנייד", + "more-info": "עוד מידע", + "protocol-1-text": "Signal, פלטפורמות גדולות", + "simplex-network-overlay-card-1-li-6": "רשתות P2P עשויות להיות פגיעות להתקפת DRDoS , כאשר הלקוחות יכולים לשדר מחדש ולהגביר את התעבורה, וכתוצאה מכך מניעת שירות בכל הרשת. לקוחות SimpleX מעבירים רק תעבורה מחיבור ידוע ואינם יכולים להיות בשימוש על ידי תוקף כדי להגביר את התעבורה בכל הרשת.", + "if-you-already-installed-simplex-chat-for-the-terminal": "אם כבר התקנתם את SimpleX Chat עבור מסוף שורת הפקודה", + "simplex-unique-overlay-card-2-p-1": "מכיוון שבפלטפורמת SimpleX אין לכם מזהים, אף אחד לא יכול ליצור איתכם קשר אלא אם כן אתם משתפים כתובת משתמש חד פעמית או זמנית, כקוד QR או קישור.", + "sign-up-to-receive-our-updates": "הירשמו כדי לקבל את העדכונים שלנו", + "simplex-private-section-header": "מה הופך את SimpleX לפרטי", + "we-invite-you-to-join-the-conversation": "אנו מזמינים אתכם להצטרף לשיחה", + "simplex-unique-overlay-card-1-p-3": "עיצוב זה מגן על הפרטיות של מי שאתה מתקשר איתו, מסתיר אותה משרתי פלטפורמת SimpleX ומכל צופה. כדי להסתיר את כתובת ה-IP שלך מהשרתים, אתה יכוללהתחבר לשרתי SimpleX באמצעות Tor.", + "privacy-matters-section-label": "ודאו שהמסנג'ר שלכם לא יכול לגשת לנתונים שלכם!", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat מאחסן את כל נתוני המשתמש רק במכשירי הלקוח באמצעות פורמט מסד נתונים מוצפן נייד שניתן לייצא ולהעביר לכל מכשיר נתמך.", + "simplex-network-3-desc": "השרתים מספקים תורים חד-כיווניים לחיבור המשתמשים, אך הם לא יכול לראות את גרף חיבור הרשת — רק המשתמשים יכולים לראות זאת.", + "simplex-unique-card-1-p-1": "SimpleX מגן על פרטיות הפרופיל, אנשי הקשר והמטא נתונים שלכם, מסתיר אותם משרתי פלטפורמת SimpleX ומכל משקיף.", + "simplex-unique-card-4-p-1": "רשת SimpleX מבוזרת לחלוטין ובלתי תלויה במטבעות קריפטוגרפיים כלשהם או כל פלטפורמה אחרת, מלבד האינטרנט.", + "guide-dropdown-5": "ניהול נתונים", + "no-federated": "לא - פדרלית", + "comparison-section-list-point-6": "למרות ש-P2P מופץ, הוא אינו פדרלי - הוא פועל כרשת אחת", + "guide-dropdown-9": "יצירת קשרים", + "comparison-section-list-point-7": "לרשתות P2P יש רשות מרכזית או שכל הרשת יכולה להיפגע", + "docs-dropdown-1": "פלטפורמת SimpleX", + "simplex-private-card-6-point-2": "כדי למנוע זאת, אפליקציות SimpleX מעבירות מפתחות חד-פעמיים מחוץ לפס, כאשר אתם משתפים כתובת כקישור או כקוד QR.", + "no": "לא", + "no-secure": "לא - מאובטח", + "guide-dropdown-3": "קבוצות סודיות", + "no-resilient": "לא - גמיש", + "privacy-matters-overlay-card-3-p-4": "זה לא מספיק להשתמש במסנג'ר מוצפן מקצה לקצה, כולנו צריכים להשתמש במסנג'רים שמגנים על הפרטיות של הרשתות האישיות שלנו — שאליהן אנחנו מחוברים.", + "comparison-section-list-point-5": "אינו מגן על פרטיות המטא נתונים של המשתמשים", + "yes": "כן", + "guide-dropdown-8": "הגדרות אפליקציה", + "comparison-section-list-point-1": "בדרך כלל מבוססת על מספר טלפון, במקרים מסוימים על שמות משתמש", + "comparison-point-5-text": "רכיב מרכזי או מתקפת רשת רחבה", + "simplex-unique-card-1-p-2": "בניגוד לכל פלטפורמת הודעות קיימת אחרת, ל- SimpleX אין מזהים שהוקצו למשתמשים — אפילו לא מספרים אקראיים.", + "guide-dropdown-2": "שליחת הודעות", + "simplex-network-overlay-card-1-li-5": "כל רשתות ה-P2P המוכרות עשויות להיות פגיעות להתקפת Sybil, מכיוון שכל צומת ניתן לגילוי, והרשת פועלת כמכלול. אמצעים ידועים כדי למתן את זה דורשים רכיב ריכוזי או מערכת הוכחת עבודה יקרה. לרשת SimpleX אין יכולת גילוי של שרת, היא מפוצלת ופועלת כמספר תת-רשתות מבודדות, מה שהופך התקפות ברחבי הרשת לבלתי אפשריות.", + "guide-dropdown-4": "פרופילי צ'אט", + "see-here": "ראו כאן", + "comparison-section-list-point-3": "מפתח ציבורי או מזהה ייחודי גלובלי אחר", + "comparison-section-list-point-2": "כתובות מבוססות DNS", + "comparison-section-list-point-4": "אם השרתי המפעיל נפגעים. אמת את קוד האבטחה ב-Signal ובכמה אפליקציות אחרות כדי למתן את זה", + "guide-dropdown-7": "פרטיות ואבטחה", + "simplex-private-card-7-point-1": "על מנת להבטיח את שלמות ההודעות, הן ממוספרות ברצף וכוללות את הגיבוב (hash) של ההודעה הקודמת.", + "comparison-section-list-point-4a": "ממסרי SimpleX אינם יכולים לסכן הצפנה מקצה לקצה. ודא קוד אבטחה כדי לצמצם התקפות על ערוץ \"מחוץ לרשת\"", + "no-private": "לא - פרטי", + "guide": "מדריך", + "guide-dropdown-6": "שיחות שמע ווידאו", + "no-decentralized": "לא - מבוזר", + "simplex-private-card-1-point-1": "פרוטוקול ראצ'ט כפול —
העברת הודעות OTR עם סודיות מושלמת קדימה ושחזור פריצה.", + "simplex-private-card-8-point-1": "שרתי SimpleX פועלים כצמתי ערבוב עם זמן השהיה נמוך — להודעות הנכנסות והיוצאות יש סדר שונה.", + "guide-dropdown-1": "התחלה מהירה", + "privacy-matters-overlay-card-1-p-4": "פלטפורמת SimpleX מגנה על פרטיות החיבורים שלכם טוב יותר מכל חלופה, ומונעת באופן מלא את הפיכת הגרף החברתי להיות זמין לחברות או ארגונים כלשהם. גם כאשר אנשים משתמשים בשרתים המסופקים על ידי SimpleX Chat, איננו יודעים את מספר המשתמשים או החיבורים שלהם.", + "simplex-network-overlay-card-1-li-1": "רשתות P2P מסתמכות על גרסה כלשהי של DHT כדי לנתב הודעות. עיצובי DHT צריכים לאזן בין אחריות מסירה לבין זמן השהיה. ל- SimpleX יש גם אחריות מסירה טובה יותר וגם השהיה נמוכה יותר מאשר P2P, מכיוון שניתן להעביר את ההודעה כיתירות דרך מספר שרתים במקביל, באמצעות השרתים שנבחרו על ידי הנמען. ברשתות P2P ההודעה מועברת דרך O(log N) צמתים ברצף, תוך שימוש בצמתים שנבחרו על ידי האלגוריתם.", + "simplex-unique-overlay-card-1-p-1": "בניגוד לפלטפורמות הודעות אחרות, ל- SimpleX אין מזהים שהוקצו למשתמשים. הוא אינו מסתמך על מספרי טלפון, כתובות מבוססות דומיין (כמו דואר אלקטרוני או XMPP), שמות משתמש, מפתחות ציבוריים או אפילו מספרים אקראיים כדי לזהות את המשתמשים שלו — אנחנו לא יודעים כמה אנשים משתמשים בשרתי SimpleX שלנו.", + "docs-dropdown-5": "כיצד לארח שרת XFTP", + "docs-dropdown-3": "כיצד לגשת למסד הנתונים של צ'אט", + "on-this-page": "בעמוד זה", + "docs-dropdown-6": "שרתי WebRTC", + "newer-version-of-eng-msg": "יש גרסה חדשה יותר של דף זה באנגלית.", + "menu": "תפריט", + "click-to-see": "לחצו כדי לראות", + "docs-dropdown-7": "תרגמו את SimpleX Chat", + "docs-dropdown-2": "כיצד לגשת לקבצי אנדרואיד", + "docs-dropdown-4": "כיצד לארח שרת SMP", + "docs-dropdown-9": "הורדות", + "signing-key-fingerprint": "חתימת מפתח טביעת אצבע (SHA-256)", + "simplex-chat-via-f-droid": "SimpleX Chat דרך F-Droid", + "glossary": "מילון מונחים", + "jobs": "הצטרפו לצוות", + "releases-to-this-repo-are-done-1-2-days-later": "גרסאות למאגר זה משוחררות לאחר יום או יומיים", + "f-droid-org-repo": "מאגר F-Droid.org", + "stable-versions-built-by-f-droid-org": "גרסאות יציבות שנבנו על ידי F-Droid.org", + "back-to-top": "חזרה למעלה", + "simplex-chat-repo": "מאגר SimpleX Chat", + "stable-and-beta-versions-built-by-developers": "גרסאות יציבות ובטא שנבנו על ידי המפתחים", + "f-droid-page-simplex-chat-repo-section-text": "כדי להוסיף אותו ללקוח F-Droid שלכם, סרקו את קוד ה-QR או השתמשו בכתובת האתר הזו:", + "docs-dropdown-8": "שירות מדריך כתובות SimpleX", + "f-droid-page-f-droid-org-repo-section-text": "מאגרי SimpleX Chat ו-F-Droid.org חותמים על גרסאות עם מפתחות שונים. כדי לעבור, אנא ייצא את מסד הנתונים של הצ'אט והתקן מחדש את האפליקציה." } diff --git a/website/langs/it.json b/website/langs/it.json index fa254e66ef..b8a83aee52 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -243,5 +243,12 @@ "simplex-chat-repo": "Repo di SimpleX Chat", "stable-and-beta-versions-built-by-developers": "Versioni stabili e beta compilate dagli sviluppatori", "f-droid-page-simplex-chat-repo-section-text": "Per aggiungerlo al tuo client F-Droid scansiona il codice QR o usa questo URL:", - "comparison-section-list-point-4a": "I relay di SimpleX non possono compromettere la crittografia e2e. Verifica il codice di sicurezza per mitigare gli attacchi sul canale fuori banda" + "comparison-section-list-point-4a": "I relay di SimpleX non possono compromettere la crittografia e2e. Verifica il codice di sicurezza per mitigare gli attacchi sul canale fuori banda", + "hero-overlay-3-title": "Valutazione della sicurezza", + "hero-overlay-card-3-p-2": "Trail of Bits ha revisionato i componenti di crittografia e di rete della piattaforma SimpleX nel novembre 2022.", + "hero-overlay-card-3-p-3": "Maggiori informazioni nell'annuncio.", + "jobs": "Unisciti al team", + "hero-overlay-3-textlink": "Valutazione della sicurezza", + "hero-overlay-card-3-p-1": "Trail of Bits è leader nella consulenza di sicurezza e tecnologia, i cui clienti includono grandi aziende, agenzie governative e importanti progetti di blockchain.", + "docs-dropdown-9": "Download" } diff --git a/website/langs/nl.json b/website/langs/nl.json index 02bf471d3b..0cb3428563 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -243,5 +243,12 @@ "releases-to-this-repo-are-done-1-2-days-later": "De releases voor deze repository vinden 1-2 dagen later plaats", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat- en F-Droid.org-repository's ondertekenen builds met de verschillende sleutels. Om over te stappen, alstublieft exporteer de chatdatabase en installeer de app opnieuw.", "docs-dropdown-8": "SimpleX Directory Service", - "comparison-section-list-point-4a": "SimpleX relais kunnen de e2e-versleuteling niet in gevaar brengen. Controleer de beveiligingscode om aanvallen op out-of-band kanalen te beperken" + "comparison-section-list-point-4a": "SimpleX relais kunnen de e2e-versleuteling niet in gevaar brengen. Controleer de beveiligingscode om aanvallen op out-of-band kanalen te beperken", + "hero-overlay-3-title": "Beveiligings beoordeling", + "hero-overlay-card-3-p-2": "Trail of Bits heeft in november 2022 de cryptografie en netwerkcomponenten van het SimpleX-platform beoordeeld.", + "hero-overlay-card-3-p-3": "Lees meer in de aankondiging.", + "jobs": "Sluit je aan bij het team", + "hero-overlay-3-textlink": "Beveiligings beoordeling", + "hero-overlay-card-3-p-1": "Trail of Bits is een toonaangevend beveiligings- en technologieadviesbureau met klanten onder meer grote technologiebedrijven, overheidsinstanties en grote blockchain-projecten.", + "docs-dropdown-9": "Downloads" } diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index c327e160be..78e55a1f72 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -35,7 +35,7 @@ "simplex-network-1-overlay-linktext": "P2P网络存在的问题", "simplex-network-2-header": "不同于联邦网络", "comparison-point-1-text": "需要全球身份", - "no-secure": "不需要 - 安全", + "no-secure": "不可能 - 安全", "simplex-privacy": "SimpleX 的隐私性", "home": "主页", "developers": "开发者", @@ -185,14 +185,14 @@ "protocol-3-text": "P2P协议", "no": "不需要", "comparison-point-3-text": "对 DNS 的依赖", - "no-federated": "不需要 - 联邦的", + "no-federated": "不依赖 - 联邦式网络", "protocol-2-text": "XMPP、Matrix", "comparison-point-2-text": "中间人攻击的可能性", "comparison-point-5-text": "中央组件或其他全网攻击", "yes": "需要", "comparison-section-list-point-5": "不保护用户的元数据", - "no-resilient": "不需要 - 有抗御力", - "no-decentralized": "不需要 - 去中心化的", + "no-resilient": "不依赖 - 有韧性", + "no-decentralized": "不依赖 - 去中心化的", "comparison-section-list-point-3": "公钥或其他一些全球唯一的 ID", "comparison-section-list-point-4": "如果运营商的服务器受到威胁。 验证 Signal 和其他一些应用程序中的安全代码以缓解该问题", "comparison-section-list-point-1": "通常基于电话号码,在某些情况下基于用户名", @@ -243,5 +243,12 @@ "f-droid-page-simplex-chat-repo-section-text": "要将其添加到您的 F-Droid 客户端,请扫描二维码或使用以下 URL:", "comparison-section-list-point-4a": "SimpleX 中继无法破坏 e2e 加密。 验证安全代码以减轻对带外通道的攻击", "docs-dropdown-8": "SimpleX 目录服务", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和 F-Droid.org 存储库使用不同的密钥对构建进行签名。 如需切换,请导出聊天数据库并重新安装应用。" + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和 F-Droid.org 存储库使用不同的密钥对构建进行签名。 如需切换,请导出聊天数据库并重新安装应用。", + "hero-overlay-3-title": "安全性评估", + "hero-overlay-card-3-p-2": "2022年11月份,Trail of Bits 审核了 SimpleX 平台的密码学和网络部件。", + "hero-overlay-card-3-p-3": "更多内容见 该公告。", + "jobs": "加入团队", + "hero-overlay-3-textlink": "安全性评估", + "hero-overlay-card-3-p-1": "Trail of Bits 是一家领先的安全和技术咨询企业,其客户包括大型科技公司、政府机构和重要的区块链项目。", + "docs-dropdown-9": "下载" } From ae286124aaf21d273829c659fc2ed474dd5c80c7 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Nov 2023 03:27:06 +0800 Subject: [PATCH 049/174] ios: allow sound in silent mode (#3346) Co-authored-by: Avently --- apps/ios/Shared/Model/AudioRecPlay.swift | 24 +++++++++++++++++++ .../Views/Chat/ChatItem/CIVideoView.swift | 11 +++++++++ .../Views/Helpers/VideoPlayerView.swift | 7 +++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift index 78773f5ea5..a9d0d6c1d9 100644 --- a/apps/ios/Shared/Model/AudioRecPlay.swift +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -61,6 +61,7 @@ class AudioRecorder { await MainActor.run { AppDelegate.keepScreenOn(false) } + try? av.setCategory(AVAudioSession.Category.soloAmbient) logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)") return .error(error.localizedDescription) } @@ -76,6 +77,7 @@ class AudioRecorder { } recordingTimer = nil AppDelegate.keepScreenOn(false) + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient) } private func checkPermission() async -> Bool { @@ -129,6 +131,9 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { AppDelegate.keepScreenOn(true) guard let time = self.audioPlayer?.currentTime else { return } self.onTimer?(time) + AudioPlayer.changeAudioSession(true) + } else { + AudioPlayer.changeAudioSession(false) } } } @@ -157,6 +162,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { if let player = audioPlayer { player.stop() AppDelegate.keepScreenOn(false) + AudioPlayer.changeAudioSession(false) } audioPlayer = nil if let timer = playbackTimer { @@ -165,6 +171,24 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { playbackTimer = nil } + static func changeAudioSession(_ playback: Bool) { + // When there is a audio recording, setting any other category will disable sound + if AVAudioSession.sharedInstance().category == .playAndRecord { + return + } + if playback { + if AVAudioSession.sharedInstance().category != .playback { + logger.log("AudioSession: playback") + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers) + } + } else { + if AVAudioSession.sharedInstance().category != .soloAmbient { + logger.log("AudioSession: soloAmbient") + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient) + } + } + } + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { stop() self.onFinishPlayback?() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index bc7153ed47..be8b25a0fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -9,6 +9,7 @@ import SwiftUI import AVKit import SimpleXChat +import Combine struct CIVideoView: View { @EnvironmentObject var m: ChatModel @@ -28,6 +29,7 @@ struct CIVideoView: View { @State private var showFullScreenPlayer = false @State private var timeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil + @State private var publisher: AnyCancellable? = nil init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding, scrollProxy: ScrollViewProxy?) { self.chatItem = chatItem @@ -294,6 +296,14 @@ struct CIVideoView: View { m.stopPreviousRecPlay = url if let player = fullPlayer { player.play() + var played = false + publisher = player.publisher(for: \.timeControlStatus).sink { status in + if played || status == .playing { + AppDelegate.keepScreenOn(status == .playing) + AudioPlayer.changeAudioSession(status == .playing) + } + played = status == .playing + } fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in player.seek(to: CMTime.zero) player.play() @@ -308,6 +318,7 @@ struct CIVideoView: View { fullScreenTimeObserver = nil fullPlayer?.pause() fullPlayer?.seek(to: CMTime.zero) + publisher?.cancel() } } } diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 416fa0c378..33acf22ebe 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -38,8 +38,13 @@ struct VideoPlayerView: UIViewRepresentable { player.seek(to: CMTime.zero) player.play() } + var played = false context.coordinator.publisher = player.publisher(for: \.timeControlStatus).sink { status in - AppDelegate.keepScreenOn(status == .playing) + if played || status == .playing { + AppDelegate.keepScreenOn(status == .playing) + AudioPlayer.changeAudioSession(status == .playing) + } + played = status == .playing } return controller.view } From 83aaaa9ada44c6cce4d3775303749d34de425353 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 10 Nov 2023 20:45:41 +0000 Subject: [PATCH 050/174] translation: remove duplicate string --- .../common/src/commonMain/resources/MR/base/strings.xml | 1 - 1 file changed, 1 deletion(-) 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 8630e37e86..170a28f3d6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1642,7 +1642,6 @@ This is your link for group %1$s! Open group Repeat join request? - You are already joining the group via this link! Group already exists! You are already joining the group %1$s. Already joining the group! From f7b4e4b16abcc0b527df0d32543cd5ef48b075d7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 11 Nov 2023 09:36:16 +0000 Subject: [PATCH 051/174] core: 5.4.0.3 --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- stack.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 0ed57ec28c..b074ed540a 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e9b5a849ab18de085e8c69d239a9706b99bcf787 + tag: 9460551a042ce9dbd3f686576942fade823a6941 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 6f333d9bb1..09e83047e6 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.2 +version: 5.4.0.3 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index adb5d7d7d0..5ddf00d849 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e9b5a849ab18de085e8c69d239a9706b99bcf787" = "0b50mlnzwian4l9kx4niwnj9qkyp21ryc8x9d3il9jkdfxrx8kqi"; + "https://github.com/simplex-chat/simplexmq.git"."9460551a042ce9dbd3f686576942fade823a6941" = "1j5s7h55j6dpmiajdh380mma1jkffbn88qyqfgjn5nx6il2svkmz"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f8540eb6e7..622226a0c2 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.4.0.2 +version: 5.4.0.3 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/stack.yaml b/stack.yaml index da69b9e905..75d59b0397 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: e9b5a849ab18de085e8c69d239a9706b99bcf787 + commit: 9460551a042ce9dbd3f686576942fade823a6941 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From 11362941fdb9ab6f088287fa8e574475f12d7546 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 11 Nov 2023 12:12:04 +0000 Subject: [PATCH 052/174] 5.4.0-beta.3: iOS 181, Android 160, Desktop 16 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8366161fc5..e675772e7d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -142,11 +142,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 */; }; - 5CF4DF632AF906B1007893ED /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF5E2AF906B1007893ED /* libgmp.a */; }; - 5CF4DF642AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF5F2AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a */; }; - 5CF4DF652AF906B1007893ED /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF602AF906B1007893ED /* libffi.a */; }; - 5CF4DF662AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF612AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a */; }; - 5CF4DF672AF906B1007893ED /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF622AF906B1007893ED /* libgmpxx.a */; }; + 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF722AFF8D4D007893ED /* libffi.a */; }; + 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */; }; + 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */; }; + 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF752AFF8D4E007893ED /* libgmp.a */; }; + 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -423,11 +423,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 = ""; }; - 5CF4DF5E2AF906B1007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF4DF5F2AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a"; sourceTree = ""; }; - 5CF4DF602AF906B1007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF4DF612AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a"; sourceTree = ""; }; - 5CF4DF622AF906B1007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF4DF722AFF8D4D007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a"; sourceTree = ""; }; + 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF4DF752AFF8D4E007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -505,13 +505,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF4DF662AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CF4DF632AF906B1007893ED /* libgmp.a in Frameworks */, - 5CF4DF642AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a in Frameworks */, - 5CF4DF652AF906B1007893ED /* libffi.a in Frameworks */, + 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */, + 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CF4DF672AF906B1007893ED /* libgmpxx.a in Frameworks */, + 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */, + 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */, + 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -572,11 +572,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF4DF602AF906B1007893ED /* libffi.a */, - 5CF4DF5E2AF906B1007893ED /* libgmp.a */, - 5CF4DF622AF906B1007893ED /* libgmpxx.a */, - 5CF4DF5F2AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a */, - 5CF4DF612AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a */, + 5CF4DF722AFF8D4D007893ED /* libffi.a */, + 5CF4DF752AFF8D4E007893ED /* libgmp.a */, + 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */, + 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */, + 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */, ); path = Libraries; sourceTree = ""; @@ -1482,7 +1482,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1524,7 +1524,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1604,7 +1604,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1636,7 +1636,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1668,7 +1668,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1714,7 +1714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index d1ce585346..b16636d0d6 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.2 -android.version_code=159 +android.version_name=5.4-beta.3 +android.version_code=160 -desktop.version_name=5.4-beta.2 -desktop.version_code=15 +desktop.version_name=5.4-beta.3 +desktop.version_code=16 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 8b67ff7a00efaf39338ead9eeeec2f49447b2938 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 11 Nov 2023 16:03:12 +0000 Subject: [PATCH 053/174] core: remote error handling (#3347) * core: remote error handling * fix test, show DB errors --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Remote.hs | 61 ++++++++++++++++++-------------------- src/Simplex/Chat/View.hs | 2 ++ tests/RemoteTests.hs | 2 +- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dad00fcefa..88cb8dd25c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1955,7 +1955,7 @@ processChatCommand = \case ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ StartRemoteHost rh_ -> withUser_ $ do - (remoteHost_, inv) <- startRemoteHost' rh_ + (remoteHost_, inv) <- startRemoteHost rh_ pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} StopRemoteHost rh_ -> withUser_ $ closeRemoteHost rh_ >> ok_ DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index ef5589e5aa..6916a54a0e 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -122,8 +122,8 @@ setNewRemoteHostId rhKey rhId = do Just s -> Right () <$ TM.insert (RHId rhId) s sessions liftEither r -startRemoteHost' :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) -startRemoteHost' rh_ = do +startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) +startRemoteHost rh_ = do (rhKey, multicast, remoteHost_, pairing) <- case rh_ of Just (rhId, multicast) -> do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId @@ -134,8 +134,9 @@ startRemoteHost' rh_ = do (invitation, rchClient, vars) <- withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do + rhKeyVar <- newTVarIO rhKey atomically $ takeTMVar cmdOk - cleanupOnError rchClient $ waitForSession remoteHost_ vars + handleHostError rhKeyVar $ waitForHostSession remoteHost_ rhKey rhKeyVar vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} withRemoteHostSession rhKey $ \case RHSessionStarting -> Right ((), RHSessionConnecting rhs) @@ -152,18 +153,15 @@ startRemoteHost' rh_ = do unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo - cleanupOnError :: ChatMonad m => RCHostClient -> (TMVar RHKey -> m ()) -> m () - cleanupOnError rchClient action = do - currentKey <- newEmptyTMVarIO - action currentKey `catchChatError` \err -> do - logError $ "startRemoteHost'.waitForSession crashed: " <> tshow err + handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () + handleHostError rhKeyVar action = do + action `catchChatError` \err -> do + logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err sessions <- asks remoteHostSessions - atomically $ readTMVar currentKey >>= (`TM.delete` sessions) - liftIO $ cancelHostClient rchClient - waitForSession :: ChatMonad m => Maybe RemoteHostInfo -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> TMVar RHKey -> m () - waitForSession remoteHost_ vars currentKey = do - let rhKey = maybe RHNew (\RemoteHostInfo {remoteHostId} -> RHId remoteHostId) remoteHost_ - atomically $ writeTMVar currentKey rhKey + session_ <- atomically $ readTVar rhKeyVar >>= (`TM.lookupDelete` sessions) + mapM_ (liftIO . cancelRemoteHost) session_ + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession remoteHost_ rhKey rhKeyVar vars = do (sessId, vars') <- takeRCStep vars toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' @@ -175,7 +173,7 @@ startRemoteHost' rh_ = do -- update remoteHost with updated pairing rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew - atomically $ writeTMVar currentKey rhKey' + atomically $ writeTVar rhKeyVar rhKey' disconnected <- toIO $ onDisconnected remoteHostId httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo @@ -252,7 +250,7 @@ switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo switchRemoteHost rhId_ = do rhi_ <- forM rhId_ $ \rhId -> do let rhKey = RHId rhId - rhi <- withError (const $ ChatErrorRemoteHost rhKey RHEMissing) $ (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) + rhi <- (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) active <- chatReadVar remoteHostSessions case M.lookup rhKey active of Just RHSessionConnected {} -> pure rhi @@ -338,19 +336,14 @@ connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk - cleanupOnError rcsClient $ waitForSession rc_ ctrlDeviceName rcsClient vars - cleanupOnError rcsClient . updateRemoteCtrlSession $ \case + handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars + handleCtrlError "connectRemoteCtrl" . updateRemoteCtrlSession $ \case RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () where - cleanupOnError :: ChatMonad m => RCCtrlClient -> m () -> m () - cleanupOnError rcsClient action = action `catchChatError` \e -> do - logError $ "connectRemoteCtrl crashed with: " <> tshow e - chatWriteVar remoteCtrlSession Nothing -- XXX: can only wipe PendingConfirmation or RCSessionConnecting, which only have rcsClient to cancel - liftIO $ cancelCtrlClient rcsClient - waitForSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () - waitForSession rc_ ctrlName rcsClient vars = do + waitForCtrlSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () + waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- takeRCStep vars let sessionCode = verificationCode uniq toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} @@ -487,7 +480,7 @@ confirmRemoteCtrl _rcId = do -- | Take a look at emoji of tlsunique, commit pairing, and start session server verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo -verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do +verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do (client, ctrlName, sessionCode, vars) <- getRemoteCtrlSession >>= \case RCSessionPendingConfirmation {rcsClient, ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) @@ -514,16 +507,11 @@ verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do Just rc@RemoteCtrl {remoteCtrlId} -> do liftIO $ updateCtrlPairingKeys db remoteCtrlId (dhPrivKey rcCtrlPairing) pure rc - cleanupOnError :: ChatMonad m => m a -> m a - cleanupOnError action = action `catchChatError` \e -> do - logError $ "verifyRemoteCtrlSession crashed with: " <> tshow e - withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) -- cancel session threads, if any - throwError e monitor :: ChatMonad m => Async () -> m () monitor server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) -- cancel session threads, if any + cancelActiveRemoteCtrl toView CRRemoteCtrlStopped stopRemoteCtrl :: ChatMonad m => m () @@ -531,6 +519,15 @@ stopRemoteCtrl = join . withRemoteCtrlSession_ . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ \s -> Right (liftIO $ cancelRemoteCtrl s, Nothing) +handleCtrlError :: ChatMonad m => Text -> m a -> m a +handleCtrlError name action = action `catchChatError` \e -> do + logError $ name <> " remote ctrl error: " <> tshow e + cancelActiveRemoteCtrl + throwError e + +cancelActiveRemoteCtrl :: ChatMonad m => m () +cancelActiveRemoteCtrl = withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) + cancelRemoteCtrl :: RemoteCtrlSession -> IO () cancelRemoteCtrl = \case RCSessionStarting -> pure () diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 177f3400dd..f96857fd4f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1822,6 +1822,8 @@ viewChatError logLevel = \case SEChatItemNotFoundByText text -> ["message not found by text: " <> plain text] SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> viewGroupName g)] SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> viewGroupName g)] + SERemoteCtrlNotFound rcId -> ["no remote controller " <> sShow rcId] + SERemoteHostNotFound rhId -> ["no remote host " <> sShow rhId] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 9c135a81a5..4aaa3b68cb 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -343,7 +343,7 @@ switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \ desktop <## "bob (Bob)" desktop ##> "/switch remote host 123" - desktop <## "remote host 123 error: RHEMissing" + desktop <## "no remote host 123" stopDesktop mobile desktop desktop ##> "/contacts" From 8e3e58cac805a091593b26309ffc3ad9c30edddc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 12 Nov 2023 12:40:13 +0000 Subject: [PATCH 054/174] core: update remote controller name (#3352) --- src/Simplex/Chat/Remote.hs | 10 +++++----- src/Simplex/Chat/Store/Remote.hs | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 6916a54a0e..9acde94049 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -29,13 +29,12 @@ import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N -import Network.HTTP2.Client (HTTP2Error (..)) import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC import Simplex.Chat.Archive (archiveFilesFolder) @@ -504,9 +503,10 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot rc_ <- liftIO $ getRemoteCtrlByFingerprint db (ctrlFingerprint rcCtrlPairing) case rc_ of Nothing -> insertRemoteCtrl db ctrlName rcCtrlPairing >>= getRemoteCtrl db - Just rc@RemoteCtrl {remoteCtrlId} -> do - liftIO $ updateCtrlPairingKeys db remoteCtrlId (dhPrivKey rcCtrlPairing) - pure rc + Just rc@RemoteCtrl {ctrlPairing} -> do + let dhPrivKey' = dhPrivKey rcCtrlPairing + liftIO $ updateRemoteCtrl db rc ctrlName dhPrivKey' + pure rc {ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} monitor :: ChatMonad m => Async () -> m () monitor server = do res <- waitCatch server diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index e12b581252..22eda53c7a 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -130,16 +130,16 @@ toRemoteCtrl (remoteCtrlId, ctrlName, caKey, C.SignedObject caCert, ctrlFingerpr ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} } -updateCtrlPairingKeys :: DB.Connection -> RemoteCtrlId -> C.PrivateKeyX25519 -> IO () -updateCtrlPairingKeys db rcId dhPrivKey = +updateRemoteCtrl :: DB.Connection -> RemoteCtrl -> Text -> C.PrivateKeyX25519 -> IO () +updateRemoteCtrl db RemoteCtrl {remoteCtrlId} ctrlDeviceName dhPrivKey = DB.execute db [sql| UPDATE remote_controllers - SET dh_priv_key = ?, prev_dh_priv_key = dh_priv_key + SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key WHERE remote_ctrl_id = ? |] - (dhPrivKey, rcId) + (ctrlDeviceName, dhPrivKey, remoteCtrlId) deleteRemoteCtrlRecord :: DB.Connection -> RemoteCtrlId -> IO () deleteRemoteCtrlRecord db remoteCtrlId = From 5beeff5cb6dbbc93aebe42a37df090e91ae50da8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sun, 12 Nov 2023 16:41:41 +0400 Subject: [PATCH 055/174] core: take chat lock when synchronizing ratchet (#3349) --- src/Simplex/Chat.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5e3d2f0dad..aaa23f77ff 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1256,7 +1256,7 @@ processChatCommand = \case connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive - APISyncContactRatchet contactId force -> withUser $ \user -> do + APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do ct <- withStore $ \db -> getContact db user contactId case contactConnId ct of Just connId -> do @@ -1264,7 +1264,7 @@ processChatCommand = \case createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct - APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> do + APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withChatLock "syncGroupMemberRatchet" $ do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case memberConnId m of Just connId -> do From 92e3f576ca4ba7bfa98f2a0e19d882202dd66483 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 12 Nov 2023 14:40:49 +0000 Subject: [PATCH 056/174] core: return controller app info in response when connecting, validate ID key (#3353) --- src/Simplex/Chat.hs | 5 +++- src/Simplex/Chat/Controller.hs | 8 ++---- src/Simplex/Chat/Remote.hs | 37 +++++++++++++++------------ src/Simplex/Chat/Remote/AppVersion.hs | 9 ++++++- src/Simplex/Chat/Remote/Types.hs | 9 ++++--- src/Simplex/Chat/Store/Remote.hs | 17 +++++------- src/Simplex/Chat/View.hs | 37 ++++++++++++++++----------- tests/RemoteTests.hs | 4 +-- 8 files changed, 70 insertions(+), 56 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 88cb8dd25c..291ca8be36 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1961,7 +1961,10 @@ processChatCommand = \case DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ - ConnectRemoteCtrl oob -> withUser_ $ connectRemoteCtrl oob >> ok_ + ConnectRemoteCtrl inv -> withUser_ $ do + (rc_, ctrlAppInfo) <- connectRemoteCtrl inv + let remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_ + pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ VerifyRemoteCtrlSession sessId -> withUser_ $ CRRemoteCtrlConnected <$> verifyRemoteCtrlSession (execChatCommand Nothing) sessId diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a9950372bc..b4f69d9082 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -651,10 +651,8 @@ data ChatResponse | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} -- TODO remove - | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- TODO remove, unregistered fingerprint, needs confirmation -- TODO is it needed? | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} -- TODO is remove + | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} -- TODO is remove | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped @@ -682,8 +680,6 @@ allowRemoteEvent = \case CRRemoteHostConnected {} -> False CRRemoteHostStopped {} -> False CRRemoteCtrlList {} -> False - CRRemoteCtrlRegistered {} -> False - CRRemoteCtrlAnnounce {} -> False CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False @@ -1086,7 +1082,7 @@ data RemoteCtrlSession rcsWaitSession :: Async () } | RCSessionPendingConfirmation - { ctrlName :: Text, + { ctrlDeviceName :: Text, rcsClient :: RCCtrlClient, tls :: TLS, sessionCode :: Text, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 9acde94049..0ba5f5feda 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [5, 4, 0, 2] +minRemoteCtrlVersion = AppVersion [5, 4, 0, 3] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [5, 4, 0, 2] +minRemoteHostVersion = AppVersion [5, 4, 0, 3] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -256,10 +256,9 @@ switchRemoteHost rhId_ = do _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ --- XXX: replacing hostPairing replaced with sessionActive, could be a ($>) remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, hostName} sessionActive = - RemoteHostInfo {remoteHostId, storePath, hostName, sessionActive} +remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionActive = + RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionActive} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do @@ -325,37 +324,41 @@ findKnownRemoteCtrl :: ChatMonad m => m () findKnownRemoteCtrl = undefined -- do -- | Use provided OOB link as an annouce -connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m () -connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = do - (ctrlDeviceName, v) <- parseCtrlAppInfo app +connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrl, CtrlAppInfo) +connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {ca, app}} = handleCtrlError "connectRemoteCtrl" $ do + (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca + mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v - (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a inv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars - handleCtrlError "connectRemoteCtrl" . updateRemoteCtrlSession $ \case + updateRemoteCtrlSession $ \case RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () + pure (rc_, ctrlInfo) where + validateRemoteCtrl RCInvitation {idkey} RemoteCtrl {ctrlPairing = RCCtrlPairing {idPubKey}} = + unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity waitForCtrlSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- takeRCStep vars let sessionCode = verificationCode uniq toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case - RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} + RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState parseCtrlAppInfo ctrlAppInfo = do - CtrlAppInfo {deviceName, appVersionRange} <- + ctrlInfo@CtrlAppInfo {appVersionRange} <- liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo v <- case compatibleAppVersion hostAppVersionRange appVersionRange of Just (AppCompatible v) -> pure v Nothing -> throwError $ ChatErrorRemoteCtrl $ RCEBadVersion $ maxVersion appVersionRange - pure (deviceName, v) + pure (ctrlInfo, v) getHostAppInfo appVersion = do hostDeviceName <- chatReadVar localDeviceName encryptFiles <- chatReadVar encryptLocalFiles @@ -465,8 +468,8 @@ listRemoteCtrls = do remoteCtrlInfo rc $ activeRcId == Just remoteCtrlId remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo -remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlName} sessionActive = - RemoteCtrlInfo {remoteCtrlId, ctrlName, sessionActive} +remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionActive = + RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} -- XXX: only used for multicast confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () @@ -482,7 +485,7 @@ verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do (client, ctrlName, sessionCode, vars) <- getRemoteCtrlSession >>= \case - RCSessionPendingConfirmation {rcsClient, ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) + RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState let verified = sameVerificationCode sessCode' sessionCode liftIO $ confirmCtrlSession client verified @@ -506,7 +509,7 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot Just rc@RemoteCtrl {ctrlPairing} -> do let dhPrivKey' = dhPrivKey rcCtrlPairing liftIO $ updateRemoteCtrl db rc ctrlName dhPrivKey' - pure rc {ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} + pure rc {ctrlDeviceName = ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} monitor :: ChatMonad m => Async () -> m () monitor server = do res <- waitCatch server diff --git a/src/Simplex/Chat/Remote/AppVersion.hs b/src/Simplex/Chat/Remote/AppVersion.hs index a8943968d5..e39a64b0a3 100644 --- a/src/Simplex/Chat/Remote/AppVersion.hs +++ b/src/Simplex/Chat/Remote/AppVersion.hs @@ -4,6 +4,7 @@ module Simplex.Chat.Remote.AppVersion ( AppVersionRange (minVersion, maxVersion), + pattern AppVersionRange, AppVersion (..), pattern AppCompatible, mkAppVersionRange, @@ -22,7 +23,7 @@ import qualified Data.Version as V import Simplex.Messaging.Parsers (defaultJSON) import Text.ParserCombinators.ReadP (readP_to_S) -newtype AppVersion = AppVersion V.Version +newtype AppVersion = AppVersion {appVersion :: V.Version} deriving (Eq, Ord, Show) instance ToJSON AppVersion where @@ -40,6 +41,12 @@ data AppVersionRange = AppVRange { minVersion :: AppVersion, maxVersion :: AppVersion } + deriving (Show) + +pattern AppVersionRange :: AppVersion -> AppVersion -> AppVersionRange +pattern AppVersionRange v1 v2 <- AppVRange v1 v2 + +{-# COMPLETE AppVersionRange #-} mkAppVersionRange :: AppVersion -> AppVersion -> AppVersionRange mkAppVersionRange v1 v2 diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 3177ae3ef6..419339e41e 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -96,7 +96,7 @@ data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} -- | Storable/internal remote host data data RemoteHost = RemoteHost { remoteHostId :: RemoteHostId, - hostName :: Text, + hostDeviceName :: Text, storePath :: FilePath, hostPairing :: RCHostPairing } @@ -104,7 +104,7 @@ data RemoteHost = RemoteHost -- | UI-accessible remote host information data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, - hostName :: Text, + hostDeviceName :: Text, storePath :: FilePath, sessionActive :: Bool } @@ -115,14 +115,14 @@ type RemoteCtrlId = Int64 -- | Storable/internal remote controller data data RemoteCtrl = RemoteCtrl { remoteCtrlId :: RemoteCtrlId, - ctrlName :: Text, + ctrlDeviceName :: Text, ctrlPairing :: RCCtrlPairing } -- | UI-accessible remote controller information data RemoteCtrlInfo = RemoteCtrlInfo { remoteCtrlId :: RemoteCtrlId, - ctrlName :: Text, + ctrlDeviceName :: Text, sessionActive :: Bool } deriving (Show) @@ -151,6 +151,7 @@ data CtrlAppInfo = CtrlAppInfo { appVersionRange :: AppVersionRange, deviceName :: Text } + deriving (Show) data HostAppInfo = HostAppInfo { appVersion :: AppVersion, diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 22eda53c7a..ec84860379 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -57,14 +57,14 @@ remoteHostQuery = |] toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519) -> RemoteHost -toRemoteHost (remoteHostId, hostName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) = - RemoteHost {remoteHostId, hostName, storePath, hostPairing} +toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) = + RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing} where hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost} knownHost = KnownHostPairing {hostFingerprint, hostDhPubKey} updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> IO () -updateHostPairing db rhId hostName hostDhPubKey = +updateHostPairing db rhId hostDeviceName hostDhPubKey = DB.execute db [sql| @@ -72,7 +72,7 @@ updateHostPairing db rhId hostName hostDhPubKey = SET host_device_name = ?, host_dh_pub = ? WHERE remote_host_id = ? |] - (hostName, hostDhPubKey, rhId) + (hostDeviceName, hostDhPubKey, rhId) deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) @@ -123,12 +123,9 @@ toRemoteCtrl :: Maybe C.PrivateKeyX25519 ) -> RemoteCtrl -toRemoteCtrl (remoteCtrlId, ctrlName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) = - RemoteCtrl - { remoteCtrlId, - ctrlName, - ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} - } +toRemoteCtrl (remoteCtrlId, ctrlDeviceName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) = + let ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} + in RemoteCtrl {remoteCtrlId, ctrlDeviceName, ctrlPairing} updateRemoteCtrl :: DB.Connection -> RemoteCtrl -> Text -> C.PrivateKeyX25519 -> IO () updateRemoteCtrl db RemoteCtrl {remoteCtrlId} ctrlDeviceName dhPrivKey = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f96857fd4f..d1871deb73 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -5,6 +5,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} @@ -32,6 +33,7 @@ import Data.Time (LocalTime (..), TimeOfDay (..), TimeZone (..), utcToLocalTime) import Data.Time.Calendar (addDays) import Data.Time.Clock (UTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) +import qualified Data.Version as V import qualified Network.HTTP.Types as Q import Numeric (showFFloat) import Simplex.Chat (defaultChatConfig, maxImageSize) @@ -43,6 +45,7 @@ import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types +import Simplex.Chat.Remote.AppVersion (pattern AppVersionRange, AppVersion (..)) import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types @@ -279,7 +282,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" - (\RemoteHostInfo {remoteHostId = rhId, hostName} -> "Using remote host " <> sShow rhId <> " (" <> plain hostName <> ")") + (\RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> "Using remote host " <> sShow rhId <> " (" <> plain hostDeviceName <> ")") rhi_ ] CRRemoteHostList hs -> viewRemoteHosts hs @@ -299,21 +302,25 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> - ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlAnnounce fingerprint -> - ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, ctrlName} -> - ["remote controller " <> sShow rcId <> " connecting to " <> plain ctrlName] + CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo = CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (AppVersion ctrlVersion)}, appVersion = AppVersion v} -> + [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") + <> (if T.null deviceName then "" else plain deviceName <> ", ") + <> ("v" <> plain (V.showVersion ctrlVersion) <> ctrlVersionInfo) + ] + where + ctrlVersionInfo + | ctrlVersion < v = " (older than this app - upgrade controller)" + | ctrlVersion > v = " (newer than this app - upgrade it)" + | otherwise = "" CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, "Compare session code with controller and use:", "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId ] - CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlName} -> - ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlName] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> + ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> @@ -1697,21 +1704,21 @@ viewRemoteHosts = \case [] -> ["No remote hosts"] hs -> "Remote hosts: " : map viewRemoteHostInfo hs where - viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostName, sessionActive} = - plain $ tshow remoteHostId <> ". " <> hostName <> if sessionActive then " (active)" else "" + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionActive} = + plain $ tshow remoteHostId <> ". " <> hostDeviceName <> if sessionActive then " (active)" else "" viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] viewRemoteCtrls = \case [] -> ["No remote controllers"] hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs where - viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlName, sessionActive} = - plain $ tshow remoteCtrlId <> ". " <> ctrlName <> if sessionActive then " (active)" else "" + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (active)" else "" -- TODO fingerprint, accepted? viewRemoteCtrl :: RemoteCtrlInfo -> StyledString -viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlName} = - plain $ tshow remoteCtrlId <> ". " <> ctrlName +viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 4aaa3b68cb..35f7d15b25 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -388,7 +388,7 @@ startRemote mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "ok" + mobile <## "connecting new remote controller: My desktop, v5.4.0.3" desktop <## "new remote host connecting" desktop <## "Compare session code with host:" sessId <- getTermLine desktop @@ -406,7 +406,7 @@ startRemoteStored mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "ok" + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" desktop <## "remote host 1 connecting" desktop <## "Compare session code with host:" sessId <- getTermLine desktop From 72b25385ba389fceddf1e7f3e1d3836ffe49471e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 12 Nov 2023 21:43:43 +0000 Subject: [PATCH 057/174] core: event when new remote host added (#3355) --- src/Simplex/Chat/Controller.hs | 6 ++---- src/Simplex/Chat/Remote.hs | 6 ++++-- src/Simplex/Chat/View.hs | 2 +- tests/RemoteTests.hs | 1 + 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b4f69d9082..84bbe37337 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -422,7 +422,6 @@ data ChatCommand | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) | SetLocalDeviceName Text - -- | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host @@ -642,17 +641,17 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} - | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} + | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} -- TODO is remove + | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped @@ -675,7 +674,6 @@ data ChatResponse allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case - CRRemoteHostCreated {} -> False CRRemoteHostList {} -> False CRRemoteHostConnected {} -> False CRRemoteHostStopped {} -> False diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 0ba5f5feda..57dcd33e43 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -172,7 +172,9 @@ startRemoteHost rh_ = do -- update remoteHost with updated pairing rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew - atomically $ writeTVar rhKeyVar rhKey' + when (rhKey' /= rhKey) $ do + atomically $ writeTVar rhKeyVar rhKey' + toView $ CRNewRemoteHost rhi disconnected <- toIO $ onDisconnected remoteHostId httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo @@ -193,7 +195,7 @@ startRemoteHost rh_ = do pure $ remoteHostInfo rh True Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' - pure rhi + pure (rhi :: RemoteHostInfo) {sessionActive = True} onDisconnected :: ChatMonad m => RemoteHostId -> m () onDisconnected remoteHostId = do logDebug "HTTP2 client disconnected" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d1871deb73..f3011e4102 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -278,7 +278,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] - CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" @@ -296,6 +295,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe "Compare session code with host:", plain sessionCode ] + CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 35f7d15b25..6647971122 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -397,6 +397,7 @@ startRemote mobile desktop = do mobile <## ("/verify remote ctrl " <> sessId) mobile ##> ("/verify remote ctrl " <> sessId) mobile <## "remote controller 1 session started with My desktop" + desktop <## "new remote host 1 added: Mobile" desktop <## "remote host 1 connected" startRemoteStored :: TestCC -> TestCC -> IO () From 338417d963fbd8072cdc3e2432b3a01b0681b959 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 13 Nov 2023 22:06:01 +0800 Subject: [PATCH 058/174] desktop: catch unreadable crypto file (#3359) --- .../simplex/common/platform/Share.android.kt | 17 ++++++++++++++--- .../common/views/chat/item/CIFileView.kt | 8 +++++++- .../common/platform/RecAndPlay.desktop.kt | 1 + .../simplex/common/platform/Share.desktop.kt | 6 +++++- .../views/chat/item/ChatItemView.desktop.kt | 7 ++++++- .../common/views/helpers/Utils.desktop.kt | 11 ++++++++--- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index cf3fcbaae7..eb6ed0bbf8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -35,7 +35,12 @@ actual fun shareFile(text: String, fileSource: CryptoFile) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() ChatModel.filesToDelete.add(tmpFile) - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + return + } getAppFileUri(tmpFile.absolutePath) } else { getAppFileUri(fileSource.filePath) @@ -96,15 +101,21 @@ fun saveImage(ciFile: CIFile?) { val outputStream = BufferedOutputStream(stream) if (ciFile.fileSource?.cryptoArgs != null) { createTmpFileAndDelete { tmpFile -> - decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + return@createTmpFileAndDelete + } tmpFile.inputStream().use { it.copyTo(outputStream) } + showToast(generalGetString(MR.strings.image_saved)) } outputStream.close() } else { File(filePath).inputStream().use { it.copyTo(outputStream) } outputStream.close() + showToast(generalGetString(MR.strings.image_saved)) } - showToast(generalGetString(MR.strings.image_saved)) } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 87f4aa4f31..57dcd16cb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -214,7 +214,13 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = if (filePath != null && to != null) { if (ciFile?.fileSource?.cryptoArgs != null) { createTmpFileAndDelete { tmpFile -> - decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + tmpFile.delete() + return@createTmpFileAndDelete + } copyFileToFile(tmpFile, to) {} tmpFile.delete() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 25fc9ec8d5..83351d7729 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -59,6 +59,7 @@ actual object AudioPlayer: AudioPlayerInterface { } }.onFailure { Log.e(TAG, it.stackTraceToString()) + fileSource.deleteTmpFile() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message) return null } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index 1d5ab45bbb..a91bc5a761 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -27,7 +27,11 @@ actual fun shareFile(text: String, fileSource: CryptoFile) { FileChooserLauncher(false) { to: URI? -> if (to != null) { if (fileSource.cryptoArgs != null) { - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path) + try { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + } } else { copyFileToFile(File(fileSource.filePath), to) {} } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 9df5bd0a12..f602dd577c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -48,7 +48,12 @@ actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { val filePath: String = if (fileSource.cryptoArgs != null) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + return + } tmpFile.absolutePath } else { getAppFilePath(fileSource.filePath) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index e867dd1b34..7478e22a43 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -94,9 +94,14 @@ actual fun getAppFileUri(fileName: String): URI = actual fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) return if (filePath != null) { - val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() - val bitmap = getBitmapFromByteArray(data, false) - if (bitmap != null) bitmap to data else null + try { + val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() + val bitmap = getBitmapFromByteArray(data, false) + if (bitmap != null) bitmap to data else null + } catch (e: Exception) { + Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString()) + null + } } else { null } From a2fe5cfb66db24eb2166f480e5dc1cef1bdc1c6c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 13 Nov 2023 17:45:10 +0000 Subject: [PATCH 059/174] core: fix incorrect JSON serialization (#3361) --- src/Simplex/Chat/Messages/CIContent.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 639093d01b..d0be9e2d8c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -555,7 +555,7 @@ jsonCIContent = \case CIRcvChatFeatureRejected feature -> JCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> JCIRcvGroupFeatureRejected {groupFeature} CISndModerated -> JCISndModerated - CIRcvModerated -> JCISndModerated + CIRcvModerated -> JCIRcvModerated CIInvalidJSON json -> JCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentJSON :: JSONCIContent -> ACIContent From 598b6659ccfe410f1264eb65876b4dfbc1026a3f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 13 Nov 2023 20:39:41 +0200 Subject: [PATCH 060/174] core: better handling of remote errors (#3358) * Allow ExitCode exceptions to do their job * Use appropriate error type * Close TLS server when cancelling connected remote host * Add timeout errors * Bump simplexmq * extract common timeout value --- cabal.project | 2 +- src/Simplex/Chat.hs | 9 +++++++-- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/Remote.hs | 26 +++++++++++++++++--------- src/Simplex/Chat/Remote/Types.hs | 8 +++++++- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/cabal.project b/cabal.project index 5730bfb7b9..c9273ea950 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c051ebab74632e0eb60686329ab3fad521736f79 + tag: 4f5d52ada47a15532766b2ff3d3781be629648d8 source-repository-package type: git diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 60e861a484..8b9abfde5f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -103,7 +103,7 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version -import System.Exit (exitFailure, exitSuccess) +import System.Exit (ExitCode, exitFailure, exitSuccess) import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) import System.Random (randomRIO) @@ -411,7 +411,12 @@ execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ChatCommand - execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse -handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError)) +handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catches` ioErrors) + where + ioErrors = + [ E.Handler $ \(e :: ExitCode) -> E.throwIO e, + E.Handler $ pure . Left . mkChatError + ] parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 84bbe37337..622ce7b706 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1048,6 +1048,7 @@ data RemoteHostError = RHEMissing -- ^ No remote session matches this identifier | RHEInactive -- ^ A session exists, but not active | RHEBusy -- ^ A session is already running + | RHETimeout | RHEBadState -- ^ Illegal state transition | RHEBadVersion {appVersion :: AppVersion} | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected? @@ -1059,10 +1060,10 @@ data RemoteCtrlError = RCEInactive -- ^ No session is running | RCEBadState -- ^ A session is in a wrong state for the current operation | RCEBusy -- ^ A session is already running + | RCETimeout | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller | RCEBadInvitation | RCEBadVersion {appVersion :: AppVersion} - | RCEBadVerificationCode -- ^ The code submitted doesn't match session TLSunique | RCEHTTP2Error {http2Error :: Text} -- TODO currently not used | RCEProtocolError {protocolError :: RemoteProtocolError} deriving (Show, Exception) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 57dcd33e43..e819f02240 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -90,6 +90,9 @@ ctrlAppVersionRange = mkAppVersionRange minRemoteHostVersion currentAppVersion hostAppVersionRange :: AppVersionRange hostAppVersionRange = mkAppVersionRange minRemoteCtrlVersion currentAppVersion +networkIOTimeout :: Int +networkIOTimeout = 15000000 + -- * Desktop side getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient @@ -161,9 +164,9 @@ startRemoteHost rh_ = do mapM_ (liftIO . cancelRemoteHost) session_ waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do - (sessId, vars') <- takeRCStep vars + (sessId, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm - (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' + (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case @@ -180,7 +183,7 @@ startRemoteHost rh_ = do rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient withRemoteHostSession rhKey' $ \case - RHSessionConfirmed _ RHPendingSession {} -> Right ((), RHSessionConnected {tls, rhClient, pollAction, storePath}) + RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi @@ -216,7 +219,7 @@ closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH - join . withRemoteHostSession_ rhKey . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ + join . withRemoteHostSession_ rhKey . maybe (Left $ ChatErrorRemoteHost rhKey RHEInactive) $ \s -> Right (liftIO $ cancelRemoteHost s, Nothing) cancelRemoteHost :: RemoteHostSession -> IO () @@ -226,10 +229,11 @@ cancelRemoteHost = \case RHSessionConfirmed tls rhs -> do cancelPendingSession rhs closeConnection tls - RHSessionConnected {tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do + RHSessionConnected {rchClient, tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do uninterruptibleCancel pollAction closeHTTP2Client httpClient closeConnection tls + cancelHostClient rchClient where cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do uninterruptibleCancel rhsWaitSession @@ -333,7 +337,8 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v - (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + (rcsClient, vars) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . withAgent $ \a -> + rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk @@ -348,7 +353,7 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity waitForCtrlSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () waitForCtrlSession rc_ ctrlName rcsClient vars = do - (uniq, tls, rcsWaitConfirmation) <- takeRCStep vars + (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case @@ -397,6 +402,9 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque attach send flush +timeoutThrow :: (MonadUnliftIO m, MonadError e m) => e -> Int -> m a -> m a +timeoutThrow e ms action = timeout ms action >>= maybe (throwError e) pure + takeRCStep :: ChatMonad m => RCStepTMVar a -> m a takeRCStep = liftEitherError (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar @@ -490,9 +498,9 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState let verified = sameVerificationCode sessCode' sessionCode - liftIO $ confirmCtrlSession client verified + timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode - (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- takeRCStep vars + (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 419339e41e..17ea8e1599 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -65,7 +65,13 @@ data RemoteHostSession = RHSessionStarting | RHSessionConnecting {rhPendingSession :: RHPendingSession} | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} - | RHSessionConnected {tls :: TLS, rhClient :: RemoteHostClient, pollAction :: Async (), storePath :: FilePath} + | RHSessionConnected + { rchClient :: RCHostClient, + tls :: TLS, + rhClient :: RemoteHostClient, + pollAction :: Async (), + storePath :: FilePath + } data RemoteProtocolError = -- | size prefix is malformed From c91625b32a412514bc48a00dcffd44f8336f2b97 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 13 Nov 2023 20:16:34 +0000 Subject: [PATCH 061/174] core: update remote host session state, terminate TLS in one more case (#3364) * core: update remote host session state, terminate TLS in one more case * name --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Remote.hs | 57 ++++++++++++++++++-------------- src/Simplex/Chat/Remote/Types.hs | 24 ++++++++++++-- src/Simplex/Chat/View.hs | 12 +++++-- stack.yaml | 2 +- tests/RemoteTests.hs | 4 +-- 7 files changed, 69 insertions(+), 34 deletions(-) diff --git a/cabal.project b/cabal.project index c9273ea950..f7102312ce 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 4f5d52ada47a15532766b2ff3d3781be629648d8 + tag: e0b7942e45e36d92625e07c0c1ce9ca2375a0980 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index edc6f2fd23..d7870a87cb 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c051ebab74632e0eb60686329ab3fad521736f79" = "1j7z3v3vk02nq4sw46flky1l4pjxfiypbwh5s77m6f81rc0vsjvi"; + "https://github.com/simplex-chat/simplexmq.git"."e0b7942e45e36d92625e07c0c1ce9ca2375a0980" = "0swbcrmdirwqrk0kx5jmc5lcrzasccfwn3papb5c1p8hn0hjnzj7"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index e819f02240..bb96107127 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -32,7 +32,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) @@ -129,7 +129,7 @@ startRemoteHost rh_ = do (rhKey, multicast, remoteHost_, pairing) <- case rh_ of Just (rhId, multicast) -> do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId - pure (RHId rhId, multicast, Just $ remoteHostInfo rh True, hostPairing) -- get from the database, start multicast if requested + pure (RHId rhId, multicast, Just $ remoteHostInfo rh $ Just RHSStarting, hostPairing) -- get from the database, start multicast if requested Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) ctrlAppInfo <- mkCtrlAppInfo @@ -141,7 +141,9 @@ startRemoteHost rh_ = do handleHostError rhKeyVar $ waitForHostSession remoteHost_ rhKey rhKeyVar vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} withRemoteHostSession rhKey $ \case - RHSessionStarting -> Right ((), RHSessionConnecting rhs) + RHSessionStarting -> + let inv = decodeLatin1 $ strEncode invitation + in Right ((), RHSessionConnecting inv rhs) _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState (remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) where @@ -162,18 +164,22 @@ startRemoteHost rh_ = do sessions <- asks remoteHostSessions session_ <- atomically $ readTVar rhKeyVar >>= (`TM.lookupDelete` sessions) mapM_ (liftIO . cancelRemoteHost) session_ - waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do - (sessId, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite + (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite + let sessCode = verificationCode sessId + withRemoteHostSession rhKey $ \case + RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessCode tls rhs') -- TODO check it's the same session? + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm - (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code + (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case - RHSessionConnecting rhs' -> Right ((), RHSessionConfirmed tls rhs') -- TODO check it's the same session? + RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName RHSConfirmed let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -187,18 +193,18 @@ startRemoteHost rh_ = do _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi - upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> m RemoteHostInfo - upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName = do + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ case rhi_ of Nothing -> do storePath <- liftIO randomStorePath rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db setNewRemoteHostId RHNew remoteHostId - pure $ remoteHostInfo rh True + pure $ remoteHostInfo rh $ Just state Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' - pure (rhi :: RemoteHostInfo) {sessionActive = True} + pure (rhi :: RemoteHostInfo) {sessionState = Just state} onDisconnected :: ChatMonad m => RemoteHostId -> m () onDisconnected remoteHostId = do logDebug "HTTP2 client disconnected" @@ -225,7 +231,10 @@ closeRemoteHost rhKey = do cancelRemoteHost :: RemoteHostSession -> IO () cancelRemoteHost = \case RHSessionStarting -> pure () - RHSessionConnecting rhs -> cancelPendingSession rhs + RHSessionConnecting _inv rhs -> cancelPendingSession rhs + RHSessionPendingConfirmation _sessCode tls rhs -> do + cancelPendingSession rhs + closeConnection tls RHSessionConfirmed tls rhs -> do cancelPendingSession rhs closeConnection tls @@ -245,26 +254,26 @@ randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 listRemoteHosts :: ChatMonad m => m [RemoteHostInfo] listRemoteHosts = do - active <- chatReadVar remoteHostSessions - map (rhInfo active) <$> withStore' getRemoteHosts + sessions <- chatReadVar remoteHostSessions + map (rhInfo sessions) <$> withStore' getRemoteHosts where - rhInfo active rh@RemoteHost {remoteHostId} = - remoteHostInfo rh (M.member (RHId remoteHostId) active) + rhInfo sessions rh@RemoteHost {remoteHostId} = + remoteHostInfo rh (rhsSessionState <$> M.lookup (RHId remoteHostId) sessions) switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo) switchRemoteHost rhId_ = do rhi_ <- forM rhId_ $ \rhId -> do let rhKey = RHId rhId - rhi <- (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) - active <- chatReadVar remoteHostSessions - case M.lookup rhKey active of - Just RHSessionConnected {} -> pure rhi + rh <- withStore (`getRemoteHost` rhId) + sessions <- chatReadVar remoteHostSessions + case M.lookup rhKey sessions of + Just RHSessionConnected {} -> pure $ remoteHostInfo rh $ Just RHSConnected _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ -remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionActive = - RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionActive} +remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo +remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState = + RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionState} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 17ea8e1599..ce28040481 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -3,6 +3,7 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} @@ -63,7 +64,8 @@ data RHPendingSession = RHPendingSession data RemoteHostSession = RHSessionStarting - | RHSessionConnecting {rhPendingSession :: RHPendingSession} + | RHSessionConnecting {invitation :: Text, rhPendingSession :: RHPendingSession} + | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionConnected { rchClient :: RCHostClient, @@ -73,6 +75,22 @@ data RemoteHostSession storePath :: FilePath } +data RemoteHostSessionState + = RHSStarting + | RHSConnecting {invitation :: Text} + | RHSPendingConfirmation {sessionCode :: Text} + | RHSConfirmed + | RHSConnected + deriving (Show) + +rhsSessionState :: RemoteHostSession -> RemoteHostSessionState +rhsSessionState = \case + RHSessionStarting -> RHSStarting + RHSessionConnecting {invitation} -> RHSConnecting {invitation} + RHSessionPendingConfirmation {sessionCode} -> RHSPendingConfirmation {sessionCode} + RHSessionConfirmed {} -> RHSConfirmed + RHSessionConnected {} -> RHSConnected + data RemoteProtocolError = -- | size prefix is malformed RPEInvalidSize @@ -112,7 +130,7 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, hostDeviceName :: Text, storePath :: FilePath, - sessionActive :: Bool + sessionState :: Maybe RemoteHostSessionState } deriving (Show) @@ -174,6 +192,8 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RHKey) $(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RHS") ''RemoteHostSessionState) + $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f3011e4102..544614e23b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1704,8 +1704,14 @@ viewRemoteHosts = \case [] -> ["No remote hosts"] hs -> "Remote hosts: " : map viewRemoteHostInfo hs where - viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionActive} = - plain $ tshow remoteHostId <> ". " <> hostDeviceName <> if sessionActive then " (active)" else "" + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState} = + plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState + viewSessionState = \case + RHSStarting -> " (starting)" + RHSConnecting _ -> " (connecting)" + RHSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" + RHSConfirmed -> " (confirmed)" + RHSConnected -> " (connected)" viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] viewRemoteCtrls = \case @@ -1713,7 +1719,7 @@ viewRemoteCtrls = \case hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs where viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} = - plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (active)" else "" + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (connected)" else "" -- TODO fingerprint, accepted? viewRemoteCtrl :: RemoteCtrlInfo -> StyledString diff --git a/stack.yaml b/stack.yaml index 4fc46bf2b4..befe0b60b5 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: c051ebab74632e0eb60686329ab3fad521736f79 + commit: e0b7942e45e36d92625e07c0c1ce9ca2375a0980 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 6647971122..e3bef7f9e7 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -57,11 +57,11 @@ remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \m desktop ##> "/list remote hosts" desktop <## "Remote hosts:" - desktop <## "1. Mobile (active)" + desktop <## "1. Mobile (connected)" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" - mobile <## "1. My desktop (active)" + mobile <## "1. My desktop (connected)" if viaDesktop then stopDesktop mobile desktop else stopMobile mobile desktop From 1e8ae6d86114a86932e5e21d26b71d51725fc235 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 09:37:24 +0000 Subject: [PATCH 062/174] docs: update windows app link --- docs/DOWNLOADS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 94b8d9197e..a1576c355e 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -25,7 +25,7 @@ Using the same profile as on mobile device is not yet supported – you need to **Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.3/simplex-desktop-windows-x86-64.msi) (BETA). ## Mobile apps From 5bbde22ffa02f0bf74187a103856d30c69dea837 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 14 Nov 2023 18:23:05 +0400 Subject: [PATCH 063/174] core: new message decryption error - ratchet synchronization (#3367) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 2 ++ src/Simplex/Chat/Messages/CIContent.hs | 8 +++++++- stack.yaml | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index b074ed540a..4c11d58544 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9460551a042ce9dbd3f686576942fade823a6941 + tag: 7aae6f3cbe4aa2942371f8dd968eb439ccec3b15 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 5ddf00d849..d7940a95c8 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9460551a042ce9dbd3f686576942fade823a6941" = "1j5s7h55j6dpmiajdh380mma1jkffbn88qyqfgjn5nx6il2svkmz"; + "https://github.com/simplex-chat/simplexmq.git"."7aae6f3cbe4aa2942371f8dd968eb439ccec3b15" = "0qx7l1jq0ll1448s077v37s0rv7vlbrdbdgf46x734cl9g0gndk3"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aaa23f77ff..12936b325a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3635,6 +3635,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do RATCHET_HEADER -> (MDERatchetHeader, 1) RATCHET_EARLIER _ -> (MDERatchetEarlier, 1) RATCHET_SKIPPED n -> (MDETooManySkipped, n) + RATCHET_SYNC -> (MDERatchetSync, 0) mdeUpdatedCI :: (MsgDecryptError, Word32) -> CChatItem c -> Maybe (ChatItem c 'MDRcv, CIContent 'MDRcv) mdeUpdatedCI (mde', n') (CChatItem _ ci@ChatItem {content = CIRcvDecryptionError mde n}) @@ -3643,6 +3644,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MDETooManySkipped -> r n' -- the numbers are not added as sequential MDETooManySkipped will have it incremented by 1 MDERatchetEarlier -> r (n + n') MDEOther -> r (n + n') + MDERatchetSync -> r 0 | otherwise = Nothing where r n'' = Just (ci, CIRcvDecryptionError mde n'') diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index d0be9e2d8c..ea5c7dfe00 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -150,7 +150,12 @@ ciMsgContent = \case CIRcvMsgContent mc -> Just mc _ -> Nothing -data MsgDecryptError = MDERatchetHeader | MDETooManySkipped | MDERatchetEarlier | MDEOther +data MsgDecryptError + = MDERatchetHeader + | MDETooManySkipped + | MDERatchetEarlier + | MDEOther + | MDERatchetSync deriving (Eq, Show, Generic) instance ToJSON MsgDecryptError where @@ -460,6 +465,7 @@ msgDecryptErrorText err n = MDETooManySkipped -> Just $ "too many skipped messages" <> counter MDERatchetEarlier -> Just $ "earlier message" <> counter MDEOther -> counter_ + MDERatchetSync -> Just "synchronization error" counter_ = if n == 1 then Nothing else Just $ tshow n <> " messages" counter = maybe "" (", " <>) counter_ diff --git a/stack.yaml b/stack.yaml index 75d59b0397..a3ff4342c5 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 9460551a042ce9dbd3f686576942fade823a6941 + commit: 7aae6f3cbe4aa2942371f8dd968eb439ccec3b15 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From 36509a6d796795cf07cccae5bfe548148abe2fa3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 14 Nov 2023 19:39:32 +0400 Subject: [PATCH 064/174] ios, android: new message decryption error - ratchet synchronization (#3368) --- .../ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift | 2 ++ apps/ios/SimpleXChat/ChatTypes.swift | 2 ++ .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 4 +++- .../simplex/common/views/chat/item/CIRcvDecryptionError.kt | 2 ++ .../common/src/commonMain/resources/MR/base/strings.xml | 2 ++ 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index d8a560640e..3ad45d6987 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -165,6 +165,8 @@ struct CIRcvDecryptionError: View { message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why case .other: message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + case .ratchetSync: + message = Text("Encryption re-negotiation failed.") } return message } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f8a6d78a53..551ed2794d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2676,6 +2676,7 @@ public enum MsgDecryptError: String, Decodable { case tooManySkipped case ratchetEarlier case other + case ratchetSync var text: String { switch self { @@ -2683,6 +2684,7 @@ public enum MsgDecryptError: String, Decodable { case .tooManySkipped: return NSLocalizedString("Permanent decryption error", comment: "message decrypt error item") case .ratchetEarlier: return NSLocalizedString("Decryption error", comment: "message decrypt error item") case .other: return NSLocalizedString("Decryption error", comment: "message decrypt error item") + case .ratchetSync: return NSLocalizedString("Encryption re-negotiation error", comment: "message decrypt error item") } } } 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 91b4a8d8f6..efd7ced3a6 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 @@ -2106,13 +2106,15 @@ enum class MsgDecryptError { @SerialName("ratchetHeader") RatchetHeader, @SerialName("tooManySkipped") TooManySkipped, @SerialName("ratchetEarlier") RatchetEarlier, - @SerialName("other") Other; + @SerialName("other") Other, + @SerialName("ratchetSync") RatchetSync; val text: String get() = when (this) { RatchetHeader -> generalGetString(MR.strings.decryption_error) TooManySkipped -> generalGetString(MR.strings.decryption_error) RatchetEarlier -> generalGetString(MR.strings.decryption_error) Other -> generalGetString(MR.strings.decryption_error) + RatchetSync -> generalGetString(MR.strings.encryption_renegotiation_error) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index ecf7f10dd9..318735d73e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -218,5 +218,7 @@ private fun alertMessage(msgDecryptError: MsgDecryptError, msgCount: UInt): Stri MsgDecryptError.Other -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.RatchetSync -> generalGetString(MR.strings.alert_text_encryption_renegotiation_failed) } } 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 170a28f3d6..d2f464297b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -45,6 +45,7 @@ invalid chat invalid data Decryption error + Encryption re-negotiation error connection %1$d @@ -866,6 +867,7 @@ %1$d messages failed to decrypt. %1$d messages skipped. It can happen when you or your connection used the old database backup. + Encryption re-negotiation failed. Please report it to the developers. From 0a4920daae4ea8f042fe227d9588d2bd22151489 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 16:44:12 +0000 Subject: [PATCH 065/174] core: encrypt stored/loaded remote files (#3366) * core: encrypt stored/loaded remote files * simplexmq * constant --- src/Simplex/Chat/Remote.hs | 20 +++++++------- src/Simplex/Chat/Remote/Protocol.hs | 18 ++++++------- src/Simplex/Chat/Remote/Transport.hs | 39 ++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index bb96107127..49c29b6733 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -62,7 +62,6 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) -import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util import Simplex.RemoteControl.Client @@ -399,8 +398,8 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque processCommand user getNext = \case RCSend {command} -> handleSend execChatCommand command >>= reply RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply - RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile fileName fileSize fileDigest getNext >>= reply - RCGetFile {file} -> handleGetFile user file replyWith + RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile encryption fileName fileSize fileDigest getNext >>= reply + RCGetFile {file} -> handleGetFile encryption user file replyWith reply :: RemoteResponse -> m () reply = (`replyWith` \_ -> pure ()) replyWith :: Respond m @@ -444,8 +443,8 @@ handleRecv time events = do -- TODO this command could remember stored files and return IDs to allow removing files that are not needed. -- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files). -handleStoreFile :: forall m. ChatMonad m => FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse -handleStoreFile fileName fileSize fileDigest getChunk = +handleStoreFile :: forall m. ChatMonad m => RemoteCrypto -> FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse +handleStoreFile encryption fileName fileSize fileDigest getChunk = either RRProtocolError RRFileStored <$> (chatReadVar filesFolder >>= storeFile) where storeFile :: Maybe FilePath -> m (Either RemoteProtocolError FilePath) @@ -455,11 +454,11 @@ handleStoreFile fileName fileSize fileDigest getChunk = storeFileTo :: FilePath -> m (Either RemoteProtocolError FilePath) storeFileTo dir = liftRC . tryRemoteError $ do filePath <- dir `uniqueCombine` fileName - receiveRemoteFile getChunk fileSize fileDigest filePath + receiveEncryptedFile encryption getChunk fileSize fileDigest filePath pure filePath -handleGetFile :: ChatMonad m => User -> RemoteFile -> Respond m -> m () -handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do +handleGetFile :: ChatMonad m => RemoteCrypto -> User -> RemoteFile -> Respond m -> m () +handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do logDebug $ "GetFile: " <> tshow filePath unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId} path <- maybe filePath ( filePath) <$> chatReadVar filesFolder @@ -469,8 +468,9 @@ handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fi liftRC (tryRemoteError $ getFileInfo path) >>= \case Left e -> reply (RRProtocolError e) $ \_ -> pure () Right (fileSize, fileDigest) -> - withFile path ReadMode $ \h -> - reply RRFile {fileSize, fileDigest} $ \send -> hSendFile h send fileSize + withFile path ReadMode $ \h -> do + encFile <- liftRC $ prepareEncryptedFile encryption (h, fileSize) + reply RRFile {fileSize, fileDigest} $ sendEncryptedFile encFile discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> m () discoverRemoteCtrls discovered = do diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index eae71d09c7..c1acee1e0f 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -47,7 +47,6 @@ import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFi import Simplex.Messaging.Transport.Buffer (getBuffered) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) -import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Util (liftEitherError, liftEitherWith, liftError, tshow) import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode) import Simplex.RemoteControl.Client (xrcpBlockSize) @@ -127,31 +126,30 @@ remoteStoreFile c localPath fileName = do r -> badResponse r remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () -remoteGetFile c destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = +remoteGetFile c@RemoteHostClient{encryption} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = sendRemoteCommand c Nothing RCGetFile {file = rf} >>= \case (getChunk, RRFile {fileSize, fileDigest}) -> do -- TODO we could optimize by checking size and hash before receiving the file let localPath = destDir takeFileName filePath - receiveRemoteFile getChunk fileSize fileDigest localPath + receiveEncryptedFile encryption getChunk fileSize fileDigest localPath (_, r) -> badResponse r --- TODO validate there is no attachment +-- TODO validate there is no attachment in response sendRemoteCommand' :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) -sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} attachment_ cmd = do - req <- httpRequest <$> encryptEncodeHTTP2Body encryption (J.encode cmd) +sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do + encFile_ <- mapM (prepareEncryptedFile encryption) file_ + req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd) HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing (header, getNext) <- parseDecryptHTTP2Body encryption response respBody rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecode header >>= JT.parseEither J.parseJSON . convertJSON hostEncoding localEncoding pure (getNext, rr) where - httpRequest cmdBld = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do + httpRequest encFile_ cmdBld = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do send cmdBld - case attachment_ of - Nothing -> pure () - Just (h, sz) -> hSendFile h send sz + forM_ encFile_ (`sendEncryptedFile` send) flush badResponse :: RemoteResponse -> ExceptT RemoteProtocolError IO a diff --git a/src/Simplex/Chat/Remote/Transport.hs b/src/Simplex/Chat/Remote/Transport.hs index bf798444c0..c5ddfbdb8f 100644 --- a/src/Simplex/Chat/Remote/Transport.hs +++ b/src/Simplex/Chat/Remote/Transport.hs @@ -1,23 +1,52 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + module Simplex.Chat.Remote.Transport where import Control.Monad import Control.Monad.Except +import Data.ByteString.Builder (Builder, byteString) import Data.ByteString (ByteString) import qualified Data.ByteString.Lazy as LB import Data.Word (Word32) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Chat.Remote.Types +import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Lazy as LC -import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile) +import Simplex.FileTransfer.Transport (ReceiveFileError (..), receiveSbFile, sendEncFile) +import Simplex.Messaging.Encoding +import Simplex.Messaging.Util (liftEitherError, liftEitherWith) +import Simplex.RemoteControl.Types (RCErrorType (..)) import UnliftIO import UnliftIO.Directory (getFileSize) -receiveRemoteFile :: (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO () -receiveRemoteFile getChunk fileSize fileDigest toPath = do - diff <- liftIO $ withFile toPath WriteMode $ \h -> hReceiveFile getChunk h fileSize - unless (diff == 0) $ throwError RPEFileSize +type EncryptedFile = ((Handle, Word32), C.CbNonce, LC.SbState) + +prepareEncryptedFile :: RemoteCrypto -> (Handle, Word32) -> ExceptT RemoteProtocolError IO EncryptedFile +prepareEncryptedFile RemoteCrypto {drg, hybridKey} f = do + nonce <- atomically $ C.pseudoRandomCbNonce drg + sbState <- liftEitherWith (const $ PRERemoteControl RCEEncrypt) $ LC.kcbInit hybridKey nonce + pure (f, nonce, sbState) + +sendEncryptedFile :: EncryptedFile -> (Builder -> IO ()) -> IO () +sendEncryptedFile ((h, sz), nonce, sbState) send = do + send $ byteString $ smpEncode ('\x01', nonce, sz + fromIntegral C.authTagSize) + sendEncFile h send sbState sz + +receiveEncryptedFile :: RemoteCrypto -> (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO () +receiveEncryptedFile RemoteCrypto {hybridKey} getChunk fileSize fileDigest toPath = do + c <- liftIO $ getChunk 1 + unless (c == "\x01") $ throwError RPENoFile + nonce <- liftEitherError RPEInvalidBody $ smpDecode <$> getChunk 24 + size <- liftEitherError RPEInvalidBody $ smpDecode <$> getChunk 4 + unless (size == fileSize + fromIntegral C.authTagSize) $ throwError RPEFileSize + sbState <- liftEitherWith (const $ PRERemoteControl RCEDecrypt) $ LC.kcbInit hybridKey nonce + liftEitherError fErr $ withFile toPath WriteMode $ \h -> receiveSbFile getChunk h sbState fileSize digest <- liftIO $ LC.sha512Hash <$> LB.readFile toPath unless (FileDigest digest == fileDigest) $ throwError RPEFileDigest + where + fErr RFESize = RPEFileSize + fErr RFECrypto = PRERemoteControl RCEDecrypt getFileInfo :: FilePath -> ExceptT RemoteProtocolError IO (Word32, FileDigest) getFileInfo filePath = do From d4ba1bbe6966edda2e170f2a585d3d1fbbea1b1c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 22:27:21 +0000 Subject: [PATCH 066/174] core: update remote host session state (#3371) --- src/Simplex/Chat/Remote.hs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 49c29b6733..32ffaee919 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -170,7 +170,9 @@ startRemoteHost rh_ = do withRemoteHostSession rhKey $ \case RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessCode tls rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm + -- display confirmation code, wait for mobile to confirm + let rh_' = (\rh -> rh {sessionState = Just $ RHSPendingConfirmation sessCode}) <$> remoteHost_ + toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode = verificationCode sessId} (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello @@ -178,7 +180,7 @@ startRemoteHost rh_ = do RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName RHSConfirmed + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -191,7 +193,7 @@ startRemoteHost rh_ = do RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host - toView $ CRRemoteHostConnected rhi + toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected} upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ From 3d617bce25dd0bf9471c13e96d3e5a8680806a27 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 22:40:15 +0000 Subject: [PATCH 067/174] core: test JSON conversion (#3370) --- src/Simplex/Chat/Controller.hs | 2 +- tests/JSONTests.hs | 35 +++++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 622ce7b706..3144c909df 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1200,7 +1200,7 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RemoteHostError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RemoteCtrlError) diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 188fe27597..a17a69fae8 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -1,10 +1,14 @@ {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TemplateHaskell #-} module JSONTests where +import Control.Monad (join) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT +import Data.ByteString.Builder (toLazyByteString) import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) @@ -15,11 +19,6 @@ import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) import Test.QuickCheck (Arbitrary (..), property) -jsonTests :: Spec -jsonTests = describe "owsf2tagged" $ do - it "should convert chat types" owsf2TaggedJSONTest - describe "SomeType" owsf2TaggedSomeTypeTests - owsf2TaggedJSONTest :: IO () owsf2TaggedJSONTest = do noActiveUserSwift `to` noActiveUserTagged @@ -50,6 +49,17 @@ data SomeType | List [Int] deriving (Eq, Show, Generic) +$(pure []) + +thToJSON :: SomeType -> J.Value +thToJSON = $(JQ.mkToJSON (singleFieldJSON_ (Just SingleFieldJSONTag) id) ''SomeType) + +thToEncoding :: SomeType -> J.Encoding +thToEncoding = $(JQ.mkToEncoding (singleFieldJSON_ (Just SingleFieldJSONTag) id) ''SomeType) + +thParseJSON :: J.Value -> JT.Parser SomeType +thParseJSON = $(JQ.mkParseJSON (taggedObjectJSON id) ''SomeType) + instance Arbitrary SomeType where arbitrary = genericArbitraryU instance ToJSON SomeType where @@ -60,6 +70,17 @@ instance FromJSON SomeType where parseJSON = J.genericParseJSON $ taggedObjectJSON id owsf2TaggedSomeTypeTests :: Spec -owsf2TaggedSomeTypeTests = - modifyMaxSuccess (const 10000) $ it "should convert to tagged" $ property $ \x -> +owsf2TaggedSomeTypeTests = modifyMaxSuccess (const 10000) $ do + it "should convert to tagged" $ property $ \x -> (JT.parseMaybe J.parseJSON . owsf2tagged . J.toJSON) x == Just (x :: SomeType) + it "should convert to tagged via encoding" $ property $ \x -> + (join . fmap (JT.parseMaybe J.parseJSON . owsf2tagged) . J.decode . J.encode) x == Just (x :: SomeType) + it "should convert to tagged via TH" $ property $ \x -> + (JT.parseMaybe thParseJSON . owsf2tagged . thToJSON) x == Just (x :: SomeType) + it "should convert to tagged via TH encoding" $ property $ \x -> + (join . fmap (JT.parseMaybe thParseJSON . owsf2tagged) . J.decode . toLazyByteString . J.fromEncoding . thToEncoding) x == Just (x :: SomeType) + +jsonTests :: Spec +jsonTests = describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests From 975f6d488ed1ce6fc8d110e621072f23fd1484b0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 15 Nov 2023 11:46:45 +0400 Subject: [PATCH 068/174] android: fix group join via invitation chat item (#3372) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 42e43f7b7a..7d83693f04 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -894,7 +894,7 @@ fun BoxWithConstraintsScope.ChatItemsList( @Composable fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) } @Composable From fa9d61caa4ec4633e843d7c0d9540818b7c38a37 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:09:52 +0200 Subject: [PATCH 069/174] remove host store in deleteRemoteHost (#3373) --- src/Simplex/Chat/Remote.hs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 32ffaee919..05183c1d8d 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -70,7 +70,7 @@ import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Concurrent (forkIO) -import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) +import UnliftIO.Directory (copyFile, createDirectoryIfMissing, removeDirectoryRecursive, renameFile) -- when acting as host minRemoteCtrlVersion :: AppVersion @@ -282,7 +282,8 @@ deleteRemoteHost rhId = do chatReadVar filesFolder >>= \case Just baseDir -> do let hostStore = baseDir storePath - logError $ "TODO: remove " <> tshow hostStore + logInfo $ "removing host store at " <> tshow hostStore + removeDirectoryRecursive $ hostStore Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) From b71daed3ec02cc139d2639fc2cf821fdad8ac588 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 15 Nov 2023 13:17:31 +0000 Subject: [PATCH 070/174] core: include session code in all session states (#3374) --- src/Simplex/Chat.hs | 3 +- src/Simplex/Chat/Controller.hs | 32 ++++++++++++++++++-- src/Simplex/Chat/Remote.hs | 52 ++++++++++++++++++-------------- src/Simplex/Chat/Remote/Types.hs | 27 ++++++++--------- src/Simplex/Chat/View.hs | 13 +++++--- 5 files changed, 82 insertions(+), 45 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 619f93b891..b6b7cca1a6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1967,8 +1967,7 @@ processChatCommand = \case StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ ConnectRemoteCtrl inv -> withUser_ $ do - (rc_, ctrlAppInfo) <- connectRemoteCtrl inv - let remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_ + (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrl inv pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3144c909df..fb03844f89 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1077,11 +1077,13 @@ data ArchiveError data RemoteCtrlSession = RCSessionStarting | RCSessionConnecting - { rcsClient :: RCCtrlClient, + { remoteCtrlId_ :: Maybe RemoteCtrlId, + rcsClient :: RCCtrlClient, rcsWaitSession :: Async () } | RCSessionPendingConfirmation - { ctrlDeviceName :: Text, + { remoteCtrlId_ :: Maybe RemoteCtrlId, + ctrlDeviceName :: Text, rcsClient :: RCCtrlClient, tls :: TLS, sessionCode :: Text, @@ -1097,6 +1099,28 @@ data RemoteCtrlSession remoteOutputQ :: TBQueue ChatResponse } +data RemoteCtrlSessionState + = RCSStarting + | RCSConnecting + | RCSPendingConfirmation {sessionCode :: Text} + | RCSConnected {sessionCode :: Text} + deriving (Show) + +rcsSessionState :: RemoteCtrlSession -> RemoteCtrlSessionState +rcsSessionState = \case + RCSessionStarting -> RCSStarting + RCSessionConnecting {} -> RCSConnecting + RCSessionPendingConfirmation {tls} -> RCSPendingConfirmation {sessionCode = tlsSessionCode tls} + RCSessionConnected {tls} -> RCSConnected {sessionCode = tlsSessionCode tls} + +-- | UI-accessible remote controller information +data RemoteCtrlInfo = RemoteCtrlInfo + { remoteCtrlId :: RemoteCtrlId, + ctrlDeviceName :: Text, + sessionState :: Maybe RemoteCtrlSessionState + } + deriving (Show) + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -1259,6 +1283,10 @@ instance ToJSON AUserProtoServers where toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) + +$(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 05183c1d8d..16044ee923 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -166,13 +166,13 @@ startRemoteHost rh_ = do waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite - let sessCode = verificationCode sessId + let sessionCode = verificationCode sessId withRemoteHostSession rhKey $ \case - RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessCode tls rhs') -- TODO check it's the same session? + RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- display confirmation code, wait for mobile to confirm - let rh_' = (\rh -> rh {sessionState = Just $ RHSPendingConfirmation sessCode}) <$> remoteHost_ - toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode = verificationCode sessId} + let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just $ RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ + toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello @@ -180,7 +180,7 @@ startRemoteHost rh_ = do RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed {sessionCode} let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -193,7 +193,7 @@ startRemoteHost rh_ = do RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host - toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected} + toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ @@ -268,7 +268,7 @@ switchRemoteHost rhId_ = do rh <- withStore (`getRemoteHost` rhId) sessions <- chatReadVar remoteHostSessions case M.lookup rhKey sessions of - Just RHSessionConnected {} -> pure $ remoteHostInfo rh $ Just RHSConnected + Just RHSessionConnected {tls} -> pure $ remoteHostInfo rh $ Just RHSConnected {sessionCode = tlsSessionCode tls} _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ @@ -341,7 +341,7 @@ findKnownRemoteCtrl :: ChatMonad m => m () findKnownRemoteCtrl = undefined -- do -- | Use provided OOB link as an annouce -connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrl, CtrlAppInfo) +connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {ca, app}} = handleCtrlError "connectRemoteCtrl" $ do (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) @@ -355,10 +355,10 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c atomically $ takeTMVar cmdOk handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars updateRemoteCtrlSession $ \case - RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} + RCSessionStarting -> Right RCSessionConnecting {remoteCtrlId_ = remoteCtrlId' <$> rc_, rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () - pure (rc_, ctrlInfo) + pure ((`remoteCtrlInfo` Just RCSConnecting) <$> rc_, ctrlInfo) where validateRemoteCtrl RCInvitation {idkey} RemoteCtrl {ctrlPairing = RCCtrlPairing {idPubKey}} = unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity @@ -366,10 +366,12 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq - toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case - RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} + RCSessionConnecting {rcsWaitSession} -> + let remoteCtrlId_ = remoteCtrlId' <$> rc_ + in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState + toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} parseCtrlAppInfo ctrlAppInfo = do ctrlInfo@CtrlAppInfo {appVersionRange} <- liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo @@ -481,17 +483,23 @@ discoverRemoteCtrls discovered = do listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do - active <- chatReadVar remoteCtrlSession >>= \case - Just RCSessionConnected {remoteCtrlId} -> pure $ Just remoteCtrlId - _ -> pure Nothing - map (rcInfo active) <$> withStore' getRemoteCtrls + session <- chatReadVar remoteCtrlSession + let rcId = sessionRcId =<< session + sessState = rcsSessionState <$> session + map (rcInfo rcId sessState) <$> withStore' getRemoteCtrls where - rcInfo activeRcId rc@RemoteCtrl {remoteCtrlId} = - remoteCtrlInfo rc $ activeRcId == Just remoteCtrlId + rcInfo :: Maybe RemoteCtrlId -> Maybe RemoteCtrlSessionState -> RemoteCtrl -> RemoteCtrlInfo + rcInfo rcId sessState rc@RemoteCtrl {remoteCtrlId} = + remoteCtrlInfo rc $ if rcId == Just remoteCtrlId then sessState else Nothing + sessionRcId = \case + RCSessionConnecting {remoteCtrlId_} -> remoteCtrlId_ + RCSessionPendingConfirmation {remoteCtrlId_} -> remoteCtrlId_ + RCSessionConnected {remoteCtrlId} -> Just remoteCtrlId + _ -> Nothing -remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo -remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionActive = - RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} +remoteCtrlInfo :: RemoteCtrl -> Maybe RemoteCtrlSessionState -> RemoteCtrlInfo +remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = + RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} -- XXX: only used for multicast confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () @@ -521,7 +529,7 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot withRemoteCtrlSession $ \case RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ}) _ -> Left $ ChatErrorRemoteCtrl RCEBadState - pure $ remoteCtrlInfo rc True + pure $ remoteCtrlInfo rc $ Just RCSConnected {sessionCode = tlsSessionCode tls} where upsertRemoteCtrl :: ChatMonad m => Text -> RCCtrlPairing -> m RemoteCtrl upsertRemoteCtrl ctrlName rcCtrlPairing = withStore $ \db -> do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index ce28040481..c56b2462b0 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -19,6 +19,7 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Remote.AppVersion +import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) @@ -26,7 +27,7 @@ import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Types import Simplex.Messaging.Crypto.File (CryptoFile) -import Simplex.Messaging.Transport (TLS) +import Simplex.Messaging.Transport (TLS (..)) data RemoteHostClient = RemoteHostClient { hostEncoding :: PlatformEncoding, @@ -79,17 +80,20 @@ data RemoteHostSessionState = RHSStarting | RHSConnecting {invitation :: Text} | RHSPendingConfirmation {sessionCode :: Text} - | RHSConfirmed - | RHSConnected + | RHSConfirmed {sessionCode :: Text} + | RHSConnected {sessionCode :: Text} deriving (Show) rhsSessionState :: RemoteHostSession -> RemoteHostSessionState rhsSessionState = \case RHSessionStarting -> RHSStarting RHSessionConnecting {invitation} -> RHSConnecting {invitation} - RHSessionPendingConfirmation {sessionCode} -> RHSPendingConfirmation {sessionCode} - RHSessionConfirmed {} -> RHSConfirmed - RHSessionConnected {} -> RHSConnected + RHSessionPendingConfirmation {tls} -> RHSPendingConfirmation {sessionCode = tlsSessionCode tls} + RHSessionConfirmed {tls} -> RHSConfirmed {sessionCode = tlsSessionCode tls} + RHSessionConnected {tls} -> RHSConnected {sessionCode = tlsSessionCode tls} + +tlsSessionCode :: TLS -> Text +tlsSessionCode = verificationCode . tlsUniq data RemoteProtocolError = -- | size prefix is malformed @@ -143,13 +147,8 @@ data RemoteCtrl = RemoteCtrl ctrlPairing :: RCCtrlPairing } --- | UI-accessible remote controller information -data RemoteCtrlInfo = RemoteCtrlInfo - { remoteCtrlId :: RemoteCtrlId, - ctrlDeviceName :: Text, - sessionActive :: Bool - } - deriving (Show) +remoteCtrlId' :: RemoteCtrl -> RemoteCtrlId +remoteCtrlId' = remoteCtrlId data PlatformEncoding = PESwift @@ -196,8 +195,6 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RHS") ''RemoteHostSessionState) $(J.deriveJSON defaultJSON ''RemoteHostInfo) -$(J.deriveJSON defaultJSON ''RemoteCtrlInfo) - $(J.deriveJSON defaultJSON ''CtrlAppInfo) $(J.deriveJSON defaultJSON ''HostAppInfo) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 544614e23b..98a3edd478 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1710,16 +1710,21 @@ viewRemoteHosts = \case RHSStarting -> " (starting)" RHSConnecting _ -> " (connecting)" RHSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" - RHSConfirmed -> " (confirmed)" - RHSConnected -> " (connected)" + RHSConfirmed _ -> " (confirmed)" + RHSConnected _ -> " (connected)" viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] viewRemoteCtrls = \case [] -> ["No remote controllers"] hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs where - viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} = - plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (connected)" else "" + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> maybe "" viewSessionState sessionState + viewSessionState = \case + RCSStarting -> " (starting)" + RCSConnecting -> " (connecting)" + RCSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" + RCSConnected _ -> " (connected)" -- TODO fingerprint, accepted? viewRemoteCtrl :: RemoteCtrlInfo -> StyledString From a75fce8dfa30e86a9b61c175ca1db04e49fcb77f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 15 Nov 2023 17:57:29 +0200 Subject: [PATCH 071/174] Fix hostStore path and check before removing (#3375) --- src/Simplex/Chat/Remote.hs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 16044ee923..b3d2d0ebf0 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -70,7 +70,7 @@ import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Concurrent (forkIO) -import UnliftIO.Directory (copyFile, createDirectoryIfMissing, removeDirectoryRecursive, renameFile) +import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile) -- when acting as host minRemoteCtrlVersion :: AppVersion @@ -279,11 +279,11 @@ remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do RemoteHost {storePath} <- withStore (`getRemoteHost` rhId) - chatReadVar filesFolder >>= \case + chatReadVar remoteHostsFolder >>= \case Just baseDir -> do let hostStore = baseDir storePath logInfo $ "removing host store at " <> tshow hostStore - removeDirectoryRecursive $ hostStore + whenM (doesDirectoryExist hostStore) $ removeDirectoryRecursive hostStore Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) From 339c3d2be187c843ab63a0eb519c04ed987d271c Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 15 Nov 2023 19:31:36 +0200 Subject: [PATCH 072/174] Send CRRemote*Stopped on all errors (#3376) * Send CRRemote*Stopped on all errors Commands use the same action, made idempotent and don't send events. * fix tests * get http2 cancelling back --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Remote.hs | 64 +++++++++++++++++++--------------- src/Simplex/Chat/View.hs | 4 ++- tests/RemoteTests.hs | 20 +++-------- 5 files changed, 45 insertions(+), 47 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b6b7cca1a6..ea16785e44 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -375,7 +375,7 @@ restoreCalls = do stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do - readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost) + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost True) atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fb03844f89..68d909f784 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -647,7 +647,7 @@ data ChatResponse | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} - | CRRemoteHostStopped {remoteHostId :: RemoteHostId} + | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index b3d2d0ebf0..23d74404d5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -157,12 +157,9 @@ startRemoteHost rh_ = do when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () - handleHostError rhKeyVar action = do - action `catchChatError` \err -> do - logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - sessions <- asks remoteHostSessions - session_ <- atomically $ readTVar rhKeyVar >>= (`TM.lookupDelete` sessions) - mapM_ (liftIO . cancelRemoteHost) session_ + handleHostError rhKeyVar action = action `catchChatError` \err -> do + logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err + readTVarIO rhKeyVar >>= cancelRemoteHostSession True True waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite @@ -185,7 +182,7 @@ startRemoteHost rh_ = do when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' toView $ CRNewRemoteHost rhi - disconnected <- toIO $ onDisconnected remoteHostId + disconnected <- toIO $ onDisconnected rhKey' httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient @@ -206,13 +203,10 @@ startRemoteHost rh_ = do Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' pure (rhi :: RemoteHostInfo) {sessionState = Just state} - onDisconnected :: ChatMonad m => RemoteHostId -> m () - onDisconnected remoteHostId = do + onDisconnected :: ChatMonad m => RHKey -> m () + onDisconnected rhKey = do logDebug "HTTP2 client disconnected" - chatModifyVar currentRemoteHost $ \cur -> if cur == Just remoteHostId then Nothing else cur -- only wipe the closing RH - sessions <- asks remoteHostSessions - void . atomically $ TM.lookupDelete (RHId remoteHostId) sessions - toView $ CRRemoteHostStopped remoteHostId + cancelRemoteHostSession True False rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ @@ -225,12 +219,23 @@ startRemoteHost rh_ = do closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey - chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH - join . withRemoteHostSession_ rhKey . maybe (Left $ ChatErrorRemoteHost rhKey RHEInactive) $ - \s -> Right (liftIO $ cancelRemoteHost s, Nothing) + cancelRemoteHostSession False True rhKey -cancelRemoteHost :: RemoteHostSession -> IO () -cancelRemoteHost = \case +cancelRemoteHostSession :: ChatMonad m => Bool -> Bool -> RHKey -> m () +cancelRemoteHostSession sendEvent stopHttp rhKey = handleAny (logError . tshow) $ do + chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH + sessions <- asks remoteHostSessions + session_ <- atomically $ TM.lookupDelete rhKey sessions + forM_ session_ $ \session -> do + liftIO $ cancelRemoteHost stopHttp session + when sendEvent $ toView $ CRRemoteHostStopped rhId_ + where + rhId_ = case rhKey of + RHNew -> Nothing + RHId rhId -> Just rhId + +cancelRemoteHost :: Bool -> RemoteHostSession -> IO () +cancelRemoteHost stopHttp = \case RHSessionStarting -> pure () RHSessionConnecting _inv rhs -> cancelPendingSession rhs RHSessionPendingConfirmation _sessCode tls rhs -> do @@ -241,9 +246,9 @@ cancelRemoteHost = \case closeConnection tls RHSessionConnected {rchClient, tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do uninterruptibleCancel pollAction - closeHTTP2Client httpClient - closeConnection tls - cancelHostClient rchClient + when stopHttp $ closeHTTP2Client httpClient `catchAny` (logError . tshow) + closeConnection tls `catchAny` (logError . tshow) + cancelHostClient rchClient `catchAny` (logError . tshow) where cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do uninterruptibleCancel rhsWaitSession @@ -544,22 +549,23 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot monitor server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - cancelActiveRemoteCtrl - toView CRRemoteCtrlStopped + cancelActiveRemoteCtrl True stopRemoteCtrl :: ChatMonad m => m () -stopRemoteCtrl = - join . withRemoteCtrlSession_ . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ - \s -> Right (liftIO $ cancelRemoteCtrl s, Nothing) +stopRemoteCtrl = cancelActiveRemoteCtrl False handleCtrlError :: ChatMonad m => Text -> m a -> m a handleCtrlError name action = action `catchChatError` \e -> do logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl + cancelActiveRemoteCtrl True throwError e -cancelActiveRemoteCtrl :: ChatMonad m => m () -cancelActiveRemoteCtrl = withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) +cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () +cancelActiveRemoteCtrl sendEvent = handleAny (logError . tshow) $ do + session_ <- withRemoteCtrlSession_ (\s -> pure (s, Nothing)) + forM_ session_ $ \session -> do + liftIO $ cancelRemoteCtrl session + when sendEvent $ toView CRRemoteCtrlStopped cancelRemoteCtrl :: RemoteCtrlSession -> IO () cancelRemoteCtrl = \case diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 98a3edd478..a6843de601 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -297,7 +297,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ] CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] - CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] + CRRemoteHostStopped rhId_ -> + [ maybe "new remote host" (mappend "remote host " . sShow) rhId_ <> " stopped" + ] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index e3bef7f9e7..0d3dc74627 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -1,7 +1,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} module RemoteTests where @@ -40,7 +39,7 @@ remoteTests = describe "Remote" $ do it "sends messages" remoteMessageTest describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest - it "should send files from CLI wihtout /store" remoteCLIFileTest + it "should send files from CLI without /store" remoteCLIFileTest it "switches remote hosts" switchRemoteHostTest it "indicates remote hosts" indicateRemoteHostTest @@ -439,24 +438,15 @@ stopDesktop :: HasCallStack => TestCC -> TestCC -> IO () stopDesktop mobile desktop = do logWarn "stopping via desktop" desktop ##> "/stop remote host 1" - -- desktop <## "ok" - concurrentlyN_ - [ do - desktop <## "remote host 1 stopped" - desktop <## "ok", - eventually 3 $ mobile <## "remote controller stopped" - ] + desktop <## "ok" + eventually 3 $ mobile <## "remote controller stopped" stopMobile :: HasCallStack => TestCC -> TestCC -> IO () stopMobile mobile desktop = do logWarn "stopping via mobile" mobile ##> "/stop remote ctrl" - concurrentlyN_ - [ do - mobile <## "remote controller stopped" - mobile <## "ok", - eventually 3 $ desktop <## "remote host 1 stopped" - ] + mobile <## "ok" + eventually 3 $ desktop <## "remote host 1 stopped" -- | Run action with extended timeout eventually :: Int -> IO a -> IO a From c31ae396176394f48287f5f1dfb0ce25b1f9d3f2 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:56:39 +0200 Subject: [PATCH 073/174] remote: fix circular error handling (#3380) --- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Remote.hs | 46 +++++++++++++++++++------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ea16785e44..e2439f69d6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -375,8 +375,8 @@ restoreCalls = do stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do - readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost True) - atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost False) + atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl False) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 23d74404d5..331e3348a4 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -159,10 +159,10 @@ startRemoteHost rh_ = do handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () handleHostError rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - readTVarIO rhKeyVar >>= cancelRemoteHostSession True True + readTVarIO rhKeyVar >>= cancelRemoteHostSession True waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do - (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite + (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId withRemoteHostSession rhKey $ \case RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') -- TODO check it's the same session? @@ -170,7 +170,7 @@ startRemoteHost rh_ = do -- display confirmation code, wait for mobile to confirm let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just $ RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} - (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code + (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case @@ -206,7 +206,7 @@ startRemoteHost rh_ = do onDisconnected :: ChatMonad m => RHKey -> m () onDisconnected rhKey = do logDebug "HTTP2 client disconnected" - cancelRemoteHostSession True False rhKey + cancelRemoteHostSession True rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ @@ -219,23 +219,23 @@ startRemoteHost rh_ = do closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey - cancelRemoteHostSession False True rhKey + cancelRemoteHostSession False rhKey -cancelRemoteHostSession :: ChatMonad m => Bool -> Bool -> RHKey -> m () -cancelRemoteHostSession sendEvent stopHttp rhKey = handleAny (logError . tshow) $ do +cancelRemoteHostSession :: ChatMonad m => Bool -> RHKey -> m () +cancelRemoteHostSession handlingError rhKey = handleAny (logError . tshow) $ do chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH sessions <- asks remoteHostSessions - session_ <- atomically $ TM.lookupDelete rhKey sessions + session_ <- atomically $ TM.lookupDelete rhKey sessions -- XXX: when invoked from delayed error handler this can wipe the next session instead forM_ session_ $ \session -> do - liftIO $ cancelRemoteHost stopHttp session - when sendEvent $ toView $ CRRemoteHostStopped rhId_ + liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) + when handlingError $ toView $ CRRemoteHostStopped rhId_ where rhId_ = case rhKey of RHNew -> Nothing RHId rhId -> Just rhId cancelRemoteHost :: Bool -> RemoteHostSession -> IO () -cancelRemoteHost stopHttp = \case +cancelRemoteHost handlingError = \case RHSessionStarting -> pure () RHSessionConnecting _inv rhs -> cancelPendingSession rhs RHSessionPendingConfirmation _sessCode tls rhs -> do @@ -246,13 +246,13 @@ cancelRemoteHost stopHttp = \case closeConnection tls RHSessionConnected {rchClient, tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do uninterruptibleCancel pollAction - when stopHttp $ closeHTTP2Client httpClient `catchAny` (logError . tshow) - closeConnection tls `catchAny` (logError . tshow) cancelHostClient rchClient `catchAny` (logError . tshow) + closeConnection tls `catchAny` (logError . tshow) + unless handlingError $ closeHTTP2Client httpClient `catchAny` (logError . tshow) where cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do - uninterruptibleCancel rhsWaitSession - cancelHostClient rchClient + unless handlingError $ uninterruptibleCancel rhsWaitSession `catchAny` (logError . tshow) + cancelHostClient rchClient `catchAny` (logError . tshow) -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -561,24 +561,24 @@ handleCtrlError name action = action `catchChatError` \e -> do throwError e cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () -cancelActiveRemoteCtrl sendEvent = handleAny (logError . tshow) $ do +cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do session_ <- withRemoteCtrlSession_ (\s -> pure (s, Nothing)) forM_ session_ $ \session -> do - liftIO $ cancelRemoteCtrl session - when sendEvent $ toView CRRemoteCtrlStopped + liftIO $ cancelRemoteCtrl handlingError session + when handlingError $ toView CRRemoteCtrlStopped -cancelRemoteCtrl :: RemoteCtrlSession -> IO () -cancelRemoteCtrl = \case +cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () +cancelRemoteCtrl handlingError = \case RCSessionStarting -> pure () RCSessionConnecting {rcsClient, rcsWaitSession} -> do - uninterruptibleCancel rcsWaitSession + unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient RCSessionPendingConfirmation {rcsClient, tls, rcsWaitSession} -> do - uninterruptibleCancel rcsWaitSession + unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient closeConnection tls RCSessionConnected {rcsClient, tls, http2Server} -> do - uninterruptibleCancel http2Server + unless handlingError $ uninterruptibleCancel http2Server cancelCtrlClient rcsClient closeConnection tls From 0322b9708b9675e67078c4fd9e1070eb939ceafa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:53:44 +0000 Subject: [PATCH 074/174] desktop, ios: remote desktop/mobile connection (#3223) * ui: remote desktop/mobile connection (WIP) * add startRemoteCtrl and capability (does not work) * re-add view * update core library * iOS connects to CLI * ios: mobile ui * multiplatform types * update lib * iOS and desktop connected * fix controllers list on mobile * remove iOS 16 paste button * update device name * connect existing device * proposed model * missing function names in exports * unused * remote host picker * update type * update lib, keep iOS session alive * better UI * update network statuses on switching remote/local hosts * changes * ios: prevent dismissing sheet/back when session is connected * changes * ios: fix back button asking to disconnect when not connected * iOS: update type * picker and session code * multiplatform: update type * menu fix * ios: better ux * desktop: better ux * ios: options etc * UI * desktop: fix RemoteHostStopped event * ios: open Use from desktop via picker * desktop: "new mobile device" * ios: load remote controllers synchronously, update on connect, fix alerts * titles * changes * more changes to ui * more and more changes in ui * padding * ios: show desktop version, handle errors * fix stopped event * refresh hosts always * radical change * optimization * change * ios: stop in progress session when closing window --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 35 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 73 ++- .../Shared/Views/ChatList/ChatListView.swift | 10 +- .../Shared/Views/ChatList/UserPicker.swift | 14 +- .../RemoteAccess/ConnectDesktopView.swift | 434 ++++++++++++++++++ .../Views/UserSettings/SettingsView.swift | 19 +- apps/ios/SimpleX (iOS).entitlements | 2 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 ++- apps/ios/SimpleXChat/APITypes.swift | 106 +++-- .../src/commonMain/cpp/android/simplex-api.c | 9 + .../src/commonMain/cpp/desktop/simplex-api.c | 9 + .../kotlin/chat/simplex/common/App.kt | 11 +- .../chat/simplex/common/model/ChatModel.kt | 19 + .../chat/simplex/common/model/SimpleXAPI.kt | 320 +++++++++---- .../chat/simplex/common/platform/Core.kt | 1 + .../simplex/common/views/chat/ChatView.kt | 5 +- .../common/views/chat/item/ChatItemView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 49 +- .../common/views/chatlist/ShareListView.kt | 7 +- .../common/views/chatlist/UserPicker.kt | 255 ++++++++-- .../simplex/common/views/helpers/ModalView.kt | 3 +- .../common/views/remote/ConnectMobileView.kt | 359 +++++++++++++++ .../common/views/usersettings/SettingsView.kt | 4 + .../commonMain/resources/MR/base/strings.xml | 18 + .../resources/MR/images/ic_smartphone.svg | 1 + .../resources/MR/images/ic_smartphone_300.svg | 1 + .../resources/MR/images/ic_wifi.svg | 1 + .../resources/MR/images/ic_wifi_off.svg | 1 + flake.nix | 2 + libsimplex.dll.def | 1 + 30 files changed, 1569 insertions(+), 254 deletions(-) create mode 100644 apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index fe9032e7c2..90e4272b0d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -85,6 +85,8 @@ final class ChatModel: ObservableObject { @Published var activeCall: Call? @Published var callCommand: WCallCommand? @Published var showCallView = false + // remote desktop + @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing QR code @Published var connReqInv: String? // audio recording and playback @@ -110,6 +112,10 @@ final class ChatModel: ObservableObject { notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() } + var activeRemoteCtrl: Bool { + remoteCtrlSession?.active ?? false + } + func getUser(_ userId: Int64) -> User? { currentUser?.userId == userId ? currentUser @@ -762,3 +768,32 @@ final class GMember: ObservableObject, Identifiable { var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } } static let sampleData = GMember(GroupMember.sampleData) } + +struct RemoteCtrlSession { + var ctrlAppInfo: CtrlAppInfo + var appVersion: String + var sessionState: UIRemoteCtrlSessionState + + func updateState(_ state: UIRemoteCtrlSessionState) -> RemoteCtrlSession { + RemoteCtrlSession(ctrlAppInfo: ctrlAppInfo, appVersion: appVersion, sessionState: state) + } + + var active: Bool { + if case .connected = sessionState { true } else { false } + } + + var sessionCode: String? { + switch sessionState { + case let .pendingConfirmation(_, sessionCode): sessionCode + case let .connected(_, sessionCode): sessionCode + default: nil + } + } +} + +enum UIRemoteCtrlSessionState { + case starting + case connecting(remoteCtrl_: RemoteCtrlInfo?) + case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) + case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String) +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f47d391931..f1aba91263 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -905,30 +905,36 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? { } } -func startRemoteCtrl() async throws { - try await sendCommandOkResp(.startRemoteCtrl) +func setLocalDeviceName(_ displayName: String) throws { + try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName)) } -func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> RemoteCtrlInfo { - let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB)) - if case let .remoteCtrlRegistered(rcInfo) = r { return rcInfo } +func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { + let r = await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) + if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } throw r } -func listRemoteCtrls() async throws -> [RemoteCtrlInfo] { - let r = await chatSendCmd(.listRemoteCtrls) +func findKnownRemoteCtrl() async throws { + try await sendCommandOkResp(.findKnownRemoteCtrl) +} + +func confirmRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.confirmRemoteCtrl(remoteCtrlId: rcId)) +} + +func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo { + let r = await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode)) + if case let .remoteCtrlConnected(rc) = r { return rc } + throw r +} + +func listRemoteCtrls() throws -> [RemoteCtrlInfo] { + let r = chatSendCmdSync(.listRemoteCtrls) if case let .remoteCtrlList(rcInfo) = r { return rcInfo } throw r } -func acceptRemoteCtrl(_ rcId: Int64) async throws { - try await sendCommandOkResp(.acceptRemoteCtrl(remoteCtrlId: rcId)) -} - -func rejectRemoteCtrl(_ rcId: Int64) async throws { - try await sendCommandOkResp(.rejectRemoteCtrl(remoteCtrlId: rcId)) -} - func stopRemoteCtrl() async throws { try await sendCommandOkResp(.stopRemoteCtrl) } @@ -1065,6 +1071,12 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } +private func sendCommandOkRespSync(_ cmd: ChatCommand) throws { + let r = chatSendCmdSync(cmd) + if case .cmdOk = r { return } + throw r +} + func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) @@ -1702,6 +1714,24 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) } + case let .remoteCtrlFound(remoteCtrl): + // TODO multicast + logger.debug("\(String(describing: remoteCtrl))") + case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): + await MainActor.run { + let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode) + m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state) + } + case let .remoteCtrlConnected(remoteCtrl): + // TODO currently it is returned in response to command, so it is redundant + await MainActor.run { + let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "") + m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state) + } + case .remoteCtrlStopped: + await MainActor.run { + switchToLocalSession() + } default: logger.debug("unsupported event: \(res.responseType)") } @@ -1715,6 +1745,19 @@ func processReceivedMsg(_ res: ChatResponse) async { } } +func switchToLocalSession() { + let m = ChatModel.shared + m.remoteCtrlSession = nil + do { + m.users = try listUsers() + try getUserChatData() + let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } + m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) + } catch let error { + logger.debug("error updating chat data: \(responseError(error))") + } +} + func active(_ user: any UserLike) -> Bool { user.userId == ChatModel.shared.currentUser?.id } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index a006f333f8..1d86733206 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -15,6 +15,7 @@ struct ChatListView: View { @State private var searchText = "" @State private var showAddChat = false @State private var userPickerVisible = false + @State private var showConnectDesktop = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false var body: some View { @@ -48,7 +49,14 @@ struct ChatListView: View { } } } - UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible) + UserPicker( + showSettings: $showSettings, + showConnectDesktop: $showConnectDesktop, + userPickerVisible: $userPickerVisible + ) + } + .sheet(isPresented: $showConnectDesktop) { + ConnectDesktopView() } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index bb88f5c382..741af6f08f 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -13,6 +13,7 @@ struct UserPicker: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @Binding var showSettings: Bool + @Binding var showConnectDesktop: Bool @Binding var userPickerVisible: Bool @State var scrollViewContentSize: CGSize = .zero @State var disableScrolling: Bool = true @@ -62,6 +63,13 @@ struct UserPicker: View { .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) .frame(maxHeight: scrollViewContentSize.height) + menuButton("Use from desktop", icon: "desktopcomputer") { + showConnectDesktop = true + withAnimation { + userPickerVisible.toggle() + } + } + Divider() menuButton("Settings", icon: "gearshape") { showSettings = true withAnimation { @@ -85,7 +93,7 @@ struct UserPicker: View { do { m.users = try listUsers() } catch let error { - logger.error("Error updating users \(responseError(error))") + logger.error("Error loading users \(responseError(error))") } } } @@ -144,7 +152,8 @@ struct UserPicker: View { .overlay(DetermineWidth()) Spacer() Image(systemName: icon) -// .frame(width: 24, alignment: .center) + .symbolRenderingMode(.monochrome) + .foregroundColor(.secondary) } .padding(.horizontal) .padding(.vertical, 22) @@ -170,6 +179,7 @@ struct UserPicker_Previews: PreviewProvider { m.users = [UserInfo.sampleData, UserInfo.sampleData] return UserPicker( showSettings: Binding.constant(false), + showConnectDesktop: Binding.constant(false), userPickerVisible: Binding.constant(true) ) .environmentObject(m) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift new file mode 100644 index 0000000000..0f6ef7be0d --- /dev/null +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -0,0 +1,434 @@ +// +// ConnectDesktopView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 13/10/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import CodeScanner + +struct ConnectDesktopView: View { + @EnvironmentObject var m: ChatModel + @Environment(\.dismiss) var dismiss: DismissAction + var viaSettings = false + @AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name + @AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false + @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = false + @AppStorage(DEFAULT_OFFER_REMOTE_MULTICAST) private var offerRemoteMulticast = true + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @State private var sessionAddress: String = "" + @State private var remoteCtrls: [RemoteCtrlInfo] = [] + @State private var alert: ConnectDesktopAlert? + + private enum ConnectDesktopAlert: Identifiable { + case unlinkDesktop(rc: RemoteCtrlInfo) + case disconnectDesktop(action: UserDisconnectAction) + case badInvitationError + case badVersionError(version: String?) + case desktopDisconnectedError + case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + + var id: String { + switch self { + case let .unlinkDesktop(rc): "unlinkDesktop \(rc.remoteCtrlId)" + case let .disconnectDesktop(action): "disconnectDecktop \(action)" + case .badInvitationError: "badInvitationError" + case let .badVersionError(v): "badVersionError \(v ?? "")" + case .desktopDisconnectedError: "desktopDisconnectedError" + case let .error(title, _): "error \(title)" + } + } + } + + private enum UserDisconnectAction: String { + case back + case dismiss // TODO dismiss settings after confirmation + } + + var body: some View { + if viaSettings { + viewBody + .modifier(BackButton(label: "Back") { + if m.activeRemoteCtrl { + alert = .disconnectDesktop(action: .back) + } else { + dismiss() + } + }) + } else { + NavigationView { + viewBody + } + } + } + + var viewBody: some View { + Group { + if let session = m.remoteCtrlSession { + switch session.sessionState { + case .starting: connectingDesktopView(session, nil) + case let .connecting(rc_): connectingDesktopView(session, rc_) + case let .pendingConfirmation(rc_, sessCode): + if confirmRemoteSessions || rc_ == nil { + verifySessionView(session, rc_, sessCode) + } else { + connectingDesktopView(session, rc_).onAppear { + verifyDesktopSessionCode(sessCode) + } + } + case let .connected(rc, _): activeSessionView(session, rc) + } + } else { + connectDesktopView() + } + } + .onAppear { + setDeviceName(deviceName) + updateRemoteCtrls() + } + .onDisappear { + if m.remoteCtrlSession != nil { + disconnectDesktop() + } + } + .onChange(of: deviceName) { + setDeviceName($0) + } + .onChange(of: m.activeRemoteCtrl) { + UIApplication.shared.isIdleTimerDisabled = $0 + } + .alert(item: $alert) { a in + switch a { + case let .unlinkDesktop(rc): + Alert( + title: Text("Unlink desktop?"), + primaryButton: .destructive(Text("Unlink")) { + unlinkDesktop(rc) + }, + secondaryButton: .cancel() + ) + case let .disconnectDesktop(action): + Alert( + title: Text("Disconnect desktop?"), + primaryButton: .destructive(Text("Disconnect")) { + disconnectDesktop(action) + }, + secondaryButton: .cancel() + ) + case .badInvitationError: + Alert(title: Text("Bad desktop address")) + case let .badVersionError(v): + Alert( + title: Text("Incompatible version"), + message: Text("Desktop app version \(v ?? "") is not compatible with this app.") + ) + case .desktopDisconnectedError: + Alert(title: Text("Connection terminated")) + case let .error(title, error): + Alert(title: Text(title), message: Text(error)) + } + } + .interactiveDismissDisabled(m.activeRemoteCtrl) + } + + private func connectDesktopView() -> some View { + List { + Section("This device name") { + devicesView() + } + scanDesctopAddressView() + if developerTools { + desktopAddressView() + } + } + .navigationTitle("Connect to desktop") + } + + private func connectingDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> some View { + List { + Section("Connecting to desktop") { + ctrlDeviceNameText(session, rc) + ctrlDeviceVersionText(session) + } + + if let sessCode = session.sessionCode { + Section("Session code") { + sessionCodeText(sessCode) + } + } + + Section { + disconnectButton() + } + } + .navigationTitle("Connecting to desktop") + } + + private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View { + List { + Section("Connected to desktop") { + ctrlDeviceNameText(session, rc) + ctrlDeviceVersionText(session) + } + + Section("Verify code with desktop") { + sessionCodeText(sessCode) + Button { + verifyDesktopSessionCode(sessCode) + } label: { + Label("Confirm", systemImage: "checkmark") + } + } + + Section { + disconnectButton() + } + } + .navigationTitle("Verify connection") + } + + private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text { + var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo.deviceName) + if (rc == nil) { + t = t + Text(" ") + Text("(new)").italic() + } + return t + } + + private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text { + let v = session.ctrlAppInfo.appVersionRange.maxVersion + var t = Text("v\(v)") + if v != session.appVersion { + t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic() + } + return t + } + + private func activeSessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo) -> some View { + List { + Section("Connected desktop") { + Text(rc.deviceViewName) + ctrlDeviceVersionText(session) + } + + if let sessCode = session.sessionCode { + Section("Session code") { + sessionCodeText(sessCode) + } + } + + Section { + disconnectButton() + } footer: { + // This is specific to iOS + Text("Keep the app open to use it from desktop") + } + } + .navigationTitle("Connected to desktop") + } + + private func sessionCodeText(_ code: String) -> some View { + Text(code.prefix(23)) + } + + private func devicesView() -> some View { + Group { + TextField("Enter this device name…", text: $deviceName) + if !remoteCtrls.isEmpty { + NavigationLink { + linkedDesktopsView() + } label: { + Text("Linked desktops") + } + } + } + } + + private func scanDesctopAddressView() -> some View { + Section("Scan QR code from desktop") { + CodeScannerView(codeTypes: [.qr], completion: processDesktopQRCode) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .padding(.horizontal) + } + } + + private func desktopAddressView() -> some View { + Section("Desktop address") { + if sessionAddress.isEmpty { + Button { + sessionAddress = UIPasteboard.general.string ?? "" + } label: { + Label("Paste desktop address", systemImage: "doc.plaintext") + } + .disabled(!UIPasteboard.general.hasStrings) + } else { + HStack { + Text(sessionAddress).lineLimit(1) + Spacer() + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + .onTapGesture { sessionAddress = "" } + } + } + Button { + connectDesktopAddress(sessionAddress) + } label: { + Label("Connect to desktop", systemImage: "rectangle.connected.to.line.below") + } + .disabled(sessionAddress.isEmpty) + } + } + + private func linkedDesktopsView() -> some View { + List { + Section("Desktop devices") { + ForEach(remoteCtrls, id: \.remoteCtrlId) { rc in + remoteCtrlView(rc) + } + .onDelete { indexSet in + if let i = indexSet.first, i < remoteCtrls.count { + alert = .unlinkDesktop(rc: remoteCtrls[i]) + } + } + } + + Section("Linked desktop options") { + Toggle("Verify connections", isOn: $confirmRemoteSessions) + Toggle("Discover on network", isOn: $connectRemoteViaMulticast).disabled(true) + } + } + .navigationTitle("Linked desktops") + } + + private func remoteCtrlView(_ rc: RemoteCtrlInfo) -> some View { + Text(rc.deviceViewName) + } + + + private func setDeviceName(_ name: String) { + do { + try setLocalDeviceName(deviceName) + } catch let e { + errorAlert(e) + } + } + + private func updateRemoteCtrls() { + do { + remoteCtrls = try listRemoteCtrls() + } catch let e { + errorAlert(e) + } + } + + private func processDesktopQRCode(_ resp: Result) { + switch resp { + case let .success(r): connectDesktopAddress(r.string) + case let .failure(e): errorAlert(e) + } + } + + private func connectDesktopAddress(_ addr: String) { + Task { + do { + let (rc_, ctrlAppInfo, v) = try await connectRemoteCtrl(desktopAddress: addr) + await MainActor.run { + sessionAddress = "" + m.remoteCtrlSession = RemoteCtrlSession( + ctrlAppInfo: ctrlAppInfo, + appVersion: v, + sessionState: .connecting(remoteCtrl_: rc_) + ) + } + } catch let e { + await MainActor.run { + switch e as? ChatResponse { + case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError + case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError + case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v) + case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil) + case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError + default: errorAlert(e) + } + } + } + } + } + + private func verifyDesktopSessionCode(_ sessCode: String) { + Task { + do { + let rc = try await verifyRemoteCtrlSession(sessCode) + await MainActor.run { + m.remoteCtrlSession = m.remoteCtrlSession?.updateState(.connected(remoteCtrl: rc, sessionCode: sessCode)) + } + await MainActor.run { + updateRemoteCtrls() + } + } catch let error { + await MainActor.run { + errorAlert(error) + } + } + } + } + + private func disconnectButton() -> some View { + Button { + disconnectDesktop() + } label: { + Label("Disconnect", systemImage: "multiply") + } + } + + private func disconnectDesktop(_ action: UserDisconnectAction? = nil) { + Task { + do { + try await stopRemoteCtrl() + await MainActor.run { + switchToLocalSession() + switch action { + case .back: dismiss() + case .dismiss: dismiss() + case .none: () + } + } + } catch let e { + await MainActor.run { + errorAlert(e) + } + } + } + } + + private func unlinkDesktop(_ rc: RemoteCtrlInfo) { + Task { + do { + try await deleteRemoteCtrl(rc.remoteCtrlId) + await MainActor.run { + remoteCtrls.removeAll(where: { $0.remoteCtrlId == rc.remoteCtrlId }) + } + } catch let e { + await MainActor.run { + errorAlert(e) + } + } + } + } + + private func errorAlert(_ error: Error) { + let a = getErrorAlert(error, "Error") + alert = .error(title: a.title, error: a.message) + } +} + +#Preview { + ConnectDesktopView() +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 1cc859f49e..423786eb66 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -53,6 +53,10 @@ let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" +let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" +let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" +let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" +let DEFAULT_OFFER_REMOTE_MULTICAST = "offerRemoteMulticast" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -85,7 +89,10 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, - DEFAULT_SHOW_UNREAD_AND_FAVORITES: false + DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, + DEFAULT_CONFIRM_REMOTE_SESSIONS: false, + DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: false, + DEFAULT_OFFER_REMOTE_MULTICAST: true ] enum SimpleXLinkMode: String, Identifiable { @@ -178,6 +185,12 @@ struct SettingsView: View { } label: { settingsRow("switch.2") { Text("Chat preferences") } } + + NavigationLink { + ConnectDesktopView(viaSettings: true) + } label: { + settingsRow("desktopcomputer") { Text("Use from desktop") } + } } .disabled(chatModel.chatRunning != true) @@ -362,7 +375,9 @@ struct SettingsView: View { func settingsRow(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View { ZStack(alignment: .leading) { - Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color) + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .symbolRenderingMode(.monochrome) + .foregroundColor(color) content().padding(.leading, indent) } } diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 51672d6290..80e4adf2c2 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -18,5 +18,7 @@ $(AppIdentifierPrefix)chat.simplex.app + com.apple.developer.networking.multicast + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e675772e7d..62db4e43ec 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; + 5C3CCFCC2AE6BD3100C3F0C3 /* ConnectDesktopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */; }; 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; @@ -117,6 +118,11 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; + 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A282B04FE2D00A71D61 /* libgmp.a */; }; + 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */; }; + 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */; }; + 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */; }; + 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -142,11 +148,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 */; }; - 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF722AFF8D4D007893ED /* libffi.a */; }; - 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */; }; - 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */; }; - 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF752AFF8D4E007893ED /* libgmp.a */; }; - 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -282,6 +283,7 @@ 5C36027227F47AD5009F19D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; + 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectDesktopView.swift; sourceTree = ""; }; 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.swift; sourceTree = ""; }; 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; @@ -397,6 +399,11 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CDA5A282B04FE2D00A71D61 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a"; sourceTree = ""; }; + 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a"; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -423,11 +430,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 = ""; }; - 5CF4DF722AFF8D4D007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a"; sourceTree = ""; }; - 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF4DF752AFF8D4E007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -505,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */, - 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */, + 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */, + 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */, + 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */, - 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */, - 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */, + 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -544,6 +546,7 @@ 5CB924DD27A8622200ACCCDD /* NewChat */, 5CFA59C22860B04D00863A68 /* Database */, 5CB634AB29E46CDB0066AD6B /* LocalAuth */, + 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); @@ -572,11 +575,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF4DF722AFF8D4D007893ED /* libffi.a */, - 5CF4DF752AFF8D4E007893ED /* libgmp.a */, - 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */, - 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */, - 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */, + 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */, + 5CDA5A282B04FE2D00A71D61 /* libgmp.a */, + 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */, + 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */, + 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */, ); path = Libraries; sourceTree = ""; @@ -684,6 +687,14 @@ path = "Tests iOS"; sourceTree = ""; }; + 5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = { + isa = PBXGroup; + children = ( + 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */, + ); + path = RemoteAccess; + sourceTree = ""; + }; 5CB0BA8C282711BC00B3292C /* Onboarding */ = { isa = PBXGroup; children = ( @@ -1170,6 +1181,7 @@ 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */, 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */, 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, + 5C3CCFCC2AE6BD3100C3F0C3 /* ConnectDesktopView.swift in Sources */, 5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */, 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b893d00453..e7409a072b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -120,14 +120,16 @@ public enum ChatCommand { case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?) case setFileToReceive(fileId: Int64, encrypted: Bool?) case cancelFile(fileId: Int64) + // remote desktop commands case setLocalDeviceName(displayName: String) - case startRemoteCtrl - case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB) + case connectRemoteCtrl(xrcpInvitation: String) + case findKnownRemoteCtrl + case confirmRemoteCtrl(remoteCtrlId: Int64) + case verifyRemoteCtrlSession(sessionCode: String) case listRemoteCtrls - case acceptRemoteCtrl(remoteCtrlId: Int64) - case rejectRemoteCtrl(remoteCtrlId: Int64) case stopRemoteCtrl case deleteRemoteCtrl(remoteCtrlId: Int64) + // misc case showVersion case string(String) @@ -269,10 +271,10 @@ public enum ChatCommand { case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))" case let .cancelFile(fileId): return "/fcancel \(fileId)" case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" - case .startRemoteCtrl: return "/start remote ctrl" - case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)" - case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)" - case let .rejectRemoteCtrl(rcId): return "/reject remote ctrl \(rcId)" + case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)" + case .findKnownRemoteCtrl: return "/find remote ctrl" + case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)" + case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)" case .listRemoteCtrls: return "/list remote ctrls" case .stopRemoteCtrl: return "/stop remote ctrl" case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" @@ -392,11 +394,11 @@ public enum ChatCommand { case .setFileToReceive: return "setFileToReceive" case .cancelFile: return "cancelFile" case .setLocalDeviceName: return "setLocalDeviceName" - case .startRemoteCtrl: return "startRemoteCtrl" - case .registerRemoteCtrl: return "registerRemoteCtrl" + case .connectRemoteCtrl: return "connectRemoteCtrl" + case .findKnownRemoteCtrl: return "findKnownRemoteCtrl" + case .confirmRemoteCtrl: return "confirmRemoteCtrl" + case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession" case .listRemoteCtrls: return "listRemoteCtrls" - case .acceptRemoteCtrl: return "acceptRemoteCtrl" - case .rejectRemoteCtrl: return "rejectRemoteCtrl" case .stopRemoteCtrl: return "stopRemoteCtrl" case .deleteRemoteCtrl: return "deleteRemoteCtrl" case .showVersion: return "showVersion" @@ -605,13 +607,14 @@ public enum ChatResponse: Decodable, Error { case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case newContactConnection(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlRegistered(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlAnnounce(fingerprint: String) case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlConnecting(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) + case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlStopped + // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) case chatCmdError(user_: UserRef?, chatError: ChatError) @@ -752,10 +755,9 @@ public enum ChatResponse: Decodable, Error { case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" case .remoteCtrlList: return "remoteCtrlList" - case .remoteCtrlRegistered: return "remoteCtrlRegistered" - case .remoteCtrlAnnounce: return "remoteCtrlAnnounce" case .remoteCtrlFound: return "remoteCtrlFound" case .remoteCtrlConnecting: return "remoteCtrlConnecting" + case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" case .remoteCtrlConnected: return "remoteCtrlConnected" case .remoteCtrlStopped: return "remoteCtrlStopped" case .versionInfo: return "versionInfo" @@ -901,10 +903,9 @@ public enum ChatResponse: Decodable, Error { case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlRegistered(remoteCtrl): return String(describing: remoteCtrl) - case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)" case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl) - case let .remoteCtrlConnecting(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" + 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 .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" @@ -1533,21 +1534,31 @@ public enum NotificationPreviewMode: String, SelectableItem { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } -public struct RemoteCtrlOOB { - public var caFingerprint: String -} - public struct RemoteCtrlInfo: Decodable { public var remoteCtrlId: Int64 - public var displayName: String - public var sessionActive: Bool + public var ctrlDeviceName: String + public var sessionState: RemoteCtrlSessionState? + + public var deviceViewName: String { + ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName + } } -public struct RemoteCtrl: Decodable { - var remoteCtrlId: Int64 - var displayName: String - var fingerprint: String - var accepted: Bool? +public enum RemoteCtrlSessionState: Decodable { + case starting + case connecting + case pendingConfirmation(sessionCode: String) + case connected(sessionCode: String) +} + +public struct CtrlAppInfo: Decodable { + public var appVersionRange: AppVersionRange + public var deviceName: String +} + +public struct AppVersionRange: Decodable { + public var minVersion: String + public var maxVersion: String } public struct CoreVersionInfo: Decodable { @@ -1737,6 +1748,7 @@ public enum AgentErrorType: Decodable { case SMP(smpErr: ProtocolErrorType) case NTF(ntfErr: ProtocolErrorType) case XFTP(xftpErr: XFTPErrorType) + case RCP(rcpErr: RCErrorType) case BROKER(brokerAddress: String, brokerErr: BrokerErrorType) case AGENT(agentErr: SMPAgentError) case INTERNAL(internalErr: String) @@ -1794,6 +1806,22 @@ public enum XFTPErrorType: Decodable { case INTERNAL } +public enum RCErrorType: Decodable { + case `internal`(internalErr: String) + case identity + case noLocalAddress + case tlsStartFailed + case exception(exception: String) + case ctrlAuth + case ctrlNotFound + case ctrlError(ctrlErr: String) + case version + case encrypt + case decrypt + case blockSize + case syntax(syntaxErr: String) +} + public enum ProtocolCommandError: Decodable { case UNKNOWN case SYNTAX @@ -1831,12 +1859,12 @@ public enum ArchiveError: Decodable { } public enum RemoteCtrlError: Decodable { - case inactive - case busy - case timeout - case disconnected(remoteCtrlId: Int64, reason: String) - case connectionLost(remoteCtrlId: Int64, reason: String) - case certificateExpired(remoteCtrlId: Int64) - case certificateUntrusted(remoteCtrlId: Int64) - case badFingerprint + case inactive + case badState + case busy + case timeout + case disconnected(remoteCtrlId: Int64, reason: String) + case badInvitation + case badVersion(appVersion: String) +// case protocolError(protocolError: RemoteProtocolError) } diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 351ed93c97..b729e3b7f5 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -41,6 +41,7 @@ typedef long* chat_ctrl; extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); +extern char *chat_send_remote_cmd(chat_ctrl ctrl, const int rhId, const char *cmd); extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); @@ -86,6 +87,14 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, __unused jclas return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jint rhId, jstring msg) { + const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_send_remote_cmd((void*)controller, rhId, _msg)); + (*env)->ReleaseStringUTFChars(env, msg, _msg); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index f36c86c366..1b0d11a2b7 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -16,6 +16,7 @@ typedef long* chat_ctrl; extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); +extern char *chat_send_remote_cmd(chat_ctrl ctrl, const int rhId, const char *cmd); extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); @@ -98,6 +99,14 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, jclass clazz, return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmd(JNIEnv *env, jclass clazz, jlong controller, jint rhId, jstring msg) { + const char *_msg = encode_to_utf8_chars(env, msg); + jstring res = decode_to_utf8_string(env, chat_send_remote_cmd((void*)controller, rhId, _msg)); + (*env)->ReleaseStringUTFChars(env, msg, _msg); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) { return decode_to_utf8_string(env, chat_recv_msg((void*)controller)); 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 82201cce04..41deee7a55 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 @@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow, val scaffoldState: ScaffoldState, - val switchingUsers: MutableState + val switchingUsersAndHosts: MutableState ) @Composable @@ -121,8 +121,8 @@ fun MainScreen() { showAdvertiseLAAlert = true val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } val scaffoldState = rememberScaffoldState() - val switchingUsers = rememberSaveable { mutableStateOf(false) } - val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsers) } + val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) } + val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) } if (appPlatform.isAndroid) { AndroidScreen(settingsState) } else { @@ -298,7 +298,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { EndPartOfScreen() } } - val (userPickerState, scaffoldState, switchingUsers ) = settingsState + val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState val scope = rememberCoroutineScope() if (scaffoldState.drawerState.isOpen) { Box( @@ -312,8 +312,9 @@ fun DesktopScreen(settingsState: SettingsViewState) { ) } VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) - UserPicker(chatModel, userPickerState, switchingUsers) { + UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE } ModalManager.fullscreen.showInView() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 91b4a8d8f6..2de944f592 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 @@ -106,6 +106,11 @@ object ChatModel { var updatingChatsMutex: Mutex = Mutex() + // remote controller + val remoteHosts = mutableStateListOf() + val currentRemoteHost = mutableStateOf(null) + val newRemoteHostPairing = mutableStateOf?>(null) + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -2841,3 +2846,17 @@ enum class NotificationPreviewMode { val default: NotificationPreviewMode = MESSAGE } } + +data class RemoteCtrlSession( + val ctrlAppInfo: CtrlAppInfo, + val appVersion: String, + val sessionState: RemoteCtrlSessionState +) + +@Serializable +sealed class RemoteCtrlSessionState { + @Serializable @SerialName("starting") object Starting: RemoteCtrlSessionState() + @Serializable @SerialName("connecting") object Connecting: RemoteCtrlSessionState() + @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState() + @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() +} 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 7896d2d6e3..98c48dbfb4 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 @@ -345,11 +345,6 @@ object ChatController { val users = listUsers() chatModel.users.clear() chatModel.users.addAll(users) - val remoteHosts = listRemoteHosts() - if (remoteHosts != null) { - chatModel.remoteHosts.clear() - chatModel.remoteHosts.addAll(remoteHosts) - } if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true @@ -357,6 +352,7 @@ object ChatController { appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true startReceiver() + setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!) Log.d(TAG, "startChat: started") } else { updatingChatsMutex.withLock { @@ -429,7 +425,8 @@ object ChatController { val c = cmd.cmdString chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val json = chatSendCmd(ctrl, c) + val rhId = chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 + val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c) val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") if (r.resp is CR.Response || r.resp is CR.Invalid) { @@ -1174,10 +1171,10 @@ object ChatController { } } - suspend fun cancelFile(user: User, fileId: Long) { + suspend fun cancelFile(rhId: Long?, user: User, fileId: Long) { val chatItem = apiCancelFile(fileId) if (chatItem != null) { - chatItemSimpleUpdate(user, chatItem) + chatItemSimpleUpdate(rhId, user, chatItem) cleanupFile(chatItem) } } @@ -1371,46 +1368,77 @@ object ChatController { suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName)) - suspend fun createRemoteHost(): RemoteHostInfo? { - val r = sendCmd(CC.CreateRemoteHost()) - if (r is CR.RemoteHostCreated) return r.remoteHost - apiErrorAlert("createRemoteHost", generalGetString(MR.strings.error), r) - return null - } - suspend fun listRemoteHosts(): List? { val r = sendCmd(CC.ListRemoteHosts()) if (r is CR.RemoteHostList) return r.remoteHosts - apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error), r) + apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun startRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StartRemoteHost(rhId)) + suspend fun reloadRemoteHosts() { + val hosts = listRemoteHosts() ?: return + chatModel.remoteHosts.clear() + chatModel.remoteHosts.addAll(hosts) + } - suspend fun registerRemoteCtrl(oob: RemoteCtrlOOB): RemoteCtrlInfo? { - val r = sendCmd(CC.RegisterRemoteCtrl(oob)) - if (r is CR.RemoteCtrlRegistered) return r.remoteCtrl - apiErrorAlert("registerRemoteCtrl", generalGetString(MR.strings.error), r) + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair? { + val r = sendCmd(CC.StartRemoteHost(rhId, multicast)) + if (r is CR.RemoteHostStarted) return r.remoteHost_ to r.invitation + apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } + suspend fun switchRemoteHost (rhId: Long?): RemoteHostInfo? { + val r = sendCmd(CC.SwitchRemoteHost(rhId)) + if (r is CR.CurrentRemoteHost) return r.remoteHost_ + apiErrorAlert("switchRemoteHost", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun stopRemoteHost(rhId: Long?): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) + + fun stopRemoteHostAndReloadHosts(h: RemoteHostInfo, switchToLocal: Boolean) { + withBGApi { + stopRemoteHost(h.remoteHostId) + if (switchToLocal) { + switchUIRemoteHost(null) + } else { + reloadRemoteHosts() + } + } + } + + suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) + + suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? { + val r = sendCmd(CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) + if (r is CR.RemoteFileStored) return r.remoteFileSource + apiErrorAlert("storeRemoteFile", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(CC.GetRemoteFile(rhId, file)) + + suspend fun connectRemoteCtrl(invitation: String): SomeRemoteCtrl? { + val r = sendCmd(CC.ConnectRemoteCtrl(invitation)) + if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) + apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl()) + + suspend fun confirmRemoteCtrl(rhId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rhId)) + + suspend fun verifyRemoteCtrlSession(sessionCode: String): Boolean = sendCommandOkResp(CC.VerifyRemoteCtrlSession(sessionCode)) + suspend fun listRemoteCtrls(): List? { val r = sendCmd(CC.ListRemoteCtrls()) if (r is CR.RemoteCtrlList) return r.remoteCtrls - apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error), r) + apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun stopRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) - - suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) - - suspend fun startRemoteCtrl(): Boolean = sendCommandOkResp(CC.StartRemoteCtrl()) - - suspend fun acceptRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.AcceptRemoteCtrl(rcId)) - - suspend fun rejectRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.RejectRemoteCtrl(rcId)) - suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl()) suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId)) @@ -1465,6 +1493,8 @@ object ChatController { private suspend fun processReceivedMsg(apiResp: APIResponse) { lastMsgReceivedTimestamp = System.currentTimeMillis() val r = apiResp.resp + val rhId = apiResp.remoteHostId + fun active(user: UserLike): Boolean = activeUser(rhId, user) chatModel.addTerminalItem(TerminalItem.resp(r)) when (r) { is CR.NewContactConnection -> { @@ -1577,7 +1607,7 @@ object ChatController { ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { - withApi { receiveFile(r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } + withApi { receiveFile(rhId, r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } } if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) @@ -1591,7 +1621,7 @@ object ChatController { } } is CR.ChatItemUpdated -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.ChatItemReaction -> { if (active(r.user)) { chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) @@ -1703,37 +1733,37 @@ object ChatController { chatModel.updateContact(r.contact) } is CR.RcvFileStart -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.RcvFileComplete -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.RcvFileSndCancelled -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.RcvFileProgressXFTP -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.RcvFileError -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.SndFileStart -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.SndFileComplete -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupDirectFile(r.chatItem) } is CR.SndFileRcvCancelled -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupDirectFile(r.chatItem) } is CR.SndFileProgressXFTP -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.SndFileCompleteXFTP -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.SndFileError -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.CallInvitation -> { @@ -1789,12 +1819,18 @@ object ChatController { chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) is CR.GroupMemberRatchetSync -> chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + is CR.RemoteHostSessionCode -> { + chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) + } is CR.RemoteHostConnected -> { - // update - chatModel.connectingRemoteHost.value = r.remoteHost + // TODO needs to update it instead in sessions + chatModel.currentRemoteHost.value = r.remoteHost + switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.RemoteHostStopped -> { - // + chatModel.currentRemoteHost.value = null + chatModel.newRemoteHostPairing.value = null + switchUIRemoteHost(null) } else -> Log.d(TAG , "unsupported event: ${r.responseType}") @@ -1819,7 +1855,8 @@ object ChatController { } } - private fun active(user: UserLike): Boolean = user.userId == chatModel.currentUser.value?.userId + private fun activeUser(rhId: Long?, user: UserLike): Boolean = + rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value @@ -1830,10 +1867,10 @@ object ChatController { } } - suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { + suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { - chatItemSimpleUpdate(user, chatItem) + chatItemSimpleUpdate(rhId, user, chatItem) } } @@ -1844,11 +1881,11 @@ object ChatController { } } - private suspend fun chatItemSimpleUpdate(user: UserLike, aChatItem: AChatItem) { + private suspend fun chatItemSimpleUpdate(rhId: Long?, user: UserLike, aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) } - if (!active(user)) { + if (!activeUser(rhId, user)) { notify() } else if (chatModel.upsertChatItem(cInfo, cItem)) { notify() @@ -1876,6 +1913,25 @@ object ChatController { chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) } + suspend fun switchUIRemoteHost(rhId: Long?) { + chatModel.chatId.value = null + chatModel.currentRemoteHost.value = switchRemoteHost(rhId) + reloadRemoteHosts() + val user = apiGetActiveUser() + val users = listUsers() + chatModel.users.clear() + chatModel.users.addAll(users) + chatModel.currentUser.value = user + chatModel.userCreated.value = true + val statuses = apiGetNetworkStatuses() + if (statuses != null) { + chatModel.networkStatuses.clear() + val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() + chatModel.networkStatuses.putAll(ss) + } + getUserChatData() + } + fun getXFTPCfg(): XFTPFileConfig { return XFTPFileConfig(minFileSize = 0) } @@ -2059,19 +2115,23 @@ sealed class CC { class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() + // Remote control class SetLocalDeviceName(val displayName: String): CC() - class CreateRemoteHost(): CC() class ListRemoteHosts(): CC() - class StartRemoteHost(val remoteHostId: Long): CC() - class StopRemoteHost(val remoteHostId: Long): CC() + class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean): CC() + class SwitchRemoteHost (val remoteHostId: Long?): CC() + class StopRemoteHost(val remoteHostKey: Long?): CC() class DeleteRemoteHost(val remoteHostId: Long): CC() - class StartRemoteCtrl(): CC() - class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() + class StoreRemoteFile(val remoteHostId: Long, val storeEncrypted: Boolean?, val localPath: String): CC() + class GetRemoteFile(val remoteHostId: Long, val file: RemoteFile): CC() + class ConnectRemoteCtrl(val xrcpInvitation: String): CC() + class FindKnownRemoteCtrl(): CC() + class ConfirmRemoteCtrl(val remoteCtrlId: Long): CC() + class VerifyRemoteCtrlSession(val sessionCode: String): CC() class ListRemoteCtrls(): CC() - class AcceptRemoteCtrl(val remoteCtrlId: Long): CC() - class RejectRemoteCtrl(val remoteCtrlId: Long): CC() class StopRemoteCtrl(): CC() class DeleteRemoteCtrl(val remoteCtrlId: Long): CC() + // misc class ShowVersion(): CC() val cmdString: String get() = when (this) { @@ -2192,15 +2252,20 @@ sealed class CC { (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is SetLocalDeviceName -> "/set device name $displayName" - is CreateRemoteHost -> "/create remote host" is ListRemoteHosts -> "/list remote hosts" - is StartRemoteHost -> "/start remote host $remoteHostId" - is StopRemoteHost -> "/stop remote host $remoteHostId" + is StartRemoteHost -> "/start remote host " + if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}" + is SwitchRemoteHost -> "/switch remote host " + if (remoteHostId == null) "local" else "$remoteHostId" + is StopRemoteHost -> "/stop remote host " + if (remoteHostKey == null) "new" else "$remoteHostKey" is DeleteRemoteHost -> "/delete remote host $remoteHostId" - is StartRemoteCtrl -> "/start remote ctrl" - is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.fingerprint}" - is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId" - is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId" + is StoreRemoteFile -> + "/store remote file $remoteHostId " + + (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)}") + + localPath + is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" + is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" + is FindKnownRemoteCtrl -> "/find remote ctrl" + is ConfirmRemoteCtrl -> "/confirm remote ctrl $remoteCtrlId" + is VerifyRemoteCtrlSession -> "/verify remote ctrl $sessionCode" is ListRemoteCtrls -> "/list remote ctrls" is StopRemoteCtrl -> "/stop remote ctrl" is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId" @@ -2306,16 +2371,18 @@ sealed class CC { is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" is SetLocalDeviceName -> "setLocalDeviceName" - is CreateRemoteHost -> "createRemoteHost" is ListRemoteHosts -> "listRemoteHosts" is StartRemoteHost -> "startRemoteHost" + is SwitchRemoteHost -> "switchRemoteHost" is StopRemoteHost -> "stopRemoteHost" is DeleteRemoteHost -> "deleteRemoteHost" - is StartRemoteCtrl -> "startRemoteCtrl" - is RegisterRemoteCtrl -> "registerRemoteCtrl" + is StoreRemoteFile -> "storeRemoteFile" + is GetRemoteFile -> "getRemoteFile" + is ConnectRemoteCtrl -> "connectRemoteCtrl" + is FindKnownRemoteCtrl -> "FindKnownRemoteCtrl" + is ConfirmRemoteCtrl -> "confirmRemoteCtrl" + is VerifyRemoteCtrlSession -> "verifyRemoteCtrlSession" is ListRemoteCtrls -> "listRemoteCtrls" - is AcceptRemoteCtrl -> "acceptRemoteCtrl" - is RejectRemoteCtrl -> "rejectRemoteCtrl" is StopRemoteCtrl -> "stopRemoteCtrl" is DeleteRemoteCtrl -> "deleteRemoteCtrl" is ShowVersion -> "showVersion" @@ -3388,27 +3455,34 @@ data class RemoteCtrl ( val accepted: Boolean? ) -@Serializable -data class RemoteCtrlOOB ( - val fingerprint: String, - val displayName: String -) - @Serializable data class RemoteCtrlInfo ( val remoteCtrlId: Long, - val displayName: String, - val sessionActive: Boolean + val ctrlDeviceName: String, + val sessionState: RemoteCtrlSessionState? ) @Serializable -data class RemoteHostInfo ( +data class RemoteHostInfo( val remoteHostId: Long, + val hostDeviceName: String, val storePath: String, - val displayName: String, - val remoteCtrlOOB: RemoteCtrlOOB, - val sessionActive: Boolean -) + val sessionState: RemoteHostSessionState? +) { + val activeHost: Boolean + @Composable get() = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId + + fun activeHost(): Boolean = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId +} + +@Serializable +sealed class RemoteHostSessionState { + @Serializable @SerialName("starting") object Starting: RemoteHostSessionState() + @Serializable @SerialName("connecting") class Connecting(val invitation: String): RemoteHostSessionState() + @Serializable @SerialName("pendingConfirmation") class PendingConfirmation(val sessionCode: String): RemoteHostSessionState() + @Serializable @SerialName("confirmed") data class Confirmed(val sessionCode: String): RemoteHostSessionState() + @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState() +} val json = Json { prettyPrint = true @@ -3621,16 +3695,19 @@ sealed class CR { @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() // remote events (desktop) - @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() + @Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR() + @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String): CR() + @Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR() + @Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() - @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?): CR() + @Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR() // remote events (mobile) @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrl: RemoteCtrlInfo): CR() - @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR() - @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): 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(): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @@ -3767,15 +3844,18 @@ sealed class CR { is CallEnded -> "callEnded" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" - is RemoteHostCreated -> "remoteHostCreated" is RemoteHostList -> "remoteHostList" + is CurrentRemoteHost -> "currentRemoteHost" + is RemoteHostStarted -> "remoteHostStarted" + is RemoteHostSessionCode -> "remoteHostSessionCode" + is NewRemoteHost -> "newRemoteHost" is RemoteHostConnected -> "remoteHostConnected" is RemoteHostStopped -> "remoteHostStopped" + is RemoteFileStored -> "remoteFileStored" is RemoteCtrlList -> "remoteCtrlList" - is RemoteCtrlRegistered -> "remoteCtrlRegistered" - is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" is RemoteCtrlFound -> "remoteCtrlFound" is RemoteCtrlConnecting -> "remoteCtrlConnecting" + is RemoteCtrlSessionCode -> "remoteCtrlSessionCode" is RemoteCtrlConnected -> "remoteCtrlConnected" is RemoteCtrlStopped -> "remoteCtrlStopped" is VersionInfo -> "versionInfo" @@ -3912,15 +3992,29 @@ sealed class CR { is CallEnded -> withUser(user, "contact: ${contact.id}") is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) - is RemoteHostCreated -> json.encodeToString(remoteHost) + // remote events (mobile) is RemoteHostList -> json.encodeToString(remoteHosts) + is CurrentRemoteHost -> if (remoteHost_ == null) "local" else json.encodeToString(remoteHost_) + is RemoteHostStarted -> if (remoteHost_ == null) "new" else json.encodeToString(remoteHost_) + is RemoteHostSessionCode -> + "remote host: " + + (if (remoteHost_ == null) "new" else json.encodeToString(remoteHost_)) + + "\nsession code: $sessionCode" + is NewRemoteHost -> json.encodeToString(remoteHost) is RemoteHostConnected -> json.encodeToString(remoteHost) - is RemoteHostStopped -> "remote host ID: $remoteHostId" + is RemoteHostStopped -> "remote host ID: $remoteHostId_" + is RemoteFileStored -> "remote host ID: $remoteHostId\nremoteFileSource:\n" + json.encodeToString(remoteFileSource) is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlRegistered -> json.encodeToString(remoteCtrl) - is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" is RemoteCtrlFound -> json.encodeToString(remoteCtrl) - is RemoteCtrlConnecting -> json.encodeToString(remoteCtrl) + is RemoteCtrlConnecting -> + "remote ctrl: " + + (if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) + + "\nctrlAppInfo:\n${json.encodeToString(ctrlAppInfo)}" + + "\nappVersion: $appVersion" + is RemoteCtrlSessionCode -> + "remote ctrl: " + + (if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) + + "\nsessionCode: $sessionCode" is RemoteCtrlConnected -> json.encodeToString(remoteCtrl) is RemoteCtrlStopped -> noDetails() is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" + @@ -4102,6 +4196,26 @@ data class CoreVersionInfo( val simplexmqCommit: String ) +data class SomeRemoteCtrl( + val remoteCtrl_: RemoteCtrlInfo?, + val ctrlAppInfo: CtrlAppInfo, + val appVersion: String +) + +@Serializable +data class CtrlAppInfo(val appVersionRange: AppVersionRange, val deviceName: String) + +@Serializable +data class AppVersionRange(val minVersion: String, val maxVersion: String) + +@Serializable +data class RemoteFile( + val userId: Long, + val fileId: Long, + val sent: Boolean, + val fileSource: CryptoFile +) + @Serializable sealed class ChatError { val string: String get() = when (this) { @@ -4624,18 +4738,20 @@ sealed class ArchiveError { sealed class RemoteHostError { val string: String get() = when (this) { is Missing -> "missing" + is Inactive -> "inactive" is Busy -> "busy" - is Rejected -> "rejected" is Timeout -> "timeout" + is BadState -> "badState" + is BadVersion -> "badVersion" is Disconnected -> "disconnected" - is ConnectionLost -> "connectionLost" } @Serializable @SerialName("missing") object Missing: RemoteHostError() + @Serializable @SerialName("inactive") object Inactive: RemoteHostError() @Serializable @SerialName("busy") object Busy: RemoteHostError() - @Serializable @SerialName("rejected") object Rejected: RemoteHostError() @Serializable @SerialName("timeout") object Timeout: RemoteHostError() + @Serializable @SerialName("badState") object BadState: RemoteHostError() + @Serializable @SerialName("badVersion") object BadVersion: RemoteHostError() @Serializable @SerialName("disconnected") class Disconnected(val reason: String): RemoteHostError() - @Serializable @SerialName("connectionLost") class ConnectionLost(val reason: String): RemoteHostError() } @Serializable 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 2bed24b1f6..c32137ee61 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 @@ -15,6 +15,7 @@ external fun pipeStdOutToSocket(socketName: String) : Int typealias ChatCtrl = Long external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String +external fun chatSendRemoteCmd(ctrl: ChatCtrl, rhId: Int, msg: String): String external fun chatRecvMsg(ctrl: ChatCtrl): String external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 42e43f7b7a..2d526c513a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -46,6 +46,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value + val rhId = remember { chatModel.currentRemoteHost }.value?.remoteHostId val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { mutableStateOf( @@ -284,10 +285,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } }, receiveFile = { fileId, encrypted -> - withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } + withApi { chatModel.controller.receiveFile(rhId, user, fileId, encrypted) } }, cancelFile = { fileId -> - withApi { chatModel.controller.cancelFile(user, fileId) } + withApi { chatModel.controller.cancelFile(rhId, user, fileId) } }, joinGroup = { groupId, onComplete -> withApi { 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 fdf361bf69..095723a18e 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 @@ -590,7 +590,7 @@ private fun ShrinkItemAction(revealed: MutableState, showMenu: MutableS } @Composable -fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) { +fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { if (isInDarkTheme()) MenuTextColorDark else Color.Black } else color 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 7af4d2670a..7883a7a738 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 @@ -1,6 +1,5 @@ package chat.simplex.common.views.chatlist -import SectionItemView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -9,30 +8,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.SettingsViewState import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView -import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.common.platform.* -import chat.simplex.common.views.call.Call -import chat.simplex.common.views.call.CallMediaType -import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -77,7 +68,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp var searchInList by rememberSaveable { mutableStateOf("") } val scope = rememberCoroutineScope() - val (userPickerState, scaffoldState, switchingUsers ) = settingsState + val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, scaffoldState = scaffoldState, drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, @@ -113,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf ) { if (chatModel.chats.isNotEmpty()) { ChatList(chatModel, search = searchInList) - } else if (!switchingUsers.value) { + } else if (!switchingUsersAndHosts.value) { Box(Modifier.fillMaxSize()) { if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { OnboardingButtons(showNewChatSheet) @@ -129,11 +120,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsers) { + UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE } } - if (switchingUsers.value) { + if (switchingUsersAndHosts.value) { Box( Modifier.fillMaxSize().clickable(enabled = false, onClick = {}), contentAlignment = Alignment.Center @@ -224,7 +216,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { - if (users.size == 1) { + if (users.size == 1 && chatModel.remoteHosts.isEmpty()) { scope.launch { drawerState.open() } } else { userPickerState.value = AnimatedViewState.VISIBLE @@ -254,14 +246,25 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user @Composable fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { - IconButton(onClick = onButtonClicked) { - Box { - ProfileImage( - image = image, - size = 37.dp - ) - if (!allRead) { - unreadBadge() + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onButtonClicked) { + Box { + ProfileImage( + image = image, + size = 37.dp + ) + if (!allRead) { + unreadBadge() + } + } + } + if (appPlatform.isDesktop) { + val h by remember { chatModel.currentRemoteHost } + if (h != null) { + Spacer(Modifier.width(12.dp)) + HostDisconnectButton { + stopRemoteHostAndReloadHosts(h!!, true) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 8b65b2b5bc..ecd47c937a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { var searchInList by rememberSaveable { mutableStateOf("") } - val (userPickerState, scaffoldState, switchingUsers) = settingsState + val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp Scaffold( Modifier.padding(end = endPadding), @@ -47,8 +47,9 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe } } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = { + UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = { chatModel.sharedContent.value = null + userPickerState.value = AnimatedViewState.GONE }) } } @@ -72,7 +73,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState val navButton: @Composable RowScope.() -> Unit = { when { showSearch -> NavigationButtonBack(hideSearchOnBack) - users.size > 1 -> { + users.size > 1 || chatModel.remoteHosts.isNotEmpty() -> { val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 8c7dc2c605..66cac7204a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -4,10 +4,12 @@ import SectionItemView import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.Color @@ -18,12 +20,15 @@ import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.User import chat.simplex.common.platform.* +import chat.simplex.common.views.remote.connectMobileDevice import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -33,7 +38,7 @@ import kotlin.math.roundToInt fun UserPicker( chatModel: ChatModel, userPickerState: MutableStateFlow, - switchingUsers: MutableState, + switchingUsersAndHosts: MutableState, showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, @@ -53,6 +58,12 @@ fun UserPicker( .sortedByDescending { it.user.activeUser } } } + val remoteHosts by remember { + derivedStateOf { + chatModel.remoteHosts + .sortedBy { it.hostDeviceName } + } + } val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } LaunchedEffect(Unit) { launch { @@ -90,8 +101,42 @@ fun UserPicker( } catch (e: Exception) { Log.e(TAG, "Error updating users ${e.stackTraceToString()}") } + if (!appPlatform.isDesktop) return@collect + try { + val updatedHosts = chatModel.controller.listRemoteHosts()?.sortedBy { it.hostDeviceName } ?: emptyList() + if (remoteHosts != updatedHosts) { + chatModel.remoteHosts.clear() + chatModel.remoteHosts.addAll(updatedHosts) + } + } catch (e: Exception) { + Log.e(TAG, "Error updating remote hosts ${e.stackTraceToString()}") + } } } + LaunchedEffect(Unit) { + controller.reloadRemoteHosts() + } + val UsersView: @Composable ColumnScope.() -> Unit = { + users.forEach { u -> + UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) { + userPickerState.value = AnimatedViewState.HIDING + if (!u.user.activeUser) { + scope.launch { + val job = launch { + delay(500) + switchingUsersAndHosts.value = true + } + ModalManager.closeAllModalsEverywhere() + chatModel.controller.changeActiveUser(u.user.userId, null) + job.cancel() + switchingUsersAndHosts.value = false + } + } + } + Divider(Modifier.requiredHeight(1.dp)) + if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp)) + } + } val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() } val maxWidth = with(LocalDensity.current) { windowWidth() * density } Box(Modifier @@ -113,48 +158,63 @@ fun UserPicker( .background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp))) .clip(RoundedCornerShape(corner = CornerSize(25.dp))) ) { + val currentRemoteHost = remember { chatModel.currentRemoteHost }.value Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - users.forEach { u -> - UserProfilePickerItem(u.user, u.unreadCount, PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), openSettings = { - settingsClicked() - userPickerState.value = AnimatedViewState.GONE - }) { - userPickerState.value = AnimatedViewState.HIDING - if (!u.user.activeUser) { - scope.launch { - val job = launch { - delay(500) - switchingUsers.value = true - } - ModalManager.closeAllModalsEverywhere() - chatModel.controller.changeActiveUser(u.user.userId, null) - job.cancel() - switchingUsers.value = false - } + if (remoteHosts.isNotEmpty()) { + if (currentRemoteHost == null) { + LocalDevicePickerItem(true) { + userPickerState.value = AnimatedViewState.HIDING + switchToLocalDevice() } + Divider(Modifier.requiredHeight(1.dp)) + } else { + val connecting = rememberSaveable { mutableStateOf(false) } + RemoteHostPickerItem(currentRemoteHost, + actionButtonClick = { + userPickerState.value = AnimatedViewState.HIDING + stopRemoteHostAndReloadHosts(currentRemoteHost, true) + }) { + userPickerState.value = AnimatedViewState.HIDING + switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting) + } + Divider(Modifier.requiredHeight(1.dp)) + } + } + + UsersView() + + if (remoteHosts.isNotEmpty() && currentRemoteHost != null) { + LocalDevicePickerItem(false) { + userPickerState.value = AnimatedViewState.HIDING + switchToLocalDevice() + } + Divider(Modifier.requiredHeight(1.dp)) + } + remoteHosts.filter { !it.activeHost }.forEach { h -> + val connecting = rememberSaveable { mutableStateOf(false) } + RemoteHostPickerItem(h, + actionButtonClick = { + userPickerState.value = AnimatedViewState.HIDING + stopRemoteHostAndReloadHosts(h, false) + }) { + userPickerState.value = AnimatedViewState.HIDING + switchToRemoteHost(h, switchingUsersAndHosts, connecting) } Divider(Modifier.requiredHeight(1.dp)) - if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp)) } } if (showSettings) { - SettingsPickerItem { - settingsClicked() - userPickerState.value = AnimatedViewState.GONE - } + SettingsPickerItem(settingsClicked) } if (showCancel) { - CancelPickerItem { - cancelClicked() - userPickerState.value = AnimatedViewState.GONE - } + CancelPickerItem(cancelClicked) } } } } @Composable -fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) { +fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) { Row( Modifier .fillMaxWidth() @@ -166,7 +226,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues indication = if (!u.activeUser) LocalIndication.current else null ) .onRightClick { onLongClick() } - .padding(padding), + .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -219,16 +279,97 @@ fun UserProfileRow(u: User) { } } +@Composable +fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) + .sizeIn(minHeight = 46.dp) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + .onRightClick { onLongClick() } + .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + RemoteHostRow(h) + if (h.sessionState is RemoteHostSessionState.Connected) { + HostDisconnectButton(actionButtonClick) + } else { + Box(Modifier.size(20.dp)) + } + } +} + +@Composable +fun RemoteHostRow(h: RemoteHostInfo) { + Row( + Modifier + .widthIn(max = windowWidth() * 0.7f) + .padding(start = 17.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Text( + h.hostDeviceName, + modifier = Modifier.padding(start = 26.dp, end = 8.dp), + color = if (h.activeHost) MaterialTheme.colors.onBackground else if (isInDarkTheme()) MenuTextColorDark else Color.Black, + fontSize = 14.sp, + ) + } +} + +@Composable +fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) + .sizeIn(minHeight = 46.dp) + .combinedClickable( + onClick = if (active) {{}} else onClick, + onLongClick = onLongClick, + interactionSource = remember { MutableInteractionSource() }, + indication = if (!active) LocalIndication.current else null + ) + .onRightClick { onLongClick() } + .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + LocalDeviceRow(active) + Box(Modifier.size(20.dp)) + } +} + +@Composable +fun LocalDeviceRow(active: Boolean) { + Row( + Modifier + .widthIn(max = windowWidth() * 0.7f) + .padding(start = 17.dp, end = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Text( + stringResource(MR.strings.this_device), + modifier = Modifier.padding(start = 26.dp, end = 8.dp), + color = if (active) MaterialTheme.colors.onBackground else if (isInDarkTheme()) MenuTextColorDark else Color.Black, + fontSize = 14.sp, + ) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current) Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text( - text, - color = if (isInDarkTheme()) MenuTextColorDark else Color.Black, - ) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) } } @@ -238,9 +379,47 @@ private fun CancelPickerItem(onClick: () -> Unit) { val text = generalGetString(MR.strings.cancel_verb) Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text( - text, - color = if (isInDarkTheme()) MenuTextColorDark else Color.Black, + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + +@Composable +fun HostDisconnectButton(onClick: (() -> Unit)?) { + val interactionSource = remember { MutableInteractionSource() } + val hovered = interactionSource.collectIsHoveredAsState().value + IconButton(onClick ?: {}, Modifier.requiredSize(20.dp), enabled = onClick != null) { + Icon( + painterResource(if (onClick == null) MR.images.ic_desktop else if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi), + null, + Modifier.size(20.dp).hoverable(interactionSource), + tint = if (hovered && onClick != null) WarningOrange else MaterialTheme.colors.onBackground ) } } + +private fun switchToLocalDevice() { + withBGApi { + chatController.switchUIRemoteHost(null) + } +} + +private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState, connecting: MutableState) { + if (!h.activeHost()) { + withBGApi { + val job = launch { + delay(500) + switchingUsersAndHosts.value = true + } + ModalManager.closeAllModalsEverywhere() + if (h.sessionState != null) { + chatModel.controller.switchUIRemoteHost(h.remoteHostId) + } else { + connectMobileDevice(h, connecting) + } + job.cancel() + switchingUsersAndHosts.value = false + } + } else { + connectMobileDevice(h, connecting) + } +} 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 0f930b3126..703b6f905f 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 @@ -12,6 +12,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.min @Composable fun ModalView( @@ -86,7 +87,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun closeModal() { if (modalViews.isNotEmpty()) { if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) - else runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) } + else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } } modalCount.value = modalViews.size - toRemove.size } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt new file mode 100644 index 0000000000..d00b9bb67a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -0,0 +1,359 @@ +package chat.simplex.common.views.remote + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewLongClickable +import SectionTextFooter +import SectionView +import TextIconSpaced +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.* +import androidx.compose.ui.text.input.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ConnectMobileView( + m: ChatModel +) { + val connecting = rememberSaveable() { mutableStateOf(false) } + val remoteHosts = remember { chatModel.remoteHosts } + val deviceName = m.controller.appPrefs.deviceNameForRemoteAccess + LaunchedEffect(Unit) { + controller.reloadRemoteHosts() + } + ConnectMobileLayout( + deviceName = remember { deviceName.state }, + remoteHosts = remoteHosts, + connecting, + connectedHost = remember { m.currentRemoteHost }, + updateDeviceName = { + withBGApi { + if (it != "") { + m.controller.setLocalDeviceName(it) + deviceName.set(it) + } + } + }, + addMobileDevice = { showAddingMobileDevice(connecting) }, + connectMobileDevice = { connectMobileDevice(it, connecting) }, + connectDesktop = { withBGApi { chatController.switchUIRemoteHost(null) } }, + deleteHost = { host -> + withBGApi { + val success = controller.deleteRemoteHost(host.remoteHostId) + if (success) { + chatModel.remoteHosts.removeAll { it.remoteHostId == host.remoteHostId } + } + } + } + ) +} + +@Composable +fun ConnectMobileLayout( + deviceName: State, + remoteHosts: List, + connecting: MutableState, + connectedHost: State, + updateDeviceName: (String) -> Unit, + addMobileDevice: () -> Unit, + connectMobileDevice: (RemoteHostInfo) -> Unit, + connectDesktop: () -> Unit, + deleteHost: (RemoteHostInfo) -> Unit, +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles)) + SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } + SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) + SectionDividerSpaced(maxBottomPadding = false) + } + SectionView(stringResource(MR.strings.devices).uppercase()) { + SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) { + if (connectedHost.value == null) { + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } + } + + for (host in remoteHosts) { + val showMenu = rememberSaveable { mutableStateOf(false) } + SectionItemViewLongClickable({ connectMobileDevice(host) }, { showMenu.value = true }, disabled = connecting.value) { + Icon(painterResource(MR.images.ic_smartphone_300), host.hostDeviceName, tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(host.hostDeviceName) + Spacer(Modifier.weight(1f)) + if (host.activeHost) { + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } else if (host.sessionState is RemoteHostSessionState.Connected) { + HostDisconnectButton { stopRemoteHostAndReloadHosts(host, false) } + } + } + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + DefaultDropdownMenu(showMenu) { + if (host.activeHost) { + ItemAction(stringResource(MR.strings.disconnect_remote_host), painterResource(MR.images.ic_wifi_off), color = WarningOrange) { + stopRemoteHostAndReloadHosts(host, true) + showMenu.value = false + } + } else { + ItemAction(stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), color = Color.Red) { + deleteHost(host) + showMenu.value = false + } + } + } + } + } + SectionItemView(addMobileDevice) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.link_a_mobile), tint = MaterialTheme.colors.primary) + Spacer(Modifier.padding(horizontal = 10.dp)) + Text(stringResource(MR.strings.link_a_mobile), color = MaterialTheme.colors.primary) + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun DeviceNameField( + initialValue: String, + onChange: (String) -> Unit +) { + // TODO get user-defined device name + val state = remember { mutableStateOf(TextFieldValue(initialValue)) } + DefaultConfigurableTextField( + state = state, + placeholder = generalGetString(MR.strings.enter_this_device_name), + modifier = Modifier.padding(start = DEFAULT_PADDING), + isValid = { true }, + ) + KeyChangeEffect(state.value) { + onChange(state.value.text) + } +} + +@Composable +private fun ConnectMobileViewLayout( + title: String, + invitation: String?, + deviceName: String?, + sessionCode: String? +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppBarTitle(title) + SectionView { + if (invitation != null && sessionCode == null) { + QRCode( + invitation, Modifier + .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) + .aspectRatio(1f) + ) + SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code)) + + if (remember { controller.appPrefs.developerTools.state }.value) { + val clipboard = LocalClipboardManager.current + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + SectionItemView({ clipboard.shareText(invitation) }) { + Text(generalGetString(MR.strings.share_link), color = MaterialTheme.colors.primary) + } + } + + Spacer(Modifier.height(DEFAULT_PADDING)) + } + if (deviceName != null || sessionCode != null) { + SectionView(stringResource(MR.strings.connected_mobile).uppercase()) { + SelectionContainer { + Text( + deviceName ?: stringResource(MR.strings.new_mobile_device), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp, fontStyle = if (deviceName != null) FontStyle.Normal else FontStyle.Italic) + ) + } + } + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + } + + if (sessionCode != null) { + SectionView(stringResource(MR.strings.verify_code_on_mobile).uppercase()) { + SelectionContainer { + Text( + sessionCode.substring(0, 23), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp) + ) + } + } + } + } + } +} + +fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { + if (!rh.activeHost() && rh.sessionState is RemoteHostSessionState.Connected) { + withBGApi { + controller.switchUIRemoteHost(rh.remoteHostId) + } + } else if (rh.activeHost()) { + showConnectedMobileDevice(rh) { + stopRemoteHostAndReloadHosts(rh, true) + } + } else { + showConnectMobileDevice(rh, connecting) + } +} + +private fun showAddingMobileDevice(connecting: MutableState) { + ModalManager.start.showModalCloseable { close -> + val invitation = rememberSaveable { mutableStateOf(null) } + val pairing = remember { chatModel.newRemoteHostPairing } + val sessionCode = when (val state = pairing.value?.second) { + is RemoteHostSessionState.PendingConfirmation -> state.sessionCode + else -> null + } + /** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */ + var cachedSessionCode by remember { mutableStateOf(null) } + if (cachedSessionCode == null && sessionCode != null) { + cachedSessionCode = sessionCode + } + val remoteDeviceName = pairing.value?.first?.hostDeviceName + ConnectMobileViewLayout( + title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), + invitation = invitation.value, + deviceName = remoteDeviceName, + sessionCode = cachedSessionCode + ) + val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) } + LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { + if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) { + close() + } + } + KeyChangeEffect(pairing.value) { + if (pairing.value == null) { + close() + } + } + DisposableEffect(Unit) { + withBGApi { + val r = chatModel.controller.startRemoteHost(null) + if (r != null) { + connecting.value = true + invitation.value = r.second + } + } + onDispose { + if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) { + withBGApi { + chatController.stopRemoteHost(null) + } + } + chatModel.newRemoteHostPairing.value = null + } + } + } +} + +private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { + ModalManager.start.showModalCloseable { close -> + val pairing = remember { chatModel.newRemoteHostPairing } + val invitation = rememberSaveable { mutableStateOf(null) } + val sessionCode = when (val state = pairing.value?.second) { + is RemoteHostSessionState.PendingConfirmation -> state.sessionCode + else -> null + } + /** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */ + var cachedSessionCode by remember { mutableStateOf(null) } + if (cachedSessionCode == null && sessionCode != null) { + cachedSessionCode = sessionCode + } + ConnectMobileViewLayout( + title = if (cachedSessionCode == null) stringResource(MR.strings.scan_from_mobile) else stringResource(MR.strings.verify_connection), + invitation = invitation.value, + deviceName = pairing.value?.first?.hostDeviceName ?: rh.hostDeviceName, + sessionCode = cachedSessionCode, + ) + var remoteHostId by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(Unit) { + val r = chatModel.controller.startRemoteHost(rh.remoteHostId) + if (r != null) { + val (rh_, inv) = r + connecting.value = true + remoteHostId = rh_?.remoteHostId + invitation.value = inv + } + } + LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { + if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId) { + close() + } + } + KeyChangeEffect(pairing.value) { + if (pairing.value == null) { + close() + } + } + DisposableEffect(Unit) { + onDispose { + if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != remoteHostId) { + withBGApi { + chatController.stopRemoteHost(remoteHostId) + } + } + chatModel.newRemoteHostPairing.value = null + } + } + } +} + +private fun showConnectedMobileDevice(rh: RemoteHostInfo, disconnectHost: () -> Unit) { + ModalManager.start.showModalCloseable { close -> + val sessionCode = when (val state = rh.sessionState) { + is RemoteHostSessionState.Connected -> state.sessionCode + else -> null + } + Column { + ConnectMobileViewLayout( + title = stringResource(MR.strings.connected_to_mobile), + invitation = null, + deviceName = rh.hostDeviceName, + sessionCode = sessionCode + ) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + SectionItemView(disconnectHost) { + Text(generalGetString(MR.strings.disconnect_remote_host), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + KeyChangeEffect(remember { chatModel.currentRemoteHost }.value) { + 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 d949f800bb..5fa3c41478 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 @@ -29,6 +29,7 @@ import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.remote.ConnectMobileView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -155,6 +156,9 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) ChatPreferencesItem(showCustomModal, stopped = stopped) + if (appPlatform.isDesktop) { + SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, 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 170a28f3d6..9889d6ab70 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1625,6 +1625,24 @@ You can enable them later via app Privacy & Security settings. Error enabling delivery receipts! + + Link a mobile + Linked mobiles + Scan from mobile + Verify connection + Verify code on mobile + This device name + Connected mobile + Connected to mobile + Enter this device name… + The device name will be shared with the connected mobile client. + Error + This device + Devices + New mobile device + Disconnect + Use from desktop in mobile app and scan QR code]]> + Coming soon! This feature is not yet supported. Try the next release. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg new file mode 100644 index 0000000000..93094d1445 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg new file mode 100644 index 0000000000..7d8553db11 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg new file mode 100644 index 0000000000..2fb4750af5 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg new file mode 100644 index 0000000000..814077e485 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flake.nix b/flake.nix index 3ef1913fd9..14b7ae7310 100644 --- a/flake.nix +++ b/flake.nix @@ -337,6 +337,7 @@ "chat_recv_msg" "chat_recv_msg_wait" "chat_send_cmd" + "chat_send_remote_cmd" "chat_valid_name" "chat_write_file" ]; @@ -435,6 +436,7 @@ "chat_recv_msg" "chat_recv_msg_wait" "chat_send_cmd" + "chat_send_remote_cmd" "chat_valid_name" "chat_write_file" ]; diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 755119fca8..2d6e813d77 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -3,6 +3,7 @@ EXPORTS hs_init chat_migrate_init chat_send_cmd + chat_send_remote_cmd chat_recv_msg chat_recv_msg_wait chat_parse_markdown From d0f3a3d886df605ae102d29a42c67e0c99b39c92 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Nov 2023 21:53:54 +0000 Subject: [PATCH 075/174] rfc: remote UI implementation (#3206) --- docs/rfcs/2023-09-12-remote-profile.md | 25 ++++++-- docs/rfcs/2023-10-12-remote-ui.md | 88 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 docs/rfcs/2023-10-12-remote-ui.md diff --git a/docs/rfcs/2023-09-12-remote-profile.md b/docs/rfcs/2023-09-12-remote-profile.md index 9a41d55d5a..a36c1bfcf6 100644 --- a/docs/rfcs/2023-09-12-remote-profile.md +++ b/docs/rfcs/2023-09-12-remote-profile.md @@ -125,9 +125,10 @@ Alternatively a mobile (or a desktop, why not) may signal that they're done here At any time a user may click on a "cancel" button and return to the main UI. That should fully re-initialise UI state. -In the "Network & servers" section of "Settings", there is an item to list all the registered remote controllers with buttons attached to *dispose* them one by one. -*Disposing* a remote controller means its entry will be removed from database. -Future connection attempts from a disposed device would be treated exactly as from a previously-unknown device. +This screen should have a way to open the list of all known remote controllers (desktop devices), to allow removing them. + +*Removing* remote controller means its entry will be removed from database. +Future connection attempts with a removed desktop will be treated as with a previously unknown device. ### On a desktop device @@ -148,8 +149,9 @@ Future connection attempts from a disposed device would be treated exactly as fr 4. A user may open sidebar and click "disconnect from mobile" to close the session and return to local mode. * That should fully re-initialise UI state. -In the "Network & servers" section of "Settings", there is an item to list all the registered remote hosts with buttons attached to *dispose* them one by one. -*Disposing* a remote host means its entry will be removed from database and any associated files deleted (photos, voice messages, transferred files etc). +Unlike mobile UI, removing known mobiles should happen via the same screen that shows connected mobile deivices. + +*Removing* a remote host means its entry will be removed from database and any associated files deleted (photos, voice messages, transferred files etc). ## Caveats @@ -192,9 +194,20 @@ A backup system may be implemented by attaching a headless app to a bouncer as o The unauthenticated remote host can be considered a feature. A use case for that may be something like a "dead drop" host that wakes up in response to any discovery broadcast. -## Unresolved questions +## Other questions - What to do with WebRTC/calls? + +Calls use local desktop implementation, they will use host for signalling. + - Do we want attaching only to a subset of profiles? + +No. + - Do we want a client to mix remote and local profiles? + +No. + - Do we want M-to-N sessions? (follows naturally from the previous two) + +No. diff --git a/docs/rfcs/2023-10-12-remote-ui.md b/docs/rfcs/2023-10-12-remote-ui.md new file mode 100644 index 0000000000..308babf5cd --- /dev/null +++ b/docs/rfcs/2023-10-12-remote-ui.md @@ -0,0 +1,88 @@ +# Remote desktop / mobile implementation details + +This follows the previous RFC for [remote profiles](./2023-09-12-remote-profile.md). + +Code uses terms remote controller and remote host to designate the roles, and CLI client can support both. + +This document uses the terms "mobile" to mean remote host and "desktop" to mean remote controller, mobile apps will only support "remote host" role (via UI, and, possibly, via the compilation flag to remove this functionality from the code), and desktop will only support "remote controller" role. + +## Mobile (host) + +UX is described [here](./2023-09-12-remote-profile.md#on-a-mobile-device). + +When mobile is connected to the remote desktop it will receive no events from the core (other than remote session connection status changes), and the core will accept no commands from the UI (other than to terminate the remote session). + +As currently mobile maintains the list of connection statuses for all profiles, this state will have to be maintained in Haskell to be able to send it to the remote desktop UI when it is connected. It will also be used to update mobile UI when control is returned to the mobile. + +The switching between remote host role and local UI role should prevent the possibility of any race conditions. + +To swith to remote host role: +- UI: will show the screen with "connected" status and "cancel" button, with disabled sleep timer on iOS, as iOS app will have to stay in foregro. Android will be able to function in background in this state. +- core: stop adding events to the output queues (via some flag). +- UI: process remaining events in the output queue and stop receiving them. +- core: stop sending events to and accepting commands from UI. +- core: send current list of profiles, coversations, and connections statuses to the remote desktop. +- core: start sending events to and accepting commands from remote desktop. +- core: start adding events to remote output queue. + +To switch back to local UI role: +- core: stop adding events to the output queues. +- core: stop receiving commands from and sending events to remote desktop. +- remote desktop: receive pending events and stop processing them. +- UI: load current list of profiles, conversations, and connection statuses from the core. +- UI: start receiving events +- core: start sending events to and accepting commands from local UI. +- core: start adding events to UI local remote output queue. + +Possibly, there is a simpler way to switch, but it is important that the new events are applied to the loaded state, to avoid state being out of sync. + +## Desktop (controller) + +Desktop can either control local profiles ("local host" term is used) or remote host(s)/mobile(s). Only one host, local or remote, can be controlled at a time. It is important though to be able to receive notifications from multiple hosts, at the very least from local and mobile, as the important use case is this: + +- mobile device only has contacts and important groups to save the traffic and battery. +- desktop has large public groups. + +So while reading large public groups the users should be able to receive notifications from both mobile device and local profile(s). + +That means that while only one host can be active in desktop, multiple hosts can be connected. + +Current UI model contains: +- the list of the conversations for the chosen profile. +- the list of the user profiles. +- the statuses of connections across all profiles - this is maintained because the core does not track connection statuses. + +As the core will start maintaining the connection statuses, as a possible optimisation we could reduce the connections in the UI to only the current profile and reset it every time the profile is switched. + +In addition to the above, the UI model will need to contain the list of connected remote hosts (mobiles), so that the user can switch between them. + +Switching profile currently updates the list of conversations. If connection statuses are narrowed to the current profile only, they will have to be updated as well. + +When switching host (whether to local or to remote), the UI will have to: +- update the list of profiles +- update the list of conversations for the active profile in the host +- update connection statuses, either for all profiles or for the active profile only - TBC + +When connected to remote host, or possibly always, UI will have to use the extended FFI to indicate the host in all commands (e.g., to allow automatic file reception in inactive hosts) - as the core cannot simply assume which host is active. Probably, some of the commands (most of them) should require the host to match the currently active host in the core, file reception will not require that. + +### Onboarding and "only-remote" desktop + +Majority of the users want to use desktop in "remote-only" role, when there is no local profile. Currently, it is a global assumption that the core has an active profile for most commands. Possible solutions to that are: + +- update core to allow functioning without local profile. +- create an invisible phantom profile that won't be shown in the UI but will be used by the core to process commands related to remote hosts (connected mobiles). + +The 2nd option seems simpler. The phantom profile will use an empty string as display name, and, possibly an additional flag to mark it as phantom. Once a real profile is created this phatom one can be removed (or can be just abandoned). It might be better to block most commands for this phantom profile? + +Onboarding on desktop will need to be re-implemented to offer connecting to mobile as primary option and creating a local profile as secondary, and from the users' point of view they will not be creating any local profiles. + +## Loading files + +Currently active UI, either remote (desktop) or local (mobile), will be making the decision to auto-receive file, based on its own local settings. It seems a better trade-off than alternatives, and also allows for different auto-receive configurations based on the device. + +Forwarding received and uploading sent files to/from desktop is already implemented. + +It is still required to implement both commands API in FFI layer and, possibly, HTTP API to download files to desktop when they are: +- shown in the UI for images. +- played for voice and video messages. +- saved for any other files. From 0d7a048988bc05db6a4fba1f7f96bce35bf6d7d5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Nov 2023 22:54:54 +0000 Subject: [PATCH 076/174] nix: patches for armv7a, fix segfault issues, etc (#3383) * bump mobile-core-tools * Update deps * :facepalm: * patch * needs p * 32bit patches * bump haskell.nix * bump again * fix broken flake.lock * bump haskell.nix * bump haskell.nix (to fix darwin) --------- Co-authored-by: Moritz Angermann --- flake.lock | 24 ++++++++++++------------ flake.nix | 25 +++++++++++++++++++++---- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/flake.lock b/flake.lock index 5f2cde2bdd..c482f7ca9b 100644 --- a/flake.lock +++ b/flake.lock @@ -190,11 +190,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1698020746, - "narHash": "sha256-/eFVtqLu4sGfjJiwmelfH2qK0ZGFOmyCxBTJ1ncOeSI=", + "lastModified": 1699834964, + "narHash": "sha256-733KT+G0c1euCeb60/u1qbX22Kvu9lNnIDfAmk6Jxq0=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "dc3cf3bbc0b1e9780e008f9cd6af4a9aeb344ad7", + "rev": "2e891e530400187ea1083ffef15adf259061be41", "type": "github" }, "original": { @@ -240,11 +240,11 @@ "stackage": "stackage" }, "locked": { - "lastModified": 1698666250, - "narHash": "sha256-nt8n0N//L7A6T9tgPVAp0Z2JurVDh/DveN2pAIsvIMI=", + "lastModified": 1700119633, + "narHash": "sha256-nZY2eIo8TkRbXgJXEWMm9zor330GuUtcNzvUN9tN64U=", "owner": "input-output-hk", "repo": "haskell.nix", - "rev": "cc58b1a8e0faeb1a4968a91affe0bcc21632f138", + "rev": "1fe47a3d52e1ecd6247c8ab83811a21de2e2f074", "type": "github" }, "original": { @@ -417,11 +417,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1698662474, - "narHash": "sha256-VoMvl+N1K8LuWO8rjlm6ko//1/l35InxFTrtNSKRc20=", + "lastModified": 1699767871, + "narHash": "sha256-kxeCUfwC/Vgh2FvVMlBUq0eVx1JvfHyN+5MPKUik9mE=", "owner": "zw3rk", "repo": "mobile-core-tools", - "rev": "00d48cc5a929af4b047604805dd8aba9933b37e4", + "rev": "4dcb77d5ea896d749381806dfab5358851b08951", "type": "github" }, "original": { @@ -661,11 +661,11 @@ "stackage": { "flake": false, "locked": { - "lastModified": 1698278970, - "narHash": "sha256-CogUKaZr3d4cJTeqk/u04Ct2fg1EHBCuN3SrWbNYsuM=", + "lastModified": 1699834215, + "narHash": "sha256-g/JKy0BCvJaxPuYDl3QVc4OY8cFEomgG+hW/eEV470M=", "owner": "input-output-hk", "repo": "stackage.nix", - "rev": "d0599a4c08237202eb560aa2d2625b5daa818cc6", + "rev": "47aacd04abcce6bad57f43cbbbd133538380248e", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 3ef1913fd9..b56756922f 100644 --- a/flake.nix +++ b/flake.nix @@ -127,8 +127,22 @@ hardeningDisable = [ "fortify" ]; } );in { + # STATIC x86_64-linux "${pkgs.pkgsCross.musl64.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.musl64).simplex-chat.components.exes.simplex-chat; - "${pkgs.pkgsCross.musl32.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.musl32).simplex-chat.components.exes.simplex-chat; + # STATIC i686-linux + "${pkgs.pkgsCross.musl32.hostPlatform.system}-static:exe:simplex-chat" = (drv' { + pkgs' = pkgs.pkgsCross.musl32; + extra-modules = [{ + # 32 bit patches + packages.basement.patches = [ + ./scripts/nix/basement-pr-573.patch + ]; + packages.memory.patches = [ + ./scripts/nix/memory-pr-99.patch + ]; + }]; + }).simplex-chat.components.exes.simplex-chat; + # WINDOWS x86_64-mingwW64 "${pkgs.pkgsCross.mingwW64.hostPlatform.system}:exe:simplex-chat" = (drv' { pkgs' = pkgs.pkgsCross.mingwW64; extra-modules = [{ @@ -229,15 +243,17 @@ ''; }); # "${pkgs.pkgsCross.muslpi.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.muslpi).simplex-chat.components.exes.simplex-chat; + + # STATIC aarch64-linux "${pkgs.pkgsCross.aarch64-multiplatform-musl.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.aarch64-multiplatform-musl).simplex-chat.components.exes.simplex-chat; - "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override { + "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override (p: { smallAddressSpace = true; # we won't want -dyamic (see aarch64-android:lib:simplex-chat) enableShared = false; # we also do not want to have any dependencies listed (especially no rts!) enableStatic = false; - setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; + setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; postInstall = '' mkdir -p $out/_pkg @@ -250,7 +266,7 @@ echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }; + }); # The android-support package is at # https://github.com/simplex-chat/android-support "aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override (p: { @@ -293,6 +309,7 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + # 32 bit patches packages.basement.patches = [ ./scripts/nix/basement-pr-573.patch ]; From cf102da4d35cda4516ad42e5ca37482031647446 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:19:33 +0200 Subject: [PATCH 077/174] remote: add test for rejected ca detection and stability (#3382) * add test for rejected ca detection and stability * update mq commit --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- tests/RemoteTests.hs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index b7baee588d..642f3d1021 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3b348a463cd83fbd803743b1d67f282a42d8b654 + tag: c501f4f9ccdd48807a5153697ea1827129841158 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 5f65742887..c211fc99e6 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3b348a463cd83fbd803743b1d67f282a42d8b654" = "1rbd5zz1rclnfvjf68ll5qhi9yqk040bi491z10hwyhxi2bixpaw"; + "https://github.com/simplex-chat/simplexmq.git"."c501f4f9ccdd48807a5153697ea1827129841158" = "1s99mjc7rjk9wg14m5xddw64a3mlr8l7ba9mclma598hg73l0vaw"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/stack.yaml b/stack.yaml index 4ef1c422ec..f3d98d7f65 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 3b348a463cd83fbd803743b1d67f282a42d8b654 + commit: c501f4f9ccdd48807a5153697ea1827129841158 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 0d3dc74627..db19cac519 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -36,6 +36,7 @@ remoteTests = describe "Remote" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True it "connects with stored pairing" remoteHandshakeStoredTest + it "refuses invalid client cert" remoteHandshakeRejectTest it "sends messages" remoteMessageTest describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest @@ -95,6 +96,36 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile startRemoteStored mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) +remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () +remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do + logNote "Starting new session" + startRemote mobile desktop + stopMobile mobile desktop + + mobileBob ##> "/set device name MobileBob" + mobileBob <## "ok" + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + mobileBob ##> ("/connect remote ctrl " <> inv) + mobileBob <## "connecting new remote controller: My desktop, v5.4.0.3" + mobileBob <## "remote controller stopped" + + -- the server remains active after rejecting invalid client + mobile ##> ("/connect remote ctrl " <> inv) + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + desktop <## "remote host 1 connecting" + desktop <## "Compare session code with host:" + sessId <- getTermLine desktop + mobile <## "remote controller 1 connected" + mobile <## "Compare session code with controller and use:" + mobile <## ("/verify remote ctrl " <> sessId) + mobile ##> ("/verify remote ctrl " <> sessId) + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + stopMobile mobile desktop + remoteMessageTest :: HasCallStack => FilePath -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop From f6c4e969e4d2ae6a59c50b93ed666bf1e4e4660c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:28:10 +0000 Subject: [PATCH 078/174] nix: add openssl to simplexmq, swift flag to simplex-chat (#3386) * nix: add swift flag * add openssl for simplexmq to nix * add openssl to android simplemq, try iOS with enableKTLS = false flag * fix android --- flake.nix | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/flake.nix b/flake.nix index e14e57ca75..4466fa110e 100644 --- a/flake.nix +++ b/flake.nix @@ -154,6 +154,9 @@ packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ (pkgs.pkgsCross.mingwW64.openssl) #.override) # { static = true; enableKTLS = false; }) ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.pkgsCross.mingwW64.openssl) #.override) # { static = true; enableKTLS = false; }) + ]; packages.unix-time.postPatch = '' sed -i 's/mingwex//g' unix-time.cabal ''; @@ -185,6 +188,9 @@ packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ pkgs.pkgsCross.mingwW64.openssl ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + pkgs.pkgsCross.mingwW64.openssl + ]; packages.unix-time.postPatch = '' sed -i 's/mingwex//g' unix-time.cabal ''; @@ -309,6 +315,9 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) + ]; # 32 bit patches packages.basement.patches = [ ./scripts/nix/basement-pr-573.patch @@ -413,6 +422,9 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (androidPkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override (p: { smallAddressSpace = true; @@ -509,9 +521,13 @@ "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ + packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-swift-json" @@ -522,6 +538,9 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-tagged-json" @@ -532,9 +551,13 @@ "x86_64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ + packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-swift-json" @@ -545,6 +568,9 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-tagged-json" From 84e09f195c710d366d29d8ecb13930b37b5b7ecf Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 18 Nov 2023 02:19:02 +0800 Subject: [PATCH 079/174] desktop (windows): fix build of CLI (#3387) --- .github/workflows/build.yml | 9 +++++++++ scripts/desktop/build-lib-windows.sh | 13 +++---------- scripts/desktop/prepare-openssl-windows.sh | 21 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 scripts/desktop/prepare-openssl-windows.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6687f47977..592e03257b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -266,6 +266,15 @@ jobs: if: matrix.os == 'windows-latest' shell: bash run: | + scripts/desktop/prepare-openssl-windows.sh + openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') + rm cabal.project.local 2>/dev/null || true + echo "ignore-project: False" >> cabal.project.local + echo "package direct-sqlcipher" >> cabal.project.local + echo " flags: +openssl" >> cabal.project.local + echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local + echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local + rm -rf dist-newstyle/src/direct-sq* sed -i "s/, unix /--, unix /" simplex-chat.cabal cabal build --enable-tests diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 881e0aea2a..bd2cdc1c23 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -30,16 +30,9 @@ BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* cd $root_dir mkdir dist-newstyle 2>/dev/null || true -if [ ! -f dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll ]; then - cd dist-newstyle - curl https://www.openssl.org/source/openssl-1.1.1w.tar.gz -o openssl.tar.gz - $WINDIR\\System32\\tar.exe -xvzf openssl.tar.gz - cd openssl-1.1.1w - ./Configure mingw64 - make - cd ../../ -fi -openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') +scripts/desktop/prepare-openssl-windows.sh + +openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') rm -rf $BUILD_DIR 2>/dev/null || true # Existence of this directory produces build error: cabal's bug rm -rf dist-newstyle/src/direct-sq* 2>/dev/null || true diff --git a/scripts/desktop/prepare-openssl-windows.sh b/scripts/desktop/prepare-openssl-windows.sh new file mode 100644 index 0000000000..79822d3ff5 --- /dev/null +++ b/scripts/desktop/prepare-openssl-windows.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" + +cd $root_dir + +if [ ! -f dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll ]; then + mkdir dist-newstyle 2>/dev/null || true + cd dist-newstyle + curl https://www.openssl.org/source/openssl-1.1.1w.tar.gz -o openssl.tar.gz + $WINDIR\\System32\\tar.exe -xvzf openssl.tar.gz + cd openssl-1.1.1w + ./Configure mingw64 + make + cd ../../ +fi From 79064e149a0191ddf49e11c1209f33dfb48500e0 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 18 Nov 2023 02:19:38 +0800 Subject: [PATCH 080/174] desktop: enabled smooth scrolling again (#3388) --- .../src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 6b81209d4c..2931e0e014 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -29,10 +29,6 @@ import java.io.File val simplexWindowState = SimplexWindowState() fun showApp() = application { - // TODO: remove after update to compose 1.5.0+ - // See: https://github.com/JetBrains/compose-multiplatform/issues/3366#issuecomment-1643799976 - System.setProperty("compose.scrolling.smooth.enabled", "false") - // For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366, // it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp From 42e040001461c11ccdcd283c7c91b3bfc755927d Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 17 Nov 2023 20:50:38 +0200 Subject: [PATCH 081/174] core: add remote controller discovery with multicast (#3369) * draft multicast chat api * prepare tests * Plug discovery into chat api * Add discovery timeout * post-merge fixes * rename discovery state to match others * update for unified invitation * fix review notices * rename, remove stack, update simplexmq --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 6 ++- src/Simplex/Chat/Controller.hs | 10 +++- src/Simplex/Chat/Remote.hs | 91 ++++++++++++++++++++++------------ src/Simplex/Chat/View.hs | 1 + stack.yaml | 2 +- tests/RemoteTests.hs | 46 ++++++++++++++--- 8 files changed, 115 insertions(+), 45 deletions(-) diff --git a/cabal.project b/cabal.project index 642f3d1021..4583237971 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c501f4f9ccdd48807a5153697ea1827129841158 + tag: 40ba94ce72fb4273641c56fd4c60cd133a24925a source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index c211fc99e6..0ccb77a743 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c501f4f9ccdd48807a5153697ea1827129841158" = "1s99mjc7rjk9wg14m5xddw64a3mlr8l7ba9mclma598hg73l0vaw"; + "https://github.com/simplex-chat/simplexmq.git"."40ba94ce72fb4273641c56fd4c60cd133a24925a" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e2439f69d6..feda221a0c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1967,10 +1967,12 @@ processChatCommand = \case StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ ConnectRemoteCtrl inv -> withUser_ $ do - (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrl inv + (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrlURI inv pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ - ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ + ConfirmRemoteCtrl rcId -> withUser_ $ do + (rc, ctrlAppInfo) <- confirmRemoteCtrl rcId + pure CRRemoteCtrlConnecting {remoteCtrl_ = Just rc, ctrlAppInfo, appVersion = currentAppVersion} VerifyRemoteCtrlSession sessId -> withUser_ $ CRRemoteCtrlConnected <$> verifyRemoteCtrlSession (execChatCommand Nothing) sessId StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 68d909f784..2ff9b078c6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -75,7 +75,7 @@ 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) +import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) @@ -1061,6 +1061,8 @@ data RemoteCtrlError | RCEBadState -- ^ A session is in a wrong state for the current operation | RCEBusy -- ^ A session is already running | RCETimeout + | RCENoKnownControllers -- ^ No previously-contacted controllers to discover + | RCEBadController -- ^ Attempting to confirm a found controller with another ID | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller | RCEBadInvitation | RCEBadVersion {appVersion :: AppVersion} @@ -1076,6 +1078,10 @@ data ArchiveError -- | Host (mobile) side of transport to process remote commands and forward notifications data RemoteCtrlSession = RCSessionStarting + | RCSessionSearching + { action :: Async (), + foundCtrl :: TMVar (RemoteCtrl, RCVerifiedInvitation) + } | RCSessionConnecting { remoteCtrlId_ :: Maybe RemoteCtrlId, rcsClient :: RCCtrlClient, @@ -1101,6 +1107,7 @@ data RemoteCtrlSession data RemoteCtrlSessionState = RCSStarting + | RCSSearching | RCSConnecting | RCSPendingConfirmation {sessionCode :: Text} | RCSConnected {sessionCode :: Text} @@ -1109,6 +1116,7 @@ data RemoteCtrlSessionState rcsSessionState :: RemoteCtrlSession -> RemoteCtrlSessionState rcsSessionState = \case RCSessionStarting -> RCSStarting + RCSessionSearching {} -> RCSSearching RCSessionConnecting {} -> RCSConnecting RCSessionPendingConfirmation {tls} -> RCSPendingConfirmation {sessionCode = tlsSessionCode tls} RCSessionConnected {tls} -> RCSConnected {sessionCode = tlsSessionCode tls} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 331e3348a4..f1ff0cada7 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -8,7 +8,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} -{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Remote where @@ -28,12 +27,13 @@ import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) +import Data.List.NonEmpty (nonEmpty) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Word (Word16, Word32) +import Data.Word (Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC @@ -54,18 +54,16 @@ import Simplex.Chat.Util (encryptFile) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP)) -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.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) -import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util import Simplex.RemoteControl.Client -import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..)) +import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..), RCVerifiedInvitation (..), verifySignedInvitation) import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO @@ -92,6 +90,9 @@ hostAppVersionRange = mkAppVersionRange minRemoteCtrlVersion currentAppVersion networkIOTimeout :: Int networkIOTimeout = 15000000 +discoveryTimeout :: Int +discoveryTimeout = 60000000 + -- * Desktop side getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient @@ -342,19 +343,61 @@ liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) -- * Mobile side -findKnownRemoteCtrl :: ChatMonad m => m () -findKnownRemoteCtrl = undefined -- do +-- ** QR/link -- | Use provided OOB link as an annouce -connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {ca, app}} = handleCtrlError "connectRemoteCtrl" $ do - (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app +connectRemoteCtrlURI :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrlURI signedInv = handleCtrlError "connectRemoteCtrl" $ do + verifiedInv <- maybe (throwError $ ChatErrorRemoteCtrl RCEBadInvitation) pure $ verifySignedInvitation signedInv withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) + connectRemoteCtrl verifiedInv + +-- ** Multicast + +findKnownRemoteCtrl :: ChatMonad m => m () +findKnownRemoteCtrl = handleCtrlError "findKnownRemoteCtrl" $ do + knownCtrls <- withStore' getRemoteCtrls + pairings <- case nonEmpty knownCtrls of + Nothing -> throwError $ ChatErrorRemoteCtrl RCENoKnownControllers + Just ne -> pure $ fmap (\RemoteCtrl {ctrlPairing} -> ctrlPairing) ne + withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) + foundCtrl <- newEmptyTMVarIO + cmdOk <- newEmptyTMVarIO + action <- async $ handleCtrlError "findKnownRemoteCtrl.discover" $ do + atomically $ takeTMVar cmdOk + (RCCtrlPairing {ctrlFingerprint}, inv) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings + rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case + Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" + Just rc -> pure rc + atomically $ putTMVar foundCtrl (rc, inv) + toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching)} + withRemoteCtrlSession $ \case + RCSessionStarting -> Right ((), RCSessionSearching {action, foundCtrl}) + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + atomically $ putTMVar cmdOk () + +confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m (RemoteCtrlInfo, CtrlAppInfo) +confirmRemoteCtrl rcId = do + (listener, found) <- withRemoteCtrlSession $ \case + RCSessionSearching {action, foundCtrl} -> Right ((action, foundCtrl), RCSessionStarting) -- drop intermediate "Searching" state so connectRemoteCtrl can proceed + _ -> throwError $ ChatErrorRemoteCtrl RCEBadState + uninterruptibleCancel listener + (RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found + unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController + connectRemoteCtrl verifiedInv >>= \case + (Nothing, _) -> throwChatError $ CEInternalError "connecting with a stored ctrl" + (Just rci, appInfo) -> pure (rci, appInfo) + +-- ** Common + +connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) = handleCtrlError "connectRemoteCtrl" $ do + (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v (rcsClient, vars) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . withAgent $ \a -> - rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + rcConnectCtrl a verifiedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk @@ -420,9 +463,6 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque attach send flush -timeoutThrow :: (MonadUnliftIO m, MonadError e m) => e -> Int -> m a -> m a -timeoutThrow e ms action = timeout ms action >>= maybe (throwError e) pure - takeRCStep :: ChatMonad m => RCStepTMVar a -> m a takeRCStep = liftEitherError (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar @@ -482,10 +522,6 @@ handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileI encFile <- liftRC $ prepareEncryptedFile encryption (h, fileSize) reply RRFile {fileSize, fileDigest} $ sendEncryptedFile encFile -discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> m () -discoverRemoteCtrls discovered = do - error "TODO: discoverRemoteCtrls" - listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do session <- chatReadVar remoteCtrlSession @@ -506,15 +542,6 @@ remoteCtrlInfo :: RemoteCtrl -> Maybe RemoteCtrlSessionState -> RemoteCtrlInfo remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} --- XXX: only used for multicast -confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () -confirmRemoteCtrl _rcId = do - -- TODO check it exists, check the ID is the same as in session - -- RemoteCtrlSession {confirmed} <- getRemoteCtrlSession - -- withStore' $ \db -> markRemoteCtrlResolution db rcId True - -- atomically . void $ tryPutTMVar confirmed rcId -- the remote host can now proceed with connection - undefined - -- | Take a look at emoji of tlsunique, commit pairing, and start session server verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do @@ -555,10 +582,11 @@ stopRemoteCtrl :: ChatMonad m => m () stopRemoteCtrl = cancelActiveRemoteCtrl False handleCtrlError :: ChatMonad m => Text -> m a -> m a -handleCtrlError name action = action `catchChatError` \e -> do - logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl True - throwError e +handleCtrlError name action = + action `catchChatError` \e -> do + logError $ name <> " remote ctrl error: " <> tshow e + cancelActiveRemoteCtrl True + throwError e cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do @@ -570,6 +598,7 @@ cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case RCSessionStarting -> pure () + RCSessionSearching {action} -> uninterruptibleCancel action RCSessionConnecting {rcsClient, rcsWaitSession} -> do unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index a6843de601..3060723a1d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1724,6 +1724,7 @@ viewRemoteCtrls = \case plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> maybe "" viewSessionState sessionState viewSessionState = \case RCSStarting -> " (starting)" + RCSSearching -> " (searching)" RCSConnecting -> " (connecting)" RCSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" RCSConnected _ -> " (connected)" diff --git a/stack.yaml b/stack.yaml index f3d98d7f65..fd831e8100 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: c501f4f9ccdd48807a5153697ea1827129841158 + commit: 40ba94ce72fb4273641c56fd4c60cd133a24925a - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index db19cac519..dc2f890a7f 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -36,6 +36,7 @@ remoteTests = describe "Remote" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True it "connects with stored pairing" remoteHandshakeStoredTest + it "connects with multicast discovery" remoteHandshakeDiscoverTest it "refuses invalid client cert" remoteHandshakeRejectTest it "sends messages" remoteMessageTest describe "remote files" $ do @@ -96,6 +97,16 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile startRemoteStored mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) +remoteHandshakeDiscoverTest :: HasCallStack => FilePath -> IO () +remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + logNote "Preparing new session" + startRemote mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + + logNote "Starting stored session with multicast" + startRemoteDiscover mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do logNote "Starting new session" @@ -420,12 +431,8 @@ startRemote mobile desktop = do mobile ##> ("/connect remote ctrl " <> inv) mobile <## "connecting new remote controller: My desktop, v5.4.0.3" desktop <## "new remote host connecting" - desktop <## "Compare session code with host:" - sessId <- getTermLine desktop mobile <## "new remote controller connected" - mobile <## "Compare session code with controller and use:" - mobile <## ("/verify remote ctrl " <> sessId) - mobile ##> ("/verify remote ctrl " <> sessId) + verifyRemoteCtrl mobile desktop mobile <## "remote controller 1 session started with My desktop" desktop <## "new remote host 1 added: Mobile" desktop <## "remote host 1 connected" @@ -439,14 +446,37 @@ startRemoteStored mobile desktop = do mobile ##> ("/connect remote ctrl " <> inv) mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" desktop <## "remote host 1 connecting" + mobile <## "remote controller 1 connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + +startRemoteDiscover :: TestCC -> TestCC -> IO () +startRemoteDiscover mobile desktop = do + desktop ##> "/start remote host 1 multicast=on" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + _inv <- getTermLine desktop -- will use multicast instead + mobile ##> "/find remote ctrl" + mobile <## "ok" + mobile <## "remote controller found:" + mobile <## "1. My desktop" + mobile ##> "/confirm remote ctrl 1" + + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + desktop <## "remote host 1 connecting" + mobile <## "remote controller 1 connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + +verifyRemoteCtrl :: TestCC -> TestCC -> IO () +verifyRemoteCtrl mobile desktop = do desktop <## "Compare session code with host:" sessId <- getTermLine desktop - mobile <## "remote controller 1 connected" mobile <## "Compare session code with controller and use:" mobile <## ("/verify remote ctrl " <> sessId) mobile ##> ("/verify remote ctrl " <> sessId) - mobile <## "remote controller 1 session started with My desktop" - desktop <## "remote host 1 connected" contactBob :: TestCC -> TestCC -> IO () contactBob desktop bob = do From c9a1de6e4b20f3aa0898942b0f32c4d0acdbf8d7 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 18 Nov 2023 03:20:44 +0800 Subject: [PATCH 082/174] msys2 setup in different place (#3389) --- .github/workflows/build.yml | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 592e03257b..600b934bd5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -261,11 +261,27 @@ jobs: # / Windows # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing + - name: 'Setup MSYS2' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: msys2/setup-msys2@v2 + with: + msystem: ucrt64 + update: true + install: >- + git + perl + make + pacboy: >- + toolchain:p + cmake:p + + - name: Windows build id: windows_build if: matrix.os == 'windows-latest' - shell: bash + shell: msys2 {0} run: | + export PATH=$PATH:/c/ghcup/bin scripts/desktop/prepare-openssl-windows.sh openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') rm cabal.project.local 2>/dev/null || true @@ -302,20 +318,6 @@ jobs: body: | ${{ steps.windows_build.outputs.bin_hash }} - - name: 'Setup MSYS2' - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: msys2/setup-msys2@v2 - with: - msystem: ucrt64 - update: true - install: >- - git - perl - make - pacboy: >- - toolchain:p - cmake:p - - name: Windows build desktop id: windows_desktop_build if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' From 8f6a31ca0727dbb896a54e791781ffdce2903931 Mon Sep 17 00:00:00 2001 From: qvsojBJGiEnR <138215483+qvsojBJGiEnR@users.noreply.github.com> Date: Fri, 17 Nov 2023 23:29:25 +0000 Subject: [PATCH 083/174] Update app-settings.md (#3379) --- docs/guide/app-settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/app-settings.md b/docs/guide/app-settings.md index 26817a20f2..5482a1297f 100644 --- a/docs/guide/app-settings.md +++ b/docs/guide/app-settings.md @@ -8,7 +8,7 @@ title: App settings To open app settings: - Open the app. -- Tap on your user profile image in the upper right-hand of the screen. +- Tap on your user profile image in the upper left-hand of the screen. - If you have more than one profile, tap the current profile again or choose Settings. ## Your profile settings From 80abc18371feac57a68a8778390008019ce3ca16 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 15:35:06 +0000 Subject: [PATCH 084/174] core: update simplexmq (xrcp) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 4583237971..4652ee3112 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 40ba94ce72fb4273641c56fd4c60cd133a24925a + tag: 08410671323c056bbcf1a3f2756aad810b522e25 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0ccb77a743..de10eb829a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."40ba94ce72fb4273641c56fd4c60cd133a24925a" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; + "https://github.com/simplex-chat/simplexmq.git"."08410671323c056bbcf1a3f2756aad810b522e25" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/stack.yaml b/stack.yaml index fd831e8100..3006b8a61d 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 40ba94ce72fb4273641c56fd4c60cd133a24925a + commit: 08410671323c056bbcf1a3f2756aad810b522e25 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From c0e8740f5079a9a7b713b3519fbb69d840f16ca0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 18 Nov 2023 21:52:01 +0400 Subject: [PATCH 085/174] core: group message forwarding (#3360) * core: group message forwarding types * xgrpmemcon * rework xgrpmemcon to use intros table * only forward w/t error * forward msg * xGrpMsgForward, check integrity outside * deduplicate group messages * test * change error * item forwarded flag * intro_chat_protocol_version, bump version * comment * highly available client option * more comments * notify xgrpmemcon on deduplication * member vrange * encoding * remove MsgForward * remove import * exclude files from forwarding * refactor * rename to align with protocol * forward more message types * add events * remove unused error, function * add x.file.cancel, x.info and x.grp.mem.new to forwarded messages * remove unused x.msg.file.cancel --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 519 ++++++++++++------ src/Simplex/Chat/Controller.hs | 3 +- src/Simplex/Chat/Messages.hs | 13 +- .../Migrations/M20231113_group_forward.hs | 53 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 32 +- src/Simplex/Chat/Mobile.hs | 3 +- src/Simplex/Chat/Options.hs | 11 +- src/Simplex/Chat/Protocol.hs | 57 +- src/Simplex/Chat/Store/Connections.hs | 8 +- src/Simplex/Chat/Store/Groups.hs | 241 +++++--- src/Simplex/Chat/Store/Messages.hs | 139 +++-- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 12 + src/Simplex/Chat/Types.hs | 21 +- src/Simplex/Chat/View.hs | 7 +- tests/ChatClient.hs | 3 +- tests/ChatTests/Groups.hs | 134 ++++- tests/ProtocolTests.hs | 14 +- 19 files changed, 936 insertions(+), 339 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231113_group_forward.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 622226a0c2..d379203ca3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -120,6 +120,7 @@ library Simplex.Chat.Migrations.M20231019_indexes Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Migrations.M20231107_indexes + Simplex.Chat.Migrations.M20231113_group_forward Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 12936b325a..524fae2bd8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -32,6 +32,7 @@ import Data.Bifunctor (bimap, first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) @@ -144,7 +145,8 @@ defaultChatConfig = cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes - coreApi = False + coreApi = False, + highlyAvailable = False } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -188,9 +190,9 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} firstTime = dbNew chatStore currentUser <- newTVarIO user servers <- agentServers config @@ -1571,7 +1573,7 @@ processChatCommand = \case gVar <- asks idsDrg subMode <- chatReadVar subscriptionMode (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode - member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq subMode + member <- withStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} @@ -3227,7 +3229,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MSG meta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId meta $ do - (_conn', _) <- saveRcvMSG conn (ConnectionId connId) meta msgBody cmdId + (_conn', _) <- saveDirectRcvMSG conn meta cmdId msgBody pure False SENT msgId -> sentMsgDeliveryEvent conn msgId @@ -3258,14 +3260,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do - (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId + (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 msgMeta - XMsgFileCancel sharedMsgId -> cancelMessageFile ct' sharedMsgId msgMeta 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 @@ -3342,10 +3343,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) forM_ groupId_ $ \groupId -> do + groupInfo <- withStore $ \db -> getGroupInfo db user groupId subMode <- chatReadVar subscriptionMode - gVar <- asks idsDrg groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode - withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode + gVar <- asks idsDrg + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode _ -> pure () Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -3515,62 +3517,118 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> updateIntroStatus db introId GMIntroSent _ -> do -- TODO notify member who forwarded introduction - question - where it is stored? There is via_contact but probably there should be via_member in group_members table + let memCategory = memberCategory m withStore' (\db -> getViaGroupContact db user m) >>= \case Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memberCategory m == GCPreMember) $ probeMatchingMemberContact m connectedIncognito + when (memCategory == GCPreMember) $ probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn} -> forM_ activeConn $ \Connection {connStatus} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True + when (memCategory == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True + sendXGrpMemCon memCategory + where + sendXGrpMemCon = \case + GCPreMember -> + forM_ (invitedByGroupMemberId membership) $ \hostId -> do + host <- withStore $ \db -> getGroupMember db user groupId hostId + forM_ (memberConn host) $ \hostConn -> + void $ sendDirectMessage hostConn (XGrpMemCon m.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 m.memberId) (GroupId groupId) + _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn - withAckMessage agentConnId cmdId msgMeta $ do - (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody cmdId - let m' = m {activeConn = Just conn'} :: GroupMember - updateChatLock "groupMessage" event - case event of - XMsgNew mc -> canSend m' $ newGroupContentMessage gInfo m' mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr -> canSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr msgMeta - XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m' sharedMsgId msgMeta - XMsgUpdate sharedMsgId mContent ttl live -> canSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg msgMeta - XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg msgMeta - -- TODO discontinue XFile - XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg msgMeta - XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId msgMeta - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName msgMeta - -- XInfo p -> xInfoMember gInfo m' p -- TODO use for member profile update - XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p - XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg msgMeta - XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo - XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv - XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m' memInfo introInv - XGrpMemRole memId memRole -> xGrpMemRole gInfo m' memId memRole msg msgMeta - XGrpMemDel memId -> xGrpMemDel gInfo m' memId msg msgMeta - XGrpLeave -> xGrpLeave gInfo m' msg msgMeta - XGrpDel -> xGrpDel gInfo m' msg msgMeta - XGrpInfo p' -> xGrpInfo gInfo m' p' msg msgMeta - XGrpDirectInv connReq mContent_ -> canSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg msgMeta - XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash - XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe - BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta - _ -> messageError $ "unsupported message: " <> T.pack (show event) - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo - pure $ - fromMaybe (sendRcptsSmallGroups user) sendRcpts - && hasDeliveryReceipt (toCMEventTag event) - && currentMemCount <= smallGroupsRcptsMemLimit + tryChatError (processChatMessage cmdId) >>= \case + Right (ACMsg _ chatMsg, withRcpt) -> do + ackMsg agentConnId cmdId msgMeta $ if withRcpt then Just "" else Nothing + when (membership.memberRole >= GRAdmin) $ forwardMsg_ chatMsg + Left e -> ackMsg agentConnId cmdId msgMeta Nothing >> throwError e where - canSend :: GroupMember -> m () -> m () - canSend mem a - | mem.memberRole <= GRObserver = messageError "member is not allowed to send messages" - | otherwise = a + processChatMessage :: Int64 -> m (AChatMessage, Bool) + processChatMessage cmdId = do + msg@(ACMsg _ chatMsg) <- parseAChatMessage conn msgMeta msgBody + checkIntegrity chatMsg `catchChatError` \_ -> pure () + (msg,) <$> processEvent cmdId chatMsg + brokerTs = metaBrokerTs msgMeta + checkIntegrity :: ChatMessage e -> m () + checkIntegrity ChatMessage {chatMsgEvent} = do + when checkForEvent $ checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta + where + checkForEvent = case chatMsgEvent of + XMsgNew _ -> True + XFileCancel _ -> True + XFileAcptInv {} -> True + XGrpMemNew _ -> True + XGrpMemRole {} -> True + XGrpMemDel _ -> True + XGrpLeave -> True + XGrpDel -> True + XGrpInfo _ -> True + XGrpDirectInv {} -> True + _ -> False + processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m Bool + processEvent cmdId chatMsg = do + (m', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m conn msgMeta cmdId msgBody chatMsg + updateChatLock "groupMessage" event + case event of + XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs + XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg brokerTs ttl live + XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg brokerTs + XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg brokerTs + -- TODO discontinue XFile + XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg brokerTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName + XInfo p -> xInfoMember gInfo m' p + XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p + XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg brokerTs + XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo + XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv + XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m' memInfo introInv + XGrpMemRole memId memRole -> xGrpMemRole gInfo m' memId memRole msg brokerTs + XGrpMemCon memId -> xGrpMemCon gInfo m' memId + XGrpMemDel memId -> xGrpMemDel gInfo m' memId msg brokerTs + XGrpLeave -> xGrpLeave gInfo m' msg brokerTs + XGrpDel -> xGrpDel gInfo m' msg brokerTs + XGrpInfo p' -> xGrpInfo gInfo m' p' msg brokerTs + XGrpDirectInv connReq mContent_ -> memberCanSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg brokerTs + XGrpMsgForward memberId msg' msgTs -> xGrpMsgForward gInfo m' memberId msg' msgTs + XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe + BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta + _ -> messageError $ "unsupported message: " <> T.pack (show event) + checkSendRcpt event + checkSendRcpt :: ChatMsgEvent e -> m Bool + checkSendRcpt event = do + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo + let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo + pure $ + fromMaybe (sendRcptsSmallGroups user) sendRcpts + && hasDeliveryReceipt (toCMEventTag event) + && currentMemCount <= smallGroupsRcptsMemLimit + forwardMsg_ :: MsgEncodingI e => ChatMessage e -> m () + forwardMsg_ chatMsg = + forM_ (forwardedGroupMsg chatMsg) $ \chatMsg' -> do + ChatConfig {highlyAvailable} <- asks config + -- members introduced to this invited member + introducedMembers <- if memberCategory m == GCInviteeMember + then withStore' $ \db -> getForwardIntroducedMembers db user m highlyAvailable + else pure [] + -- invited members to which this member was introduced + invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db user m highlyAvailable + let ms = introducedMembers <> invitedMembers + msg = XGrpMsgForward m.memberId chatMsg' brokerTs + unless (null ms) $ + void $ sendGroupMessage user gInfo ms msg RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -3829,6 +3887,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> toView $ CRReceivedContactRequest user cReq _ -> pure () + memberCanSend :: GroupMember -> m () -> m () + memberCanSend mem a + | mem.memberRole <= GRObserver = messageError "member is not allowed to send messages" + | otherwise = a + incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m () incAuthErrCounter connEntity conn err = do case err of @@ -3872,7 +3935,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withAckMessage cId cmdId msgMeta $ action $> False withAckMessage :: ConnId -> CommandId -> MsgMeta -> m Bool -> m () - withAckMessage cId cmdId MsgMeta {recipient = (msgId, _)} action = do + withAckMessage cId cmdId msgMeta action = do -- [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: @@ -3880,10 +3943,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- 2) stabilize database -- 3) show screen of death to the user asking to restart tryChatError action >>= \case - Right withRcpt -> ack $ if withRcpt then Just "" else Nothing - Left e -> ack Nothing >> throwError e - where - ack rcpt = withAgent $ \a -> ackMessageAsync a (aCorrId cmdId) cId msgId rcpt + Right withRcpt -> ackMsg cId cmdId msgMeta $ if withRcpt then Just "" else Nothing + Left e -> ackMsg cId cmdId msgMeta Nothing >> throwError e + + ackMsg :: ConnId -> CommandId -> MsgMeta -> Maybe MsgReceiptInfo -> m () + ackMsg cId cmdId MsgMeta {recipient = (msgId, _)} rcpt = withAgent $ \a -> ackMessageAsync a (aCorrId cmdId) cId msgId rcpt ackMsgDeliveryEvent :: Connection -> CommandId -> m () ackMsgDeliveryEvent Connection {connId} ackCmdId = @@ -4003,8 +4067,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live autoAcceptFile file_ where + brokerTs = metaBrokerTs msgMeta newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}) @@ -4019,8 +4084,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId processFDMessage fileId fileDescr - groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> MsgMeta -> m () - groupMessageFileDescription GroupInfo {groupId} _m sharedMsgId fileDescr _msgMeta = do + groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> m () + groupMessageFileDescription GroupInfo {groupId} _m sharedMsgId fileDescr = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId processFDMessage fileId fileDescr @@ -4038,17 +4103,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (RFSAccepted _, Just XFTPRcvFile {}) -> receiveViaCompleteFD user fileId rfd cryptoArgs _ -> pure () - cancelMessageFile :: Contact -> SharedMsgId -> MsgMeta -> m () - cancelMessageFile ct _sharedMsgId msgMeta = do - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - -- find the original chat item and file - -- mark file as cancelled, remove description if exists - pure () - - cancelGroupMessageFile :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () - cancelGroupMessageFile _gInfo _m _sharedMsgId _msgMeta = do - pure () - processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> m (Maybe (RcvFileTransfer, CIFile 'MDRcv)) processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv@FileInvitation {fileName, fileSize} -> do ChatConfig {fileChunkSize} <- asks config @@ -4075,13 +4129,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs content Nothing timed_ live ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content live Nothing toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') where - MsgMeta {broker = (_, brokerTs)} = msgMeta + brokerTs = metaBrokerTs msgMeta content = CIRcvMsgContent mc live = fromMaybe False live_ updateRcvChatItem = do @@ -4136,8 +4190,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else pure Nothing mapM_ toView cr_ - groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> MsgMeta -> m () - groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do + groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> m () + groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} brokerTs = do when (groupFeatureAllowed SGFReactions g) $ do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False when (reactionAllowed add reaction rs) $ do @@ -4166,8 +4220,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> m () + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = do @@ -4187,38 +4241,37 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | moderatorRole < GRAdmin || moderatorRole < memberRole = createItem timed_ live | groupFeatureAllowed SGFFullDelete gInfo = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta CIRcvModerated Nothing timed_ False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed_ False ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt toView $ CRNewChatItem user $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' | otherwise = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent content) (snd <$> file_) timed_ False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content) (snd <$> file_) timed_ False toView =<< markGroupCIDeleted user gInfo ci createdByMsgId False (Just moderator) moderatedAt createItem timed_ live = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live when (showMessages $ memberSettings m) $ autoAcceptFile file_ newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo m ci' {reactions} msgMeta + groupMsgToView gInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> m () + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} brokerTs ttl_ live_ = updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) brokerTs content Nothing timed_ live ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc ci' <- updateGroupChatItem db user groupId ci content live Nothing blockedMember m ci' $ markGroupChatItemBlocked db user gInfo ci' toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') where - MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc live = fromMaybe False live_ updateRcvChatItem = do @@ -4241,8 +4294,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else messageError "x.msg.update: group member attempted to update a message of another member" _ -> messageError "x.msg.update: group member attempted invalid message update" - groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> MsgMeta -> m () - groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do + groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> UTCTime -> m () + groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} brokerTs = do let msgMemberId = fromMaybe memberId sndMemberId_ withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case Right (CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of @@ -4279,20 +4332,22 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + where + brokerTs = metaBrokerTs msgMeta -- TODO remove once XFile is discontinued - processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> UTCTime -> m () + processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} brokerTs = do ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - groupMsgToView gInfo m ci' msgMeta + groupMsgToView gInfo ci' blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) blockedMember m ci blockedCI @@ -4399,9 +4454,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure () receiveFileChunk ft Nothing meta chunk - xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () - xFileCancelGroup g@GroupInfo {groupId} mem@GroupMember {groupMemberId, memberId} sharedMsgId msgMeta = do - checkIntegrityCreateItem (CDGroupRcv g mem) msgMeta + xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> m () + xFileCancelGroup GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId case (msgDir, chatDir) of @@ -4416,9 +4470,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" - xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m () - xFileAcptInvGroup g@GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName msgMeta = do - checkIntegrityCreateItem (CDGroupRcv g m) msgMeta + xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> m () + xFileAcptInvGroup GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db user fileId assertSMPAcceptNotProhibited ci @@ -4447,9 +4500,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" - groupMsgToView :: GroupInfo -> GroupMember -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () - groupMsgToView gInfo m ci msgMeta = do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta + groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> m () + groupMsgToView gInfo ci = toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () @@ -4475,11 +4527,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content + ci <- saveRcvChatItem user (CDDirectRcv ct) msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where + brokerTs = metaBrokerTs msgMeta sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' sameGroupLinkId _ _ = False @@ -4503,13 +4556,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} let ct'' = ct' {activeConn = activeConn'} :: Contact - ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) + ci <- saveRcvChatItem user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) toView $ CRContactDeletedByContact user ct'' else do contactConns <- withStore' $ \db -> getContactConnections db userId c deleteAgentConnectionsAsync user $ map aConnId contactConns withStore' $ \db -> deleteContact db user c + where + brokerTs = metaBrokerTs msgMeta processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate c@Contact {profile = p} p' createItems @@ -4540,9 +4595,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | otherwise -> Nothing in setPreference_ SCFTimedMessages ctUserTMPref' ctUserPrefs - -- TODO use for member profile update - -- xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () - -- xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' + xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () + xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> m () xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do @@ -4674,9 +4728,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRNewChatItem user $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci else featureRejected CFCalls where - saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) + brokerTs = metaBrokerTs msgMeta + saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvChatFeatureRejected f) Nothing Nothing False + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvChatFeatureRejected f) Nothing Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) -- to party initiating call @@ -4835,21 +4890,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO show/log error, other events in SMP confirmation _ -> pure conn' - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> MsgMeta -> m () - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg msgMeta = do + xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> m () + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg brokerTs = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ if isMember memId gInfo members then messageError "x.grp.mem.new error: member already exists" else do - newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) - groupMsgToView gInfo m ci msgMeta + newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) + groupMsgToView gInfo ci toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do + xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -4860,7 +4915,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second groupConnIds <- createConn subMode - directConnIds <- case memberChatVRange of + directConnIds <- case memChatVRange of Nothing -> Just <$> createConn subMode Just mcvr | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> pure Nothing @@ -4892,7 +4947,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of @@ -4900,7 +4955,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- 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. -- For now, this branch compensates for the lack of delayed message delivery. - Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced + Nothing -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced Just m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv subMode <- chatReadVar subscriptionMode @@ -4910,11 +4965,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange + mcvr = maybe chatInitialVRange fromChatVRange memChatVRange withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode - xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> MsgMeta -> m () - xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg msgMeta + xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> m () + xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg brokerTs | membership.memberId == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole @@ -4928,16 +4983,54 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView gInfo ci toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> m () checkHostRole GroupMember {memberRole, localDisplayName} memRole = when (memberRole < GRAdmin || memberRole < memRole) $ throwChatError (CEGroupContactRole localDisplayName) - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> MsgMeta -> m () - xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg msgMeta = do + xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> m () + xGrpMemCon gInfo sendingMember memId = do + refMember <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memId + case (memberCategory sendingMember, memberCategory refMember) of + (GCInviteeMember, GCInviteeMember) -> + withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case + Right intro -> inviteeXGrpMemCon intro + Left _ -> withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case + Right intro -> forwardMemberXGrpMemCon intro + Left _ -> messageWarning "x.grp.mem.con: no introduction" + (GCInviteeMember, _) -> + withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case + Right intro -> inviteeXGrpMemCon intro + Left _ -> messageWarning "x.grp.mem.con: no introduction" + (_, GCInviteeMember) -> + withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case + Right intro -> forwardMemberXGrpMemCon intro + Left _ -> messageWarning "x.grp.mem.con: no introductiosupportn" + -- Note: we can allow XGrpMemCon to all member categories if we decide to support broader group forwarding, + -- deduplication (see saveGroupRcvMsg, saveGroupFwdRcvMsg) already supports sending XGrpMemCon + -- to any forwarding member, not only host/inviting member; + -- database would track all members connections then + -- (currently it's done via group_member_intros for introduced connections only) + _ -> + messageWarning "x.grp.mem.con: neither member is invitee" + where + inviteeXGrpMemCon :: GroupMemberIntro -> m () + inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} + | introStatus == GMIntroReConnected = updateStatus introId GMIntroConnected + | introStatus `elem` [GMIntroToConnected, GMIntroConnected] = pure () + | otherwise = updateStatus introId GMIntroToConnected + forwardMemberXGrpMemCon :: GroupMemberIntro -> m () + forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} + | introStatus == GMIntroToConnected = updateStatus introId GMIntroConnected + | introStatus `elem` [GMIntroReConnected, GMIntroConnected] = pure () + | otherwise = updateStatus introId GMIntroReConnected + updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status + + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> m () + xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg brokerTs = do members <- withStore' $ \db -> getGroupMembers db user gInfo if membership.memberId == memId then checkRole membership $ do @@ -4963,23 +5056,20 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView gInfo ci - sameMemberId :: MemberId -> GroupMember -> Bool - sameMemberId memId GroupMember {memberId} = memId == memberId - - xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> MsgMeta -> m () - xGrpLeave gInfo m msg msgMeta = do + xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> m () + xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection user m -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEMemberLeft) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + groupMsgToView gInfo ci toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} - xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> MsgMeta -> m () - xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg msgMeta = do + xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> m () + 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 @@ -4987,24 +5077,24 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure members -- member records are not deleted to keep history deleteMembersConnections user ms - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + groupMsgToView gInfo ci toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m - xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> MsgMeta -> m () - xGrpInfo g@GroupInfo {groupProfile = p} m@GroupMember {memberRole} p' msg msgMeta + xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> m () + xGrpInfo g@GroupInfo {groupProfile = p} m@GroupMember {memberRole} p' msg brokerTs | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" | otherwise = unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' toView $ CRGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m unless (sameGroupProfileInfo p p') $ do - ci <- saveRcvChatItem user cd msg msgMeta (CIRcvGroupEvent $ RGEGroupUpdated p') - groupMsgToView g' m ci msgMeta + ci <- saveRcvChatItem user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') + groupMsgToView g' ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' - xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> MsgMeta -> m () - xGrpDirectInv g m mConn connReq mContent_ msg msgMeta = do + xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> m () + xGrpDirectInv g m mConn connReq mContent_ msg brokerTs = do unless (groupFeatureAllowed SGFDirectMessages g) $ messageError "x.grp.direct.inv: direct messages not allowed" let GroupMember {memberContactId} = m subMode <- chatReadVar subscriptionMode @@ -5040,11 +5130,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do dm <- directMessage $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do - checkIntegrityCreateItem (CDGroupRcv g m') msgMeta createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CRNewMemberContactReceivedInv user mCt' g m' forM_ mContent_ $ \mc -> do - ci <- saveRcvChatItem user (CDDirectRcv mCt') msg msgMeta (CIRcvMsgContent mc) + ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat mCt') ci) securityCodeChanged :: Contact -> m () @@ -5052,6 +5141,33 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing + xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> m () + xGrpMsgForward gInfo@GroupInfo {groupId} m memberId msg msgTs = do + when (m.memberRole < GRAdmin) $ throwChatError (CEGroupContactRole m.localDisplayName) + author <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memberId + processForwardedMsg author msg + where + -- Note: forwarded group events (see forwardedGroupMsg) should include msgId to be deduplicated + processForwardedMsg :: GroupMember -> ChatMessage 'Json -> m () + processForwardedMsg author chatMsg = do + let body = LB.toStrict $ J.encode msg + rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg + case event of + XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs + XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent rcvMsg msgTs ttl live + XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs + XMsgReact sharedMsgId (Just memId) reaction add -> groupMsgReaction gInfo author sharedMsgId memId reaction add rcvMsg msgTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo author sharedMsgId + XInfo p -> xInfoMember gInfo author p + XGrpMemNew memInfo -> xGrpMemNew gInfo author memInfo rcvMsg msgTs + XGrpMemRole memId memRole -> xGrpMemRole gInfo author memId memRole rcvMsg msgTs + XGrpMemDel memId -> xGrpMemDel gInfo author memId rcvMsg msgTs + XGrpLeave -> xGrpLeave gInfo author rcvMsg msgTs + XGrpDel -> xGrpDel gInfo author rcvMsg msgTs + XGrpInfo p' -> xGrpInfo gInfo author p' rcvMsg msgTs + _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) + directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta @@ -5100,6 +5216,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () +metaBrokerTs :: MsgMeta -> UTCTime +metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs + +sameMemberId :: MemberId -> GroupMember -> Bool +sameMemberId memId GroupMember {memberId} = memId == memberId + updatePeerChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do let jMsgChatVRange = JVersionRange msgChatVRange @@ -5109,6 +5231,18 @@ updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do pure conn {peerChatVRange = jMsgChatVRange} else pure conn +updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRange -> m (GroupMember, Connection) +updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, peerChatVRange} msgChatVRange = do + let jMsgChatVRange = JVersionRange msgChatVRange + if jMsgChatVRange /= peerChatVRange + 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') + else pure (mem, conn) + parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) @@ -5357,18 +5491,36 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do where messageMember :: GroupMember -> SndMessage -> m (Maybe GroupMember) messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of - Nothing -> do - withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ - pure $ Just m + Nothing -> pendingOrForwarded Just conn@Connection {connStatus} | connDisabled conn || connStatus == ConnDeleted -> pure Nothing | connStatus == ConnSndReady || connStatus == ConnReady -> do let tag = toCMEventTag chatMsgEvent deliverMessage conn tag msgBody msgId >> postDeliver pure $ Just m - | otherwise -> do - withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ - pure $ Just m + | otherwise -> pendingOrForwarded + where + pendingOrForwarded + | forwardSupported && isForwardedGroupMsg chatMsgEvent = pure Nothing + | isXGrpMsgForward chatMsgEvent = pure Nothing + | otherwise = do + withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + pure $ Just m + forwardSupported = do + let mcvr = memberChatVRange' m + isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward + invitingMemberSupportsForward = case m.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 + Nothing -> False + Nothing -> False + isXGrpMsgForward ev = case ev of + XGrpMsgForward {} -> True + _ -> False sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do @@ -5386,18 +5538,49 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn _ -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName _ -> pure () -saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> CommandId -> m (Connection, RcvMessage) -saveRcvMSG conn@Connection {connId} connOrGroupId agentMsgMeta msgBody agentAckCmdId = do +saveDirectRcvMSG :: ChatMonad m => Connection -> MsgMeta -> CommandId -> MsgBody -> m (Connection, RcvMessage) +saveDirectRcvMSG conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody = do ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody conn' <- updatePeerChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} - msg <- withStoreCtx' - (Just $ "createNewMessageAndRcvMsgDelivery, rcvMsgDelivery: " <> show rcvMsgDelivery <> ", sharedMsgId_: " <> show sharedMsgId_ <> ", msgDeliveryStatus: MDSRcvAgent") - $ \db -> createNewMessageAndRcvMsgDelivery db connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery + msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing pure (conn', msg) +saveGroupRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> CommandId -> MsgBody -> ChatMessage e -> m (GroupMember, Connection, RcvMessage) +saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do + (am', conn') <- updateMemberChatVRange authorMember conn chatVRange + let agentMsgId = fst $ recipient agentMsgMeta + newMsg = NewMessage {chatMsgEvent, msgBody} + rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} + amId = Just am'.groupMemberId + msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery amId) + `catchChatError` \e -> case e of + ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do + fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId + forM_ (memberConn fm) $ \fmConn -> + void $ sendDirectMessage fmConn (XGrpMemCon am'.memberId) (GroupId groupId) + throwError e + _ -> throwError e + pure (am', conn', msg) + +saveGroupFwdRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> m RcvMessage +saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} = do + let newMsg = NewMessage {chatMsgEvent, msgBody} + fwdMemberId = Just $ groupMemberId' forwardingMember + refAuthorId = Just $ groupMemberId' refAuthorMember + withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) + `catchChatError` \e -> case e of + ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do + am <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId + if sameMemberId refAuthorMember.memberId am + then forM_ (memberConn forwardingMember) $ \fmConn -> + void $ sendDirectMessage fmConn (XGrpMemCon am.memberId) (GroupId groupId) + else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + throwError e + _ -> throwError e + saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> m (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing False @@ -5409,27 +5592,27 @@ saveSndChatItem' user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem ciId <- createNewSndChatItem db user cd msg content quotedItem itemTimed live createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure ciId - liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemTimed live createdAt createdAt + liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemTimed live createdAt Nothing createdAt -saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) -saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} msgMeta content = - saveRcvChatItem' user cd msg sharedMsgId_ msgMeta content Nothing Nothing False +saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) +saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = + saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False -saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> m (ChatItem c 'MDRcv) -saveRcvChatItem' user cd msg sharedMsgId_ MsgMeta {broker = (_, brokerTs)} content ciFile itemTimed live = do +saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> m (ChatItem c 'MDRcv) +saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content ciFile itemTimed live = do createdAt <- liftIO getCurrentTime (ciId, quotedItem) <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure (ciId, quotedItem) - liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs createdAt + liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs msg.forwardedByGroupMemberId createdAt -mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs currentTs = do +mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> IO (ChatItem c d) +mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByGroupMemberId currentTs = do let itemText = ciContentToText content itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByGroupMemberId currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse @@ -5592,7 +5775,7 @@ createInternalChatItem user cd content itemTs_ = do ciId <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt createNewChatItemNoMsg db user cd content itemTs createdAt - ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs createdAt + ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs Nothing createdAt toView $ CRNewChatItem user (AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci) getCreateActiveUser :: SQLiteStore -> Bool -> IO User diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d2e81f96f5..7c67cd9e52 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -126,7 +126,8 @@ data ChatConfig = ChatConfig cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds - coreApi :: Bool + coreApi :: Bool, + highlyAvailable :: Bool } data DefaultAgentServers = DefaultAgentServers diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 2ddb1e7bcd..d35ed88185 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -162,7 +162,7 @@ isMention ChatItem {chatDir, quotedItem} = case chatDir of CIQDirectSnd -> True CIQGroupSnd -> True _ -> False - + data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd CIDirectRcv :: CIDirection 'CTDirect 'MDRcv @@ -341,17 +341,18 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, editable :: Bool, + forwardedByGroupMemberId :: Maybe GroupMemberId, createdAt :: UTCTime, updatedAt :: UTCTime } deriving (Show, Generic) -mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs createdAt updatedAt = +mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt = let editable = case itemContent of CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByGroupMemberId, createdAt, updatedAt} instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions @@ -814,7 +815,9 @@ data RcvMessage = RcvMessage { msgId :: MessageId, chatMsgEvent :: AChatMsgEvent, sharedMsgId_ :: Maybe SharedMsgId, - msgBody :: MsgBody + msgBody :: MsgBody, + authorGroupMemberId :: Maybe GroupMemberId, + forwardedByGroupMemberId :: Maybe GroupMemberId } data PendingGroupMessage = PendingGroupMessage diff --git a/src/Simplex/Chat/Migrations/M20231113_group_forward.hs b/src/Simplex/Chat/Migrations/M20231113_group_forward.hs new file mode 100644 index 0000000000..f23387f011 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231113_group_forward.hs @@ -0,0 +1,53 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231113_group_forward where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231113_group_forward :: Query +m20231113_group_forward = + [sql| +ALTER TABLE group_member_intros ADD COLUMN intro_chat_protocol_version INTEGER NOT NULL DEFAULT 3; +CREATE INDEX idx_group_member_intros_re_group_member_id ON group_member_intros(re_group_member_id); + +ALTER TABLE group_members ADD COLUMN invited_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +ALTER TABLE group_members ADD COLUMN peer_chat_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE group_members ADD COLUMN peer_chat_max_version INTEGER NOT NULL DEFAULT 1; +CREATE INDEX idx_group_members_invited_by_group_member_id ON group_members(invited_by_group_member_id); + +UPDATE group_members +SET (peer_chat_min_version, peer_chat_max_version) = (c.peer_chat_min_version, c.peer_chat_max_version) +FROM connections c +WHERE c.group_member_id = group_members.group_member_id; + +ALTER TABLE messages ADD COLUMN author_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +ALTER TABLE messages ADD COLUMN forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +CREATE INDEX idx_messages_author_group_member_id ON messages(author_group_member_id); +CREATE INDEX idx_messages_forwarded_by_group_member_id ON messages(forwarded_by_group_member_id); +CREATE INDEX idx_messages_group_id_shared_msg_id ON messages(group_id, shared_msg_id); + +ALTER TABLE chat_items ADD COLUMN forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items(forwarded_by_group_member_id); +|] + +down_m20231113_group_forward :: Query +down_m20231113_group_forward = + [sql| +DROP INDEX idx_chat_items_forwarded_by_group_member_id; +ALTER TABLE chat_items DROP COLUMN forwarded_by_group_member_id; + +DROP INDEX idx_messages_group_id_shared_msg_id; +DROP INDEX idx_messages_forwarded_by_group_member_id; +DROP INDEX idx_messages_author_group_member_id; +ALTER TABLE messages DROP COLUMN forwarded_by_group_member_id; +ALTER TABLE messages DROP COLUMN author_group_member_id; + +DROP INDEX idx_group_members_invited_by_group_member_id; +ALTER TABLE group_members DROP COLUMN peer_chat_max_version; +ALTER TABLE group_members DROP COLUMN peer_chat_min_version; +ALTER TABLE group_members DROP COLUMN invited_by_group_member_id; + +DROP INDEX idx_group_member_intros_re_group_member_id; +ALTER TABLE group_member_intros DROP COLUMN intro_chat_protocol_version; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 875ee91de2..6f576a75e9 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -147,6 +147,9 @@ CREATE TABLE group_members( member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, show_messages INTEGER NOT NULL DEFAULT 1, xgrplinkmem_received INTEGER NOT NULL DEFAULT 0, + invited_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -161,7 +164,8 @@ CREATE TABLE group_member_intros( direct_queue_info BLOB, intro_status TEXT NOT NULL, created_at TEXT CHECK(created_at NOT NULL), - updated_at TEXT CHECK(updated_at NOT NULL), -- see GroupMemberIntroStatus + updated_at TEXT CHECK(updated_at NOT NULL), + intro_chat_protocol_version INTEGER NOT NULL DEFAULT 3, -- see GroupMemberIntroStatus UNIQUE(re_group_member_id, to_group_member_id) ); CREATE TABLE files( @@ -322,7 +326,9 @@ CREATE TABLE messages( connection_id INTEGER DEFAULT NULL REFERENCES connections ON DELETE CASCADE, group_id INTEGER DEFAULT NULL REFERENCES groups ON DELETE CASCADE, shared_msg_id BLOB, - shared_msg_id_user INTEGER + shared_msg_id_user INTEGER, + author_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL ); CREATE TABLE msg_deliveries( msg_delivery_id INTEGER PRIMARY KEY, @@ -372,7 +378,8 @@ CREATE TABLE chat_items( timed_delete_at TEXT, item_live INTEGER, item_deleted_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, - item_deleted_ts TEXT + item_deleted_ts TEXT, + forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL ); CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, @@ -752,3 +759,22 @@ CREATE INDEX idx_contact_profiles_contact_link ON contact_profiles( user_id, contact_link ); +CREATE INDEX idx_group_member_intros_re_group_member_id ON group_member_intros( + re_group_member_id +); +CREATE INDEX idx_group_members_invited_by_group_member_id ON group_members( + invited_by_group_member_id +); +CREATE INDEX idx_messages_author_group_member_id ON messages( + author_group_member_id +); +CREATE INDEX idx_messages_forwarded_by_group_member_id ON messages( + forwarded_by_group_member_id +); +CREATE INDEX idx_messages_group_id_shared_msg_id ON messages( + group_id, + shared_msg_id +); +CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items( + forwarded_by_group_member_id +); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b444888145..8888ed13e0 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -152,7 +152,8 @@ mobileChatOpts dbFilePrefix dbKey = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 1024 + tbqSize = 1024, + highlyAvailable = False }, chatCmd = "", chatCmdDelay = 3, diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 0b39b8dd4f..04aef29dfa 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -54,7 +54,8 @@ data CoreChatOpts = CoreChatOpts logServerHosts :: Bool, logAgent :: Maybe LogLevel, logFile :: Maybe FilePath, - tbqSize :: Natural + tbqSize :: Natural, + highlyAvailable :: Bool } agentLogLevel :: ChatLogLevel -> LogLevel @@ -172,6 +173,11 @@ coreChatOptsP appDir defaultDbFileName = do <> value 1024 <> showDefault ) + highlyAvailable <- + switch + ( long "ha" + <> help "Run as a highly available client (this may increase traffic in groups)" + ) pure CoreChatOpts { dbFilePrefix, @@ -184,7 +190,8 @@ coreChatOptsP appDir defaultDbFileName = do logServerHosts = logServerHosts || logLevel <= CLLInfo, logAgent = if logAgent || logLevel == CLLDebug then Just $ agentLogLevel logLevel else Nothing, logFile, - tbqSize + tbqSize, + highlyAvailable } where useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 5 (const 10) p diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 43ca5913f7..d1a2e476e5 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} @@ -19,7 +20,7 @@ module Simplex.Chat.Protocol where import Control.Applicative ((<|>)) import Control.Monad ((<=<)) -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.KeyMap as JM @@ -51,7 +52,7 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) currentChatVersion :: Version -currentChatVersion = 3 +currentChatVersion = 4 supportedChatVRange :: VersionRange supportedChatVRange = mkVersionRange 1 currentChatVersion @@ -68,6 +69,10 @@ xGrpDirectInvVRange = mkVersionRange 2 currentChatVersion groupLinkNoContactVRange :: VersionRange groupLinkNoContactVRange = mkVersionRange 3 currentChatVersion +-- version range that supports group forwarding +groupForwardVRange :: VersionRange +groupForwardVRange = mkVersionRange 4 currentChatVersion + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -128,7 +133,7 @@ data AppMessageJson = AppMessageJson event :: Text, params :: J.Object } - deriving (Generic, FromJSON) + deriving (Eq, Show, Generic, FromJSON) data AppMessageBinary = AppMessageBinary { msgId :: Maybe SharedMsgId, @@ -208,7 +213,6 @@ instance StrEncoding AChatMessage where data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json @@ -230,13 +234,14 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json - XGrpMemCon :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented + XGrpMemCon :: MemberId -> ChatMsgEvent 'Json XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented XGrpMemDel :: MemberId -> ChatMsgEvent 'Json XGrpLeave :: ChatMsgEvent 'Json XGrpDel :: ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> ChatMsgEvent 'Json + XGrpMsgForward :: MemberId -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json XInfoProbeOk :: Probe -> ChatMsgEvent 'Json @@ -257,6 +262,30 @@ data AChatMsgEvent = forall e. MsgEncodingI e => ACME (SMsgEncoding e) (ChatMsgE deriving instance Show AChatMsgEvent +isForwardedGroupMsg :: ChatMsgEvent e -> Bool +isForwardedGroupMsg ev = case ev of + XMsgNew mc -> case mcExtMsgContent mc of + ExtMsgContent {file = Just FileInvitation {fileInline = Just _}} -> False + _ -> True + XMsgFileDescr _ _ -> True + XMsgUpdate {} -> True + XMsgDel _ _ -> True + XMsgReact {} -> True + XFileCancel _ -> True + XInfo _ -> True + XGrpMemNew _ -> True + XGrpMemRole {} -> True + XGrpMemDel _ -> True -- TODO there should be a special logic when deleting host member (e.g., host forwards it before deleting connections) + XGrpLeave -> True + XGrpDel -> True -- TODO there should be a special logic - host should forward before deleting connections + XGrpInfo _ -> True + _ -> False + +forwardedGroupMsg :: forall e. MsgEncodingI e => ChatMessage e -> Maybe (ChatMessage 'Json) +forwardedGroupMsg msg@ChatMessage {chatMsgEvent} = case encoding @e of + SJson | isForwardedGroupMsg chatMsgEvent -> Just msg + _ -> Nothing + data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) @@ -551,7 +580,6 @@ instance FromField MsgContent where data CMEventTag (e :: MsgEncoding) where XMsgNew_ :: CMEventTag 'Json XMsgFileDescr_ :: CMEventTag 'Json - XMsgFileCancel_ :: CMEventTag 'Json XMsgUpdate_ :: CMEventTag 'Json XMsgDel_ :: CMEventTag 'Json XMsgDeleted_ :: CMEventTag 'Json @@ -580,6 +608,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpDel_ :: CMEventTag 'Json XGrpInfo_ :: CMEventTag 'Json XGrpDirectInv_ :: CMEventTag 'Json + XGrpMsgForward_ :: CMEventTag 'Json XInfoProbe_ :: CMEventTag 'Json XInfoProbeCheck_ :: CMEventTag 'Json XInfoProbeOk_ :: CMEventTag 'Json @@ -600,7 +629,6 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where strEncode = \case XMsgNew_ -> "x.msg.new" XMsgFileDescr_ -> "x.msg.file.descr" - XMsgFileCancel_ -> "x.msg.file.cancel" XMsgUpdate_ -> "x.msg.update" XMsgDel_ -> "x.msg.del" XMsgDeleted_ -> "x.msg.deleted" @@ -629,6 +657,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpDel_ -> "x.grp.del" XGrpInfo_ -> "x.grp.info" XGrpDirectInv_ -> "x.grp.direct.inv" + XGrpMsgForward_ -> "x.grp.msg.forward" XInfoProbe_ -> "x.info.probe" XInfoProbeCheck_ -> "x.info.probe.check" XInfoProbeOk_ -> "x.info.probe.ok" @@ -650,7 +679,6 @@ instance StrEncoding ACMEventTag where ('x', t) -> pure . ACMEventTag SJson $ case t of "x.msg.new" -> XMsgNew_ "x.msg.file.descr" -> XMsgFileDescr_ - "x.msg.file.cancel" -> XMsgFileCancel_ "x.msg.update" -> XMsgUpdate_ "x.msg.del" -> XMsgDel_ "x.msg.deleted" -> XMsgDeleted_ @@ -679,6 +707,7 @@ instance StrEncoding ACMEventTag where "x.grp.del" -> XGrpDel_ "x.grp.info" -> XGrpInfo_ "x.grp.direct.inv" -> XGrpDirectInv_ + "x.grp.msg.forward" -> XGrpMsgForward_ "x.info.probe" -> XInfoProbe_ "x.info.probe.check" -> XInfoProbeCheck_ "x.info.probe.ok" -> XInfoProbeOk_ @@ -696,7 +725,6 @@ toCMEventTag :: ChatMsgEvent e -> CMEventTag e toCMEventTag msg = case msg of XMsgNew _ -> XMsgNew_ XMsgFileDescr _ _ -> XMsgFileDescr_ - XMsgFileCancel _ -> XMsgFileCancel_ XMsgUpdate {} -> XMsgUpdate_ XMsgDel {} -> XMsgDel_ XMsgDeleted -> XMsgDeleted_ @@ -725,6 +753,7 @@ toCMEventTag msg = case msg of XGrpDel -> XGrpDel_ XGrpInfo _ -> XGrpInfo_ XGrpDirectInv _ _ -> XGrpDirectInv_ + XGrpMsgForward {} -> XGrpMsgForward_ XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ XInfoProbeOk _ -> XInfoProbeOk_ @@ -795,7 +824,6 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do msg = \case XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" - XMsgFileCancel_ -> XMsgFileCancel <$> p "msgId" XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> opt "ttl" <*> opt "live" XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted @@ -824,6 +852,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpDel_ -> pure XGrpDel XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" + XGrpMsgForward_ -> XGrpMsgForward <$> p "memberId" <*> p "msg" <*> p "msgTs" XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" @@ -855,7 +884,6 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgFileCancel msgId' -> o ["msgId" .= msgId'] XMsgUpdate msgId' content ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty @@ -884,6 +912,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpDel -> JM.empty XGrpInfo p -> o ["groupProfile" .= p] XGrpDirectInv connReq content -> o $ ("content" .=? content) ["connReq" .= connReq] + XGrpMsgForward memberId msg msgTs -> o ["memberId" .= memberId, "msg" .= msg, "msgTs" .= msgTs] XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] @@ -894,3 +923,9 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XCallEnd callId -> o ["callId" .= callId] XOk -> JM.empty XUnknown _ ps -> ps + +instance ToJSON (ChatMessage 'Json) where + toJSON = (\(AMJson msg) -> toJSON msg) . chatToAppMessage + +instance FromJSON (ChatMessage 'Json) where + parseJSON v = appJsonToCM <$?> parseJSON v diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index b5b377ea51..53c7d249a6 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -98,13 +98,13 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, 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 + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences 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 diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 40294dc141..673ef4d805 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -45,6 +45,7 @@ module Simplex.Chat.Store.Groups getGroupInfoByName, getGroupMember, getGroupMemberById, + getGroupMemberByMemberId, getGroupMembers, getGroupMembersForExpiration, getGroupCurrentMembersCount, @@ -77,6 +78,9 @@ module Simplex.Chat.Store.Groups createIntroductions, updateIntroStatus, saveIntroInvitation, + getIntroduction, + getForwardIntroducedMembers, + getForwardInvitedMembers, createIntroReMember, createIntroToMemberContact, saveMemberInvitation, @@ -125,6 +129,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 (currentChatVersion, groupForwardVRange, supportedChatVRange) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -140,9 +145,9 @@ 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) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, Version, Version, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (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 GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (Maybe Int64, 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 Version, Maybe Version, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (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 :: Int64 -> GroupInfoRow -> GroupInfo toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs) :. userMemberRow) = @@ -153,16 +158,17 @@ toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, de in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing + memberChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -257,13 +263,13 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, 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, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version @@ -308,14 +314,14 @@ createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = Except (ldn, userId, profileId, True, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs supportedChatVRange let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do +createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Just hostConn@Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -353,8 +359,9 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?,?)" (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs) insertedRowId db - GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs - membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs + let JVersionRange hostVRange = hostConn.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 supportedChatVRange let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs}, groupMemberId) @@ -363,8 +370,8 @@ 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 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> ExceptT StoreError IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt = do +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRange -> 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 (Just profile@LocalProfile {displayName}, Just profileId) -> @@ -381,11 +388,13 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me memberStatus, memberSettings = defaultMemberSettings, invitedBy, + invitedByGroupMemberId, localDisplayName, memberProfile, memberContactId = Just $ contactId' userOrContact, memberContactProfileId = localProfileId (profile' userOrContact), - activeConn = Nothing + activeConn = Nothing, + memberChatVRange = JVersionRange memberChatVRange } where insertMember_ :: IO ContactName @@ -395,12 +404,14 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy) + ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) + :. (minV, maxV) ) pure localDisplayName insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO ContactName @@ -410,12 +421,14 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy) + ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + :. (minV, maxV) ) pure $ Right incognitoLdn @@ -430,7 +443,7 @@ createGroupInvitedViaLink hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - void $ createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs supportedChatVRange liftIO $ setViaGroupLinkHash db groupId connId (,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId where @@ -552,8 +565,8 @@ getUserGroupDetails db User {userId, userContactId} _contactId_ search_ = db [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, - mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, - mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, + mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -617,8 +630,8 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, 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, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version @@ -647,6 +660,14 @@ getGroupMemberById db user@User {userId} groupMemberId = (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) $ + 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) @@ -705,15 +726,17 @@ getGroupInvitation db user groupId = firstRow fromOnly (SEGroupNotFound groupId) $ DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId) -createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +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} groupId 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 {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 (fromJVersionRange peerChatVRange) Nothing 0 createdAt subMode pure member where + JVersionRange (VersionRange minV maxV) = peerChatVRange + invitedByGroupMemberId = groupMemberId' membership createMember_ memberId createdAt = do insertMember_ groupMemberId <- liftIO $ insertedRowId db @@ -727,11 +750,13 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con memberStatus = GSMemInvited, memberSettings = defaultMemberSettings, invitedBy = IBUser, + invitedByGroupMemberId = Just invitedByGroupMemberId, localDisplayName, memberProfile = profile, memberContactId = Just contactId, memberContactProfileId = localProfileId profile, - activeConn = Nothing + activeConn = Nothing, + memberChatVRange = peerChatVRange } where insertMember_ = @@ -739,16 +764,18 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser) + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) + :. (minV, maxV) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode = +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> 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 insertMember_ (MemberId memId) createdAt @@ -756,17 +783,20 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode setCommandConnId db user cmdId connId where + VersionRange minV maxV = peerChatVRange insertMember_ memberId createdAt = DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser) + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) + :. (minV, maxV) ) createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) @@ -774,8 +804,8 @@ createAcceptedMember db gVar User {userId, userContactId} - GroupInfo {groupId} - UserContactRequest {localDisplayName, profileId} + GroupInfo {groupId, membership} + UserContactRequest {cReqChatVRange, localDisplayName, profileId} memberRole = do liftIO $ DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) @@ -785,17 +815,20 @@ createAcceptedMember groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) where + JVersionRange (VersionRange minV maxV) = cReqChatVRange insertMember_ memberId createdAt = DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser) + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) + :. (minV, maxV) ) createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () @@ -864,8 +897,8 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do (memStatus, currentTs, userId, groupMemberId) -- | add new member with profile -createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo memInfo@MemberInfo {profile} memCategory memStatus = do +createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs let newMember = @@ -874,6 +907,7 @@ createNewGroupMember db user gInfo memInfo@MemberInfo {profile} memCategory memS memCategory, memStatus, memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Just $ groupMemberId' invitingMember, localDisplayName, memContactId = Nothing, memProfileId @@ -896,10 +930,11 @@ createNewMember_ User {userId, userContactId} GroupInfo {groupId} NewGroupMember - { memInfo = MemberInfo memberId memberRole _ memberProfile, + { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile, memCategory = memberCategory, memStatus = memberStatus, memInvitedBy = invitedBy, + memInvitedByGroupMemberId, localDisplayName, memContactId = memberContactId, memProfileId = memberContactProfileId @@ -907,18 +942,38 @@ createNewMember_ createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing + mcvr@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange DB.execute db [sql| INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, - invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + (group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + ( (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, memInvitedByGroupMemberId) + :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + :. (minV, maxV) + ) groupMemberId <- insertedRowId db - let memberSettings = defaultMemberSettings - pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, memberSettings, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} + pure GroupMember { + groupMemberId, + groupId, + memberId, + memberRole, + memberCategory, + memberStatus, + memberSettings = defaultMemberSettings, + invitedBy, + invitedByGroupMemberId = memInvitedByGroupMemberId, + localDisplayName, + memberProfile = toLocalProfile memberContactProfileId memberProfile "", + memberContactId, + memberContactProfileId, + activeConn, + memberChatVRange = JVersionRange mcvr + } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} = @@ -965,10 +1020,10 @@ createIntroductions db members toMember = do db [sql| INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, created_at, updated_at) - VALUES (?,?,?,?,?) + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) |] - (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, ts, ts) + (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, currentChatVersion, ts, ts) introId <- insertedRowId db pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending, introInvitation = Nothing} @@ -986,7 +1041,7 @@ updateIntroStatus db introId introStatus = do saveIntroInvitation :: DB.Connection -> GroupMember -> GroupMember -> IntroInvitation -> ExceptT StoreError IO GroupMemberIntro saveIntroInvitation db reMember toMember introInv = do - intro <- getIntroduction_ db reMember toMember + intro <- getIntroduction db reMember toMember liftIO $ do currentTs <- getCurrentTime DB.executeNamed @@ -1027,8 +1082,8 @@ saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnRe ":group_member_id" := groupMemberId ] -getIntroduction_ :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro -getIntroduction_ db reMember toMember = ExceptT $ do +getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro +getIntroduction db reMember toMember = ExceptT $ do toIntro <$> DB.query db @@ -1045,9 +1100,49 @@ 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 + memberIds <- map fromOnly <$> query + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + where + mId = groupMemberId' invitee + query + | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) + | otherwise = + DB.query + db + (q <> " AND intro_chat_protocol_version >= ?") + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + q = + [sql| + SELECT re_group_member_id + FROM group_member_intros + WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) + |] + +getForwardInvitedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] +getForwardInvitedMembers db user forwardMember highlyAvailable = do + memberIds <- map fromOnly <$> query + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + where + mId = groupMemberId' forwardMember + query + | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) + | otherwise = + DB.query + db + (q <> " AND intro_chat_protocol_version >= ?") + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + q = + [sql| + SELECT to_group_member_id + FROM group_member_intros + WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) + |] + createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember -createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do - let mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange +createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do + let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- liftIO getCurrentTime newMember <- case directConnIds of @@ -1056,10 +1151,10 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM liftIO $ setCommandConnId db user directCmdId directConnId (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs Nothing liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId) - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Just contactId, memProfileId} Nothing -> do (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId} + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, 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 @@ -1116,13 +1211,13 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, 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, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version @@ -1209,8 +1304,8 @@ getGroupInfo db User {userId, userContactId} groupId = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 0136ac6609..d98023ad83 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -23,6 +23,7 @@ module Simplex.Chat.Store.Messages createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, + createNewRcvMessage, createSndMsgDeliveryEvent, createRcvMsgDeliveryEvent, createPendingGroupMessage, @@ -185,25 +186,53 @@ createSndMsgDelivery db sndMsgDelivery messageId = do createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent currentTs pure msgDeliveryId -createNewMessageAndRcvMsgDelivery :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> RcvMsgDelivery -> IO RcvMessage -createNewMessageAndRcvMsgDelivery db connOrGroupId NewMessage {chatMsgEvent, msgBody} sharedMsgId_ RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} = do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO messages (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id) VALUES (?,?,?,?,?,?,?,?)" - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_) - msgId <- insertedRowId db - DB.execute - db - "INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, agent_ack_cmd_id, chat_ts, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (msgId, connId, agentMsgId, msgMetaJson agentMsgMeta, agentAckCmdId, snd $ broker agentMsgMeta, currentTs, currentTs) - msgDeliveryId <- insertedRowId db - createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs - pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody} - where - (connId_, groupId_) = case connOrGroupId of - ConnectionId connId' -> (Just connId', Nothing) - GroupId groupId -> (Nothing, Just groupId) +createNewMessageAndRcvMsgDelivery :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> RcvMsgDelivery -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage +createNewMessageAndRcvMsgDelivery db connOrGroupId newMessage sharedMsgId_ RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} authorGroupMemberId_ = do + msg@RcvMessage {msgId} <- createNewRcvMessage db connOrGroupId newMessage sharedMsgId_ authorGroupMemberId_ Nothing + liftIO $ do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, agent_ack_cmd_id, chat_ts, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (msgId, connId, agentMsgId, msgMetaJson agentMsgMeta, agentAckCmdId, snd $ broker agentMsgMeta, currentTs, currentTs) + msgDeliveryId <- insertedRowId db + createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs + pure msg + +createNewRcvMessage :: forall e. (MsgEncodingI e) => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage +createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsgId_ authorGroupMemberId forwardedByGroupMemberId = + case connOrGroupId of + ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing + GroupId groupId -> case sharedMsgId_ of + Just sharedMsgId -> liftIO (duplicateGroupMsgMemberIds groupId sharedMsgId) >>= \case + Just (duplAuthorId, duplFwdMemberId) -> + throwError $ SEDuplicateGroupMessage groupId sharedMsgId duplAuthorId duplFwdMemberId + Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId + Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId + where + duplicateGroupMsgMemberIds :: Int64 -> SharedMsgId -> IO (Maybe (Maybe GroupMemberId, Maybe GroupMemberId)) + duplicateGroupMsgMemberIds groupId sharedMsgId = + maybeFirstRow id + $ DB.query + db + [sql| + SELECT author_group_member_id, forwarded_by_group_member_id + FROM messages + WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 + |] + (groupId, sharedMsgId) + insertRcvMsg connId_ groupId_ = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO messages + (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + |] + (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorGroupMemberId, forwardedByGroupMemberId) + msgId <- insertedRowId db + pure RcvMessage{msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorGroupMemberId, forwardedByGroupMemberId} createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO () createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do @@ -322,7 +351,7 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem timed live createdAt = - createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow timed live createdAt createdAt + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow timed live createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -337,8 +366,8 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv Nothing -> (Just False, Nothing) createNewRcvChatItem :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs createdAt +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByGroupMemberId} sharedMsgId_ ciContent timed live itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem) where @@ -353,14 +382,14 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent} shar (Just $ Just userMemberId == memberId, memberId) createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItemNoMsg db user chatDirection ciContent = - createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing False +createNewChatItemNoMsg db user chatDirection ciContent itemTs = + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing False itemTs Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt = do DB.execute db [sql| @@ -368,18 +397,18 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q -- user and IDs user_id, created_by_msg_id, contact_id, group_id, group_member_id, -- meta - item_sent, item_ts, item_content, item_text, item_status, shared_msg_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + item_sent, item_ts, item_content, item_text, item_status, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByGroupMemberId) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing) @@ -440,8 +469,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe [sql| SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -556,8 +585,8 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat, @@ -565,19 +594,21 @@ getGroupChatPreviews_ db User {userId, userContactId} = do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- CIMeta forwardedByGroupMemberId + i.forwarded_by_group_member_id, -- Maybe GroupMember - sender - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.member_status, rm.show_messages, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -1020,7 +1051,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT DBCINotDeleted -> Nothing _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1031,7 +1062,7 @@ toDirectChatItemList _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow -type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow +type MaybeGroupChatItemRow = MaybeChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup) toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction quotedSent quotedMember_ @@ -1042,8 +1073,8 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing -- this function can be changed so it never fails, not only avoid failure on invalid json -toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1079,13 +1110,13 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} toGroupChatItemList :: UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = - either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) +toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = + either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) toGroupChatItemList _ _ _ = [] getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] @@ -1529,19 +1560,21 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- CIMeta forwardedByGroupMemberId + i.forwarded_by_group_member_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.member_status, rm.show_messages, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index f5a4426204..e261d97e2a 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -88,6 +88,7 @@ import Simplex.Chat.Migrations.M20231010_member_settings import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Chat.Migrations.M20231107_indexes +import Simplex.Chat.Migrations.M20231113_group_forward import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -175,7 +176,8 @@ schemaMigrations = ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes), ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), - ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes) + ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), + ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c51fcf1ebe..260c91e0ed 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -100,6 +100,7 @@ data StoreError | SEHostMemberIdNotFound {groupId :: Int64} | SEContactNotFoundByFileId {fileId :: FileTransferId} | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} + | SEDuplicateGroupMessage {groupId :: Int64, sharedMsgId :: SharedMsgId, authorGroupMemberId :: Maybe GroupMemberId, forwardedByGroupMemberId :: Maybe GroupMemberId} deriving (Show, Exception, Generic) instance ToJSON StoreError where @@ -206,6 +207,17 @@ setPeerChatVRange db connId (VersionRange minVer maxVer) = |] (minVer, maxVer, connId) +setMemberChatVRange :: DB.Connection -> GroupMemberId -> VersionRange -> IO () +setMemberChatVRange db mId (VersionRange minVer maxVer) = + DB.execute + db + [sql| + UPDATE group_members + SET peer_chat_min_version = ?, peer_chat_max_version = ? + WHERE group_member_id = ? + |] + (minVer, maxVer, mId) + setCommandConnId :: DB.Connection -> User -> CommandId -> Int64 -> IO () setCommandConnId db User {userId} cmdId connId = do updatedAt <- getCurrentTime diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index c92b25fb20..064cf78086 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -668,9 +668,9 @@ instance ToJSON MemberInfo where memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = - MemberInfo memberId memberRole memberChatVRange (fromLocalProfile memberProfile) + MemberInfo memberId memberRole cvr (fromLocalProfile memberProfile) where - memberChatVRange = ChatVersionRange . fromJVersionRange . peerChatVRange <$> activeConn + cvr = ChatVersionRange . fromJVersionRange . peerChatVRange <$> activeConn data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, @@ -692,6 +692,7 @@ data GroupMember = GroupMember memberStatus :: GroupMemberStatus, memberSettings :: GroupMemberSettings, invitedBy :: InvitedBy, + invitedByGroupMemberId :: Maybe GroupMemberId, localDisplayName :: ContactName, -- for membership, memberProfile can be either user's profile or incognito profile, based on memberIncognito test. -- for other members it's whatever profile the local user can see (there is no info about whether it's main or incognito profile for remote users). @@ -701,7 +702,10 @@ data GroupMember = GroupMember -- for membership it would always point to user's contact -- it is used to test for incognito status by comparing with ID in memberProfile memberContactProfileId :: ProfileId, - activeConn :: Maybe Connection + activeConn :: Maybe Connection, + -- 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 + memberChatVRange :: JVersionRange } deriving (Eq, Show, Generic) @@ -719,11 +723,17 @@ groupMemberRef GroupMember {groupMemberId, memberProfile = p} = GroupMemberRef {groupMemberId, profile = fromLocalProfile p} memberConn :: GroupMember -> Maybe Connection -memberConn GroupMember{activeConn} = activeConn +memberConn GroupMember {activeConn} = activeConn memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn +memberChatVRange' :: GroupMember -> VersionRange +memberChatVRange' GroupMember {activeConn, memberChatVRange} = + fromJVersionRange $ case activeConn of + Just Connection {peerChatVRange} -> peerChatVRange + Nothing -> memberChatVRange + groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId @@ -747,6 +757,7 @@ data NewGroupMember = NewGroupMember memCategory :: GroupMemberCategory, memStatus :: GroupMemberStatus, memInvitedBy :: InvitedBy, + memInvitedByGroupMemberId :: Maybe GroupMemberId, localDisplayName :: ContactName, memProfileId :: Int64, memContactId :: Maybe Int64 @@ -1471,7 +1482,7 @@ data GroupMemberIntroStatus | GMIntroReConnected | GMIntroToConnected | GMIntroConnected - deriving (Show) + deriving (Eq, Show) instance FromField GroupMemberIntroStatus where fromField = fromTextField_ introStatusT diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f7992571a9..982c5208af 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -450,7 +450,7 @@ viewChats ts tz = concatMap chatPreview . reverse viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} doShow ts tz = - withItemDeleted <$> case chat of + withGroupMsgForwarded . withItemDeleted <$> (case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc @@ -484,11 +484,14 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} from = ttyFromGroup g m where quote = maybe [] (groupQuote g) quotedItem - _ -> [] + _ -> []) where withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of Nothing -> item Just t -> item <> styled (colored Red) (" [" <> t <> "]") + withGroupMsgForwarded item = case meta.forwardedByGroupMemberId of + Nothing -> item + Just _ -> item <> styled (colored Yellow) (" [>>]" :: String) withSndFile = withFile viewSentFileInvitation withRcvFile = withFile viewReceivedFileInvitation withFile view dir l = maybe l (\f -> l <> view dir f ts tz meta) file diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ea455a0fc1..aaf812f000 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -71,7 +71,8 @@ testOpts = logServerHosts = False, logAgent = Nothing, logFile = Nothing, - tbqSize = 16 + tbqSize = 16, + highlyAvailable = False }, chatCmd = "", chatCmdDelay = 3, diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 36b5cf4ea5..edf3d2fab8 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -7,12 +7,14 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Monad (when) +import Control.Monad (when, void) +import qualified Data.ByteString as B import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Version import System.Directory (copyFile) import System.FilePath (()) @@ -103,6 +105,8 @@ chatGroupTests = do it "invited member replaces member contact reference if it already exists" testMemberContactInvitedConnectionReplaced it "share incognito profile" testMemberContactIncognito it "sends and updates profile when creating contact" testMemberContactProfileUpdate + describe "forwarding messages" $ do + it "admin should forward messages between invitee and introduced" testGroupMsgForward where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -1522,6 +1526,13 @@ testGroupDelayedModeration tmp = do cath <## "#team: you joined the group" ] threadDelay 1000000 + + -- imitate not implemented group forwarding + -- (real client wouldn't have forwarding code, but tests use "current code" with configured version, + -- and forwarding client doesn't check compatibility) + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" alice ##> "\\\\ #team @cath hi" @@ -1561,6 +1572,13 @@ testGroupDelayedModerationFullDelete tmp = do cath <## "#team: you joined the group" ] threadDelay 1000000 + + -- imitate not implemented group forwarding + -- (real client wouldn't have forwarding code, but tests use "current code" with configured version, + -- and forwarding client doesn't check compatibility) + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" alice ##> "\\\\ #team @cath hi" @@ -3644,9 +3662,9 @@ testMemberContactProhibitedRepeatInv = testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \c -> withTestOutput c $ \cath -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -3881,3 +3899,109 @@ testMemberContactProfileUpdate = cath #> "#team hello there" alice <# "#team kate> hello there" bob <# "#team kate> hello there" -- updated profile + +testGroupMsgForward :: HasCallStack => FilePath -> IO () +testGroupMsgForward = + testChatCfg4 cfg aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> withXFTPServer $ do + createGroup3 "team" alice bob cath + + threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + + void $ withCCTransaction bob $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction cath $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + threadDelay 1000000 + + cath #> "#team hey team" + alice <# "#team cath> hey team" + bob <# "#team cath> hey team [>>]" + + alice ##> "/tail #team 2" + alice <# "#team bob> hi there" + alice <# "#team cath> hey team" + + bob ##> "/tail #team 2" + bob <# "#team hi there" + bob <# "#team cath> hey team [>>]" + + cath ##> "/tail #team 2" + cath <# "#team bob> hi there [>>]" + cath <# "#team hey team" + + bob ##> "! #team hello there" + bob <# "#team [edited] hello there" + alice <# "#team bob> [edited] hello there" + cath <# "#team bob> [edited] hello there" -- TODO show as forwarded + + cath ##> "+1 #team hello there" + cath <## "added 👍" + alice <# "#team cath> > bob hello there" + alice <## " + 👍" + bob <# "#team cath> > bob hello there" + bob <## " + 👍" + + bob ##> "\\ #team hello there" + bob <## "message marked deleted" + alice <# "#team bob> [marked deleted] hello there" + cath <# "#team bob> [marked deleted] hello there" -- TODO show as forwarded + + bob #> "/f #team ./tests/fixtures/test.jpg" + bob <## "use /fc 1 to cancel sending" + bob <## "completed uploading file 1 (test.jpg) for #team" + concurrentlyN_ + [ do + alice <# "#team bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + alice <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team bob> 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 bob to ./tests/tmp/test.jpg" + cath <## "started receiving file 1 (test.jpg) from bob" + cath <## "completed receiving file 1 (test.jpg) from bob" + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + + cath ##> "/mr #team bob member" + cath <## "#team: you changed the role of bob from admin to member" + alice <## "#team: cath changed the role of bob from admin to member" + bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded + + connectUsers cath dan + cath ##> "/a #team dan" + cath <## "invitation to join the group #team sent to dan" + dan <## "#team: cath invites you to join the group as member" + dan <## "use /j team to accept" + dan ##> "/j #team" + dan <## "#team: you joined the group" + concurrentlyN_ + [ cath <## "#team: dan joined the group", + do + alice <## "#team: cath added dan (Daniel) to the group (connecting...)" + alice <## "#team: new member dan is connected", + -- bob will not connect to dan, as introductions are not forwarded (yet?) + bob <## "#team: cath added dan (Daniel) to the group (connecting...)", -- TODO show as forwarded + dan <## "#team: member alice (Alice) is connected" + ] + dan #> "#team hello all" + alice <# "#team dan> hello all" + -- bob <# "#team dan> hello all [>>]" + cath <# "#team dan> hello all" + + bob #> "#team hi all" + alice <# "#team bob> hi all" + cath <# "#team bob> hi all [>>]" + -- dan <# "#team bob> hi all" + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index f5c1bf8560..884d2b873e 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -122,7 +122,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## 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-3\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-4\",\"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))) 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\"}}}}" @@ -232,13 +232,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"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\"}}}}}}" #==# 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-3\",\"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.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-4\",\"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} 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} 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-3\",\"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.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-4\",\"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} 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%3D1-2%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%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" @@ -250,7 +250,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"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%3D1-2%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%3D1-2%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%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"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%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-4\",\"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\"}}}}}" @@ -276,6 +276,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do 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%3D1-2%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\"}}}" + -- #==# XGrpMsgForward + -- (MemberId "\1\2\3\4") + -- (ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)))) + -- (systemToUTCTime $ MkSystemTime 1 1) it "x.info.probe" $ "{\"v\":\"1\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbe (Probe "\1\2\3\4") From e95d9d0b49cdbb66440bba62ad55eba5938fad22 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 19:18:02 +0000 Subject: [PATCH 086/174] core: rename migration to remote-control, comments (#3393) --- simplex-chat.cabal | 2 +- .../Migrations/M20231114_remote_control.hs | 45 ++++++++++++++++++ .../Migrations/M20231114_remote_controller.hs | 47 ------------------- src/Simplex/Chat/Migrations/chat_schema.sql | 38 +++++++-------- src/Simplex/Chat/Store/Migrations.hs | 4 +- 5 files changed, 66 insertions(+), 70 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231114_remote_control.hs delete mode 100644 src/Simplex/Chat/Migrations/M20231114_remote_controller.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d40d3239da..abcfcc4c45 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -123,7 +123,7 @@ library Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Migrations.M20231107_indexes Simplex.Chat.Migrations.M20231113_group_forward - Simplex.Chat.Migrations.M20231114_remote_controller + Simplex.Chat.Migrations.M20231114_remote_control Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_control.hs b/src/Simplex/Chat/Migrations/M20231114_remote_control.hs new file mode 100644 index 0000000000..e716b2aa63 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231114_remote_control.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231114_remote_control where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231114_remote_control :: Query +m20231114_remote_control = + [sql| +CREATE TABLE remote_hosts ( -- e.g., mobiles known to a desktop app + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, -- relative folder name for host files + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + id_key BLOB NOT NULL, -- long-term/identity signing key + host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected + host_dh_pub BLOB NOT NULL -- last session DH key +); + +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts(host_fingerprint); + +CREATE TABLE remote_controllers ( -- e.g., desktops known to a mobile app + remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, + ctrl_device_name TEXT NOT NULL, + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA cert fingerprint, set when connected + id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures + dh_priv_key BLOB NOT NULL, -- last session DH key + prev_dh_priv_key BLOB -- previous session DH key +); + +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers(ctrl_fingerprint); +|] + +down_m20231114_remote_control :: Query +down_m20231114_remote_control = + [sql| +DROP INDEX idx_remote_hosts_host_fingerprint; +DROP INDEX idx_remote_controllers_ctrl_fingerprint; +DROP TABLE remote_hosts; +DROP TABLE remote_controllers; +|] diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs b/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs deleted file mode 100644 index a8e92a9988..0000000000 --- a/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs +++ /dev/null @@ -1,47 +0,0 @@ -{-# LANGUAGE QuasiQuotes #-} - -module Simplex.Chat.Migrations.M20231114_remote_controller where - -import Database.SQLite.Simple (Query) -import Database.SQLite.Simple.QQ (sql) - -m20231114_remote_controller :: Query -m20231114_remote_controller = - [sql| -CREATE TABLE remote_hosts ( -- hosts known to a controlling app - remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, - host_device_name TEXT NOT NULL, - store_path TEXT NOT NULL, -- file path for host files relative to app storage (must not contain "/") - -- RCHostPairing - ca_key BLOB NOT NULL, -- private key to sign session certificates - ca_cert BLOB NOT NULL, -- root certificate - id_key BLOB NOT NULL, -- long-term/identity signing key - -- KnownHostPairing - host_fingerprint BLOB NOT NULL, -- pinned remote host CA, set when connected - -- stored host session key - host_dh_pub BLOB NOT NULL, -- session DH key - UNIQUE (host_fingerprint) ON CONFLICT FAIL -); - -CREATE TABLE remote_controllers ( -- controllers known to a hosting app - remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, - ctrl_device_name TEXT NOT NULL, - -- RCCtrlPairing - ca_key BLOB NOT NULL, -- CA key - ca_cert BLOB NOT NULL, -- CA certificate for TLS clients - ctrl_fingerprint BLOB NOT NULL, -- remote controller CA, set when connected - id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures - -- stored session key, commited on connection confirmation - dh_priv_key BLOB NOT NULL, -- session DH key - -- prev session key - prev_dh_priv_key BLOB, -- previous session DH key - UNIQUE (ctrl_fingerprint) ON CONFLICT FAIL -); -|] - -down_m20231114_remote_controller :: Query -down_m20231114_remote_controller = - [sql| -DROP TABLE remote_hosts; -DROP TABLE remote_controllers; -|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index f6aed76982..bc441ec6ff 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -528,34 +528,26 @@ CREATE TABLE IF NOT EXISTS "received_probes"( updated_at TEXT CHECK(updated_at NOT NULL) ); CREATE TABLE remote_hosts( - -- hosts known to a controlling app + -- e.g., mobiles known to a desktop app remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, host_device_name TEXT NOT NULL, - store_path TEXT NOT NULL, -- file path for host files relative to app storage(must not contain "/") - -- RCHostPairing - ca_key BLOB NOT NULL, -- private key to sign session certificates - ca_cert BLOB NOT NULL, -- root certificate + store_path TEXT NOT NULL, -- relative folder name for host files + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, id_key BLOB NOT NULL, -- long-term/identity signing key - -- KnownHostPairing - host_fingerprint BLOB NOT NULL, -- pinned remote host CA, set when connected - -- stored host session key - host_dh_pub BLOB NOT NULL, -- session DH key - UNIQUE(host_fingerprint) ON CONFLICT FAIL + host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected + host_dh_pub BLOB NOT NULL -- last session DH key ); CREATE TABLE remote_controllers( - -- controllers known to a hosting app + -- e.g., desktops known to a mobile app remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, ctrl_device_name TEXT NOT NULL, - -- RCCtrlPairing - ca_key BLOB NOT NULL, -- CA key - ca_cert BLOB NOT NULL, -- CA certificate for TLS clients - ctrl_fingerprint BLOB NOT NULL, -- remote controller CA, set when connected + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA cert fingerprint, set when connected id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures - -- stored session key, commited on connection confirmation - dh_priv_key BLOB NOT NULL, -- session DH key - -- prev session key - prev_dh_priv_key BLOB, -- previous session DH key - UNIQUE(ctrl_fingerprint) ON CONFLICT FAIL + dh_priv_key BLOB NOT NULL, -- last session DH key + prev_dh_priv_key BLOB -- previous session DH key ); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, @@ -808,3 +800,9 @@ CREATE INDEX idx_messages_group_id_shared_msg_id ON messages( CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items( forwarded_by_group_member_id ); +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts( + host_fingerprint +); +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers( + ctrl_fingerprint +); diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index f7b10971cf..7b9ead1b10 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -89,7 +89,7 @@ import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Chat.Migrations.M20231107_indexes import Simplex.Chat.Migrations.M20231113_group_forward -import Simplex.Chat.Migrations.M20231114_remote_controller +import Simplex.Chat.Migrations.M20231114_remote_control import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -179,7 +179,7 @@ schemaMigrations = ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward), - ("20231114_remote_controller", m20231114_remote_controller, Just down_m20231114_remote_controller) + ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control) ] -- | The list of migrations in ascending order by date From ca8833c0c1599dde3ad4631c7f91cdb8bbce819b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 20:11:30 +0000 Subject: [PATCH 087/174] desktop: sending and receiving files via connected mobile (#3365) * desktop: support remote files (WIP) * working with remote files locally * better * working with remote file downloads * sending files * fixes of loading files in some situations * image compression * constant for remote hosts * refactor --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../simplex/common/platform/Files.android.kt | 2 + .../common/views/helpers/Utils.android.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 59 +++++++++++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 30 ++++++--- .../chat/simplex/common/platform/Files.kt | 19 +++++- .../simplex/common/views/chat/ChatView.kt | 7 +- .../simplex/common/views/chat/ComposeView.kt | 67 +++++++++++++------ .../common/views/chat/item/CIFileView.kt | 18 +++-- .../common/views/chat/item/CIImageView.kt | 25 +++++-- .../common/views/chat/item/CIVIdeoView.kt | 18 ++++- .../common/views/chat/item/CIVoiceView.kt | 23 +++++-- .../common/views/chat/item/ChatItemView.kt | 41 ++++++++---- .../common/views/helpers/AlertManager.kt | 19 ++++++ .../simplex/common/views/helpers/Utils.kt | 34 +++++++--- .../commonMain/resources/MR/base/strings.xml | 2 + .../common/model/NtfManager.desktop.kt | 2 +- .../simplex/common/platform/Files.desktop.kt | 2 + .../views/chat/item/CIImageView.desktop.kt | 2 - .../views/chat/item/ChatItemView.desktop.kt | 36 +++++++--- .../common/views/helpers/Utils.desktop.kt | 23 +++++-- 20 files changed, 334 insertions(+), 97 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index 161bc51e61..dfc8c1d4e7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db" actual val databaseExportDir: File = androidAppContext.cacheDir +actual val remoteHostsDir: File = File(tmpDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() {} @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index f2c2f393ab..5c7273ecc8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -167,7 +167,7 @@ actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -actual fun getLoadedImage(file: CIFile?): Pair? { +actual suspend fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) return if (filePath != null && file != null) { try { 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 c3ec33e0c2..86e5072307 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 @@ -1,7 +1,8 @@ package chat.simplex.common.model -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* @@ -596,6 +597,8 @@ object ChatModel { } terminalItems.add(item) } + + fun connectedToRemote(): Boolean = currentRemoteHost.value != null } enum class ChatType(val type: String) { @@ -2224,7 +2227,7 @@ enum class MREmojiChar(val value: String) { } @Serializable -class CIFile( +data class CIFile( val fileId: Long, val fileName: String, val fileSize: Long, @@ -2268,6 +2271,39 @@ class CIFile( is CIFileStatus.Invalid -> null } + /** + * DO NOT CALL this function in compose scope, [LaunchedEffect], [DisposableEffect] and so on. Only with [withBGApi] or [runBlocking]. + * Otherwise, it will be canceled when moving to another screen/item/view, etc + * */ + suspend fun loadRemoteFile(allowToShowAlert: Boolean): Boolean { + val rh = chatModel.currentRemoteHost.value + val user = chatModel.currentUser.value + if (rh == null || user == null || fileSource == null || !loaded) return false + if (getLoadedFilePath(this) != null) return true + if (cachedRemoteFileRequests.contains(fileSource)) return false + + val rf = RemoteFile( + userId = user.userId, + fileId = fileId, + sent = fileStatus.sent, + fileSource = fileSource + ) + cachedRemoteFileRequests.add(fileSource) + val showAlert = fileSize > 5_000_000 && allowToShowAlert + if (showAlert) { + AlertManager.shared.showAlertMsgWithProgress( + title = generalGetString(MR.strings.loading_remote_file_title), + text = generalGetString(MR.strings.loading_remote_file_desc) + ) + } + val res = chatModel.controller.getRemoteFile(rh.remoteHostId, rf) + cachedRemoteFileRequests.remove(fileSource) + if (showAlert) { + AlertManager.shared.hideAlert() + } + return res + } + companion object { fun getSample( fileId: Long = 1, @@ -2277,6 +2313,8 @@ class CIFile( fileStatus: CIFileStatus = CIFileStatus.RcvComplete ): CIFile = CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) + + val cachedRemoteFileRequests = SnapshotStateList() } } @@ -2308,6 +2346,8 @@ data class CryptoFile( companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) + + fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.rawPath, null) } } @@ -2370,6 +2410,21 @@ sealed class CIFileStatus { @Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus() @Serializable @SerialName("rcvError") object RcvError: CIFileStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIFileStatus() + + val sent: Boolean get() = when (this) { + is SndStored -> true + is SndTransfer -> true + is SndComplete -> true + is SndCancelled -> true + is SndError -> true + is RcvInvitation -> false + is RcvAccepted -> false + is RcvTransfer -> false + is RcvComplete -> false + is RcvCancelled -> false + is RcvError -> false + is Invalid -> false + } } @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") 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 98c48dbfb4..0d3b16fa80 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 @@ -24,6 +24,7 @@ import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.* +import java.io.File import java.util.Date typealias ChatCtrl = Long @@ -339,6 +340,9 @@ object ChatController { apiSetNetworkConfig(getNetCfg()) apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) + if (appPlatform.isDesktop) { + apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) + } apiSetXFTPConfig(getXFTPCfg()) apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() @@ -418,14 +422,14 @@ object ChatController { } } - suspend fun sendCmd(cmd: CC): CR { + suspend fun sendCmd(cmd: CC, customRhId: Long? = null): CR { val ctrl = ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val rhId = chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 + val rhId = customRhId?.toInt() ?: chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c) val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") @@ -559,6 +563,12 @@ object ChatController { throw Error("failed to set files folder: ${r.responseType} ${r.details}") } + private suspend fun apiSetRemoteHostsFolder(remoteHostsFolder: String) { + val r = sendCmd(CC.SetRemoteHostsFolder(remoteHostsFolder)) + if (r is CR.CmdOk) return + throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") + } + suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { val r = sendCmd(CC.ApiSetXFTPConfig(cfg)) if (r is CR.CmdOk) return @@ -609,9 +619,9 @@ object ChatController { return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(rhId: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) - val r = sendCmd(cmd) + val r = sendCmd(cmd, rhId) return when (r) { is CR.NewChatItem -> r.chatItem else -> { @@ -1142,8 +1152,9 @@ object ChatController { return false } - suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { - val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline)) + suspend fun apiReceiveFile(rhId: 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(CC.ReceiveFile(fileId, encrypted, inline), rhId ?: -1) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1868,7 +1879,7 @@ object ChatController { } suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { - val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) + val chatItem = apiReceiveFile(rhId, fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { chatItemSimpleUpdate(rhId, user, chatItem) } @@ -2035,6 +2046,7 @@ sealed class CC { class ApiStopChat: 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() @@ -2161,6 +2173,7 @@ sealed class CC { is ApiStopChat -> "/_stop" 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)}" @@ -2259,7 +2272,7 @@ sealed class CC { is DeleteRemoteHost -> "/delete remote host $remoteHostId" is StoreRemoteFile -> "/store remote file $remoteHostId " + - (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)}") + + (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)} ") + localPath is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" @@ -2290,6 +2303,7 @@ sealed class CC { is ApiStopChat -> "apiStopChat" is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" + is SetRemoteHostsFolder -> "setRemoteHostsFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" 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 71a9f204f8..877356e43a 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 @@ -24,6 +24,8 @@ expect val agentDatabaseFileName: String * */ expect val databaseExportDir: File +expect val remoteHostsDir: File + expect fun desktopOpenDatabaseDir() fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { @@ -59,14 +61,20 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) { } fun getAppFilePath(fileName: String): String { - return appFilesDir.absolutePath + File.separator + fileName + val rh = chatModel.currentRemoteHost.value + val s = File.separator + return if (rh == null) { + appFilesDir.absolutePath + s + fileName + } else { + remoteHostsDir.absolutePath + s + rh.storePath + s + "simplex_v1_files" + s + fileName + } } fun getLoadedFilePath(file: CIFile?): String? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) filePath else null + if (fileReady(file, filePath)) filePath else null } else { null } @@ -76,12 +84,17 @@ fun getLoadedFileSource(file: CIFile?): CryptoFile? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) file.fileSource else null + if (fileReady(file, filePath)) file.fileSource else null } else { null } } +private fun fileReady(file: CIFile, filePath: String) = + File(filePath).exists() && + !CIFile.cachedRemoteFileRequests.contains(file.fileSource) + && File(filePath).length() >= file.fileSize + /** * [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function * */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 527d68a523..7097c77d1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -499,6 +499,7 @@ fun ChatLayout( enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onImage = { + // TODO: file is not saved anywhere?! val tmpFile = File.createTempFile("image", ".bmp", tmpDir) tmpFile.deleteOnExit() chatModel.filesToDelete.add(tmpFile) @@ -1300,7 +1301,7 @@ private fun providerForGallery( scrollTo: (Int) -> Unit ): ImageGalleryProvider { fun canShowMedia(item: ChatItem): Boolean = - (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(item.file) != null) + (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && (getLoadedFilePath(item.file) != null || chatModel.connectedToRemote())) fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { var processedInternalIndex = -skipInternalIndex.sign @@ -1327,7 +1328,7 @@ private fun providerForGallery( val item = item(internalIndex, initialChatId)?.second ?: return null return when (item.content.msgContent) { is MsgContent.MCImage -> { - val res = getLoadedImage(item.file) + val res = runBlocking { getLoadedImage(item.file) } val filePath = getLoadedFilePath(item.file) if (res != null && filePath != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -1335,7 +1336,7 @@ private fun providerForGallery( } else null } is MsgContent.MCVideo -> { - val filePath = getLoadedFilePath(item.file) + val filePath = if (chatModel.connectedToRemote() && item.file?.loaded == true) getAppFilePath(item.file.fileName) else getLoadedFilePath(item.file) if (filePath != null) { val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 959ded42bf..a9b7014d51 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -15,6 +15,8 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.Indigo import chat.simplex.common.ui.theme.isSystemInDarkTheme @@ -349,8 +351,9 @@ fun ComposeView( } } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(rhId: Long?, cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( + rhId = rhId, type = cInfo.chatType, id = cInfo.apiId, file = file, @@ -447,15 +450,23 @@ fun ComposeView( } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() + val remoteHost = chatModel.currentRemoteHost.value when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.MediaPreview -> { preview.content.forEachIndexed { index, it -> + val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() val file = when (it) { - is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) + is UploadContent.SimpleImage -> + if (remoteHost == null) saveImage(it.uri, encrypted = encrypted) + else desktopSaveImageInTmp(it.uri) + is UploadContent.AnimatedImage -> + if (remoteHost == null) saveAnimImage(it.uri, encrypted = encrypted) + else CryptoFile.desktopPlain(it.uri) + is UploadContent.Video -> + if (remoteHost == null) saveFileFromUri(it.uri, encrypted = false) + else CryptoFile.desktopPlain(it.uri) } if (file != null) { files.add(file) @@ -470,22 +481,32 @@ fun ComposeView( is ComposePreview.VoicePreview -> { val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) - val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) - files.add(withContext(Dispatchers.IO) { - if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { - val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) - tmpFile.delete() - CryptoFile(actualFile.name, args) - } else { - Files.move(tmpFile.toPath(), actualFile.toPath()) - CryptoFile.plain(actualFile.name) - } - }) - deleteUnusedFiles() + if (remoteHost == null) { + val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) + files.add(withContext(Dispatchers.IO) { + if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { + val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) + tmpFile.delete() + CryptoFile(actualFile.name, args) + } else { + Files.move(tmpFile.toPath(), actualFile.toPath()) + CryptoFile.plain(actualFile.name) + } + }) + deleteUnusedFiles() + } else { + files.add(CryptoFile.plain(tmpFile.absolutePath)) + // It will be deleted on JVM shutdown or next start (if the app crashes unexpectedly) + filesToDelete.remove(tmpFile) + } msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + val file = if (remoteHost == null) { + saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + } else { + CryptoFile.desktopPlain(preview.uri) + } if (file != null) { files.add((file)) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) @@ -499,7 +520,15 @@ fun ComposeView( sent = null msgs.forEachIndexed { index, content -> if (index > 0) delay(100) - sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), + var file = files.getOrNull(index) + if (remoteHost != null && file != null) { + file = controller.storeRemoteFile( + rhId = remoteHost.remoteHostId, + storeEncrypted = if (content is MsgContent.MCVideo) false else null, + localPath = file.filePath + ) + } + sent = send(remoteHost?.remoteHostId, cInfo, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl ) @@ -509,7 +538,7 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + sent = send(remoteHost?.remoteHostId, cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 57dcd16cb9..0d439f1235 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -94,13 +94,19 @@ fun CIFileView( ) } is CIFileStatus.RcvComplete -> { - val filePath = getLoadedFilePath(file) - if (filePath != null) { - withApi { - saveFileLauncher.launch(file.fileName) + withBGApi { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath != null) { + withApi { + saveFileLauncher.launch(file.fileName) + } + } else { + showToast(generalGetString(MR.strings.file_not_found)) } - } else { - showToast(generalGetString(MR.strings.file_not_found)) } } else -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 23d1f1d0cc..8b0b2debca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -22,6 +22,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.runBlocking import java.io.File import java.net.URI @@ -134,7 +137,7 @@ fun CIImageView( return false } - fun imageAndFilePath(file: CIFile?): Triple? { + suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -148,9 +151,23 @@ fun CIImageView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val res = remember(file) { imageAndFilePath(file) } - if (res != null) { - val (imageBitmap, data, _) = res + val res: MutableState?> = remember { + mutableStateOf( + if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) } + ) + } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { + withBGApi { + if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { + res.value = imageAndFilePath(file) + } + } + } + } + val loaded = res.value + if (loaded != null) { + val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) } else { imageView(base64ToBitmap(image), onClick = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 996dc819fe..04ec307358 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -21,6 +21,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.* import java.io.File import java.net.URI @@ -37,10 +38,21 @@ fun CIVideoView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val filePath = remember(file) { getLoadedFilePath(file) } val preview = remember(image) { base64ToBitmap(image) } - if (file != null && filePath != null) { - val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } + val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file) { + withBGApi { + if (file != null && file.loaded && getLoadedFilePath(file) == null) { + file.loadRemoteFile(false) + filePath.value = getLoadedFilePath(file) + } + } + } + } + val f = filePath.value + if (file != null && f != null) { + val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } val view = LocalMultiplatformView() VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { hideKeyboard(view) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 941bc315b6..0c8487458f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.* // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901 @@ -44,16 +44,25 @@ fun CIVoiceView( ) { if (file != null) { val f = file.fileSource?.filePath - val fileSource = remember(f, file.fileStatus) { getLoadedFileSource(file) } + val fileSource = remember(f, file.fileStatus, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFileSource(file)) } var brokenAudio by rememberSaveable(f) { mutableStateOf(false) } val audioPlaying = rememberSaveable(f) { mutableStateOf(false) } val progress = rememberSaveable(f) { mutableStateOf(0) } val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) } - val play = { - if (fileSource != null) { - AudioPlayer.play(fileSource, audioPlaying, progress, duration, true) - brokenAudio = !audioPlaying.value + val play: () -> Unit = { + val playIfExists = { + if (fileSource.value != null) { + AudioPlayer.play(fileSource.value!!, audioPlaying, progress, duration, true) + brokenAudio = !audioPlaying.value + } } + if (chatModel.connectedToRemote() && fileSource.value == null) { + withBGApi { + file.loadRemoteFile(true) + fileSource.value = getLoadedFileSource(file) + playIfExists() + } + } else playIfExists() } val pause = { AudioPlayer.pause(audioPlaying, progress) @@ -68,7 +77,7 @@ fun CIVoiceView( } } VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { - AudioPlayer.seekTo(it, progress, fileSource?.filePath) + AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) 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 095723a18e..17e2fe0447 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 @@ -194,19 +194,34 @@ fun ChatItemView( }) } val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) - } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } + val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || !cachedRemoteReqs.contains(cItem.file.fileSource) + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + var fileSource = getLoadedFileSource(cItem.file) + val shareIfExists = { + when (val f = fileSource) { + null -> clipboard.shareText(cItem.content.text) + else -> shareFile(cItem.text, f) + } + showMenu.value = false + } + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + shareIfExists() + } + } else shareIfExists() + }) + } + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + } + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && !cachedRemoteReqs.contains(cItem.file?.fileSource)))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 35d5b8b3ef..fa9c89384d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -188,6 +188,25 @@ class AlertManager { ) } } + + fun showAlertMsgWithProgress( + title: String, + text: String? = null + ) { + showAlert { + AlertDialog( + onDismissRequest = this::hideAlert, + title = alertTitle(title), + text = alertText(text), + buttons = { + Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + } + } + ) + } + } + fun showAlertMsg( title: StringResource, text: StringResource? = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 5e64de2c54..7128d2185c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB expect fun getAppFileUri(fileName: String): URI // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -expect fun getLoadedImage(file: CIFile?): Pair? +expect suspend fun getLoadedImage(file: CIFile?): Pair? expect fun getFileName(uri: URI): String? @@ -106,7 +106,7 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) @@ -124,6 +124,24 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { } } +fun desktopSaveImageInTmp(uri: URI): CryptoFile? { + val image = getBitmapFromUri(uri) ?: return null + return try { + val ext = if (image.hasAlpha()) "png" else "jpg" + val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) + val destFileName = generateNewFileName("IMG", ext, tmpDir) + val destFile = File(tmpDir, destFileName) + val output = FileOutputStream(destFile) + dataResized.writeTo(output) + output.flush() + output.close() + CryptoFile.plain(destFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Util.kt desktopSaveImageInTmp error: ${e.stackTraceToString()}") + null + } +} + fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { return try { val filename = getFileName(uri)?.lowercase() @@ -134,7 +152,7 @@ fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readBytes() ?: return null) @@ -156,7 +174,7 @@ fun saveFileFromUri(uri: URI, encrypted: Boolean, withAlertOnException: Boolean val inputStream = uri.inputStream() val fileToSave = getFileName(uri) return if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave) + val destFileName = uniqueCombine(fileToSave, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> @@ -193,21 +211,21 @@ fun createTmpFileAndDelete(onCreated: (File) -> T): T { } } -fun generateNewFileName(prefix: String, ext: String): String { +fun generateNewFileName(prefix: String, ext: String, dir: File): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext") + return uniqueCombine("${prefix}_$timestamp.$ext", dir) } -fun uniqueCombine(fileName: String): String { +fun uniqueCombine(fileName: String, dir: File): String { val orig = File(fileName) val name = orig.nameWithoutExtension val ext = orig.extension fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" val f = "$name$suffix.$ext" - return if (File(getAppFilePath(f)).exists()) tryCombine(n + 1) else f + return if (File(dir, f).exists()) tryCombine(n + 1) else f } return tryCombine(0) } 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 b2f3e2f635..eb3b9207b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -350,6 +350,8 @@ File saved File not found Error saving file + Loading the file + Please, wait while the file is being loaded from the linked mobile Voice message diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index 94e985328e..cb34bdb3b0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -113,7 +113,7 @@ object NtfManager { private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) { tmpDir.mkdir() - val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png")) + val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png", tmpDir)) try { ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream()) newFile.absolutePath diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 9042a62830..0f7c131862 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +actual val remoteHostsDir: File = File(dataDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 711e09267d..6da2078567 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -2,12 +2,10 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.ModalManager -import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index f602dd577c..91efdf7908 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -34,35 +34,51 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { - when (cItem.content.msgContent) { - is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } - else -> {} + val saveIfExists = { + when (cItem.content.msgContent) { + is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } + else -> {} + } + showMenu.value = false } - showMenu.value = false + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + saveIfExists() + } + } else saveIfExists() }) } -actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { - val fileSource = getLoadedFileSource(cItem.file) +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withBGApi { + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + } + if (fileSource != null) { val filePath: String = if (fileSource.cryptoArgs != null) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() try { - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs ?: return@withBGApi, tmpFile.absolutePath) } catch (e: Exception) { Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) - return + return@withBGApi } tmpFile.absolutePath } else { getAppFilePath(fileSource.filePath) } - when { + when { desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) else -> clipboard.setText(AnnotatedString(filePath)) } } else { clipboard.setText(AnnotatedString(cItem.content.text)) } -} + showToast(MR.strings.copied.localized()) +}.run {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 7478e22a43..9413cbb404 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -5,8 +5,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density -import chat.simplex.common.model.CIFile -import chat.simplex.common.model.readCryptoFile +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState import chat.simplex.res.MR @@ -88,11 +87,21 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat AnnotatedString(text) } -actual fun getAppFileUri(fileName: String): URI = - URI(appFilesDir.toURI().toString() + "/" + fileName) +actual fun getAppFileUri(fileName: String): URI { + val rh = chatModel.currentRemoteHost.value + return if (rh == null) { + URI(appFilesDir.toURI().toString() + "/" + fileName) + } else { + URI(dataDir.absolutePath + "/remote_hosts/" + rh.storePath + "/simplex_v1_files/" + fileName) + } +} -actual fun getLoadedImage(file: CIFile?): Pair? { - val filePath = getLoadedFilePath(file) +actual suspend fun getLoadedImage(file: CIFile?): Pair? { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file?.loadRemoteFile(false) + filePath = getLoadedFilePath(file) + } return if (filePath != null) { try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() @@ -141,7 +150,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return if (file != null) { try { val ext = if (asPng) "png" else "jpg" - val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext)) + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(getAppFilePath("")))) // LALAL FILE IS EMPTY ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) newFile From 96e000e3ea3da2792ad688149e9a58a07436aed5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 20:28:55 +0000 Subject: [PATCH 088/174] ios: add user-defined device name for remote desktop connection --- apps/ios/SimpleX (iOS).entitlements | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 80e4adf2c2..c78a7cb941 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -19,6 +19,8 @@ $(AppIdentifierPrefix)chat.simplex.app com.apple.developer.networking.multicast - + + com.apple.developer.device-information.user-assigned-device-name + From f9e5a56e1a363bbd42611c943373eb0feba115ea Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 22:20:22 +0000 Subject: [PATCH 089/174] ios: terminate session on network failure, add description for local network access --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f1aba91263..9e4cc7cd02 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1729,7 +1729,9 @@ func processReceivedMsg(_ res: ChatResponse) async { m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state) } case .remoteCtrlStopped: - await MainActor.run { + // This delay is needed to cancel the session that fails on network failure, + // e.g. when user did not grant permission to access local network yet. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { switchToLocalSession() } default: diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 62db4e43ec..e98704d309 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1502,6 +1502,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -1544,6 +1545,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; From b164cc2fa6e1a9f11dd31e46615cb9ec6a507daa Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 19 Nov 2023 08:31:29 +0800 Subject: [PATCH 090/174] nix: fix lib:support for armv7a (#3394) --- flake.nix | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b56756922f..9c6bb3a226 100644 --- a/flake.nix +++ b/flake.nix @@ -253,7 +253,17 @@ # we also do not want to have any dependencies listed (especially no rts!) enableStatic = false; - setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; + # This used to work with 8.10.7... + # setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; + # ... but now with 9.6+ + # we have to do the -shared thing by hand. + postBuild = '' + armv7a-unknown-linux-androideabi-ghc -shared -o libsupport.so \ + -optl-Wl,-u,setLineBuffering \ + -optl-Wl,-u,pipe_std_to_socket \ + dist/build/*.a + ''; + postInstall = '' mkdir -p $out/_pkg From 8f0538e756b399bc3f8b063c62d82ca926f0bd3b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 19 Nov 2023 09:07:42 +0800 Subject: [PATCH 091/174] android: UI for remote connections (#3395) * android: UI for remote connections * camera permissions * eol --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../common/platform/AppCommon.android.kt | 2 + .../common/platform/RecAndPlay.android.kt | 2 +- .../common/views/chat/ScanCodeView.android.kt | 16 - .../common/views/helpers/Utils.android.kt | 2 +- .../views/newchat/QRCodeScanner.android.kt | 6 + .../chat/simplex/common/model/ChatModel.kt | 27 +- .../chat/simplex/common/model/SimpleXAPI.kt | 116 ++++- .../chat/simplex/common/platform/AppCommon.kt | 2 + .../simplex/common/views/chat/ScanCodeView.kt | 5 +- .../common/views/chatlist/UserPicker.kt | 21 + .../simplex/common/views/helpers/Utils.kt | 8 + .../common/views/remote/ConnectDesktopView.kt | 472 ++++++++++++++++++ .../common/views/remote/ConnectMobileView.kt | 2 +- .../common/views/usersettings/SettingsView.kt | 3 + .../commonMain/resources/MR/base/strings.xml | 26 + .../common/platform/AppCommon.desktop.kt | 4 + .../common/views/chat/ScanCodeView.desktop.kt | 8 - 17 files changed, 669 insertions(+), 53 deletions(-) delete mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt index 192f3dcc29..90b18bde93 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -16,6 +16,8 @@ import kotlin.random.Random actual val appPlatform = AppPlatform.ANDROID +actual val deviceName = android.os.Build.MODEL + var isAppOnForeground: Boolean = false @Suppress("ConstantLocale") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index f99dea77ca..5b0d3c778f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -38,7 +38,7 @@ actual class RecorderNative: RecorderInterface { rec.setAudioSamplingRate(16000) rec.setAudioEncodingBitRate(32000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) - val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_"), ".tmp", tmpDir) + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) fileToSave.deleteOnExit() val path = fileToSave.absolutePath filePath = path diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt deleted file mode 100644 index 79361dc073..0000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt +++ /dev/null @@ -1,16 +0,0 @@ -package chat.simplex.common.views.chat - -import android.Manifest -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.views.chat.ScanCodeLayout -import com.google.accompanist.permissions.rememberPermissionState - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - ScanCodeLayout(verifyCode, close) -} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 5c7273ecc8..d244294763 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -295,7 +295,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return try { val ext = if (asPng) "png" else "jpg" tmpDir.mkdir() - return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply { + return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext, tmpDir)).apply { outputStream().use { out -> image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out) out.flush() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 7fb6445d57..e7453ce20a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import android.Manifest import android.annotation.SuppressLint import android.util.Log import android.view.ViewGroup @@ -19,6 +20,7 @@ import boofcv.android.ConvertCameraImage import boofcv.factory.fiducial.FactoryFiducial import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG +import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture import java.util.concurrent.* @@ -26,6 +28,10 @@ import java.util.concurrent.* @Composable actual fun QRCodeScanner(onBarcode: (String) -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } 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 86e5072307..af946be3cd 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 @@ -27,6 +27,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.io.File import java.net.URI +import java.net.URLDecoder import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* @@ -111,6 +112,7 @@ object ChatModel { val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) val newRemoteHostPairing = mutableStateOf?>(null) + val remoteCtrlSession = mutableStateOf(null) fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value @@ -598,7 +600,7 @@ object ChatModel { terminalItems.add(item) } - fun connectedToRemote(): Boolean = currentRemoteHost.value != null + fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } enum class ChatType(val type: String) { @@ -2347,7 +2349,7 @@ data class CryptoFile( companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) - fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.rawPath, null) + fun desktopPlain(f: URI): CryptoFile = CryptoFile(URLDecoder.decode(f.rawPath, "UTF-8"), null) } } @@ -2907,8 +2909,18 @@ enum class NotificationPreviewMode { data class RemoteCtrlSession( val ctrlAppInfo: CtrlAppInfo, val appVersion: String, - val sessionState: RemoteCtrlSessionState -) + val sessionState: UIRemoteCtrlSessionState +) { + val active: Boolean + get () = sessionState is UIRemoteCtrlSessionState.Connected + + val sessionCode: String? + get() = when (val s = sessionState) { + is UIRemoteCtrlSessionState.PendingConfirmation -> s.sessionCode + is UIRemoteCtrlSessionState.Connected -> s.sessionCode + else -> null + } +} @Serializable sealed class RemoteCtrlSessionState { @@ -2917,3 +2929,10 @@ sealed class RemoteCtrlSessionState { @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() } + +sealed class UIRemoteCtrlSessionState { + @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() + @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() + @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() + @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() +} 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 0d3b16fa80..253a9fb163 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 @@ -24,7 +24,6 @@ import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.* -import java.io.File import java.util.Date typealias ChatCtrl = Long @@ -167,7 +166,11 @@ class AppPreferences { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) - val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, "Desktop") + val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, deviceName) + + val confirmRemoteSessions = mkBoolPreference(SHARED_PREFS_CONFIRM_REMOTE_SESSIONS, false) + val connectRemoteViaMulticast = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST, false) + val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -309,6 +312,9 @@ class AppPreferences { private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" + private const val SHARED_PREFS_CONFIRM_REMOTE_SESSIONS = "ConfirmRemoteSessions" + private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" + private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" } } @@ -1430,18 +1436,23 @@ object ChatController { suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(CC.GetRemoteFile(rhId, file)) - suspend fun connectRemoteCtrl(invitation: String): SomeRemoteCtrl? { - val r = sendCmd(CC.ConnectRemoteCtrl(invitation)) - if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) - apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) - return null + suspend fun connectRemoteCtrl(desktopAddress: String): Pair { + val r = sendCmd(CC.ConnectRemoteCtrl(desktopAddress)) + if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) return null to r + else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") } suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rhId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rhId)) + suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rcId)) - suspend fun verifyRemoteCtrlSession(sessionCode: String): Boolean = sendCommandOkResp(CC.VerifyRemoteCtrlSession(sessionCode)) + suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { + val r = sendCmd(CC.VerifyRemoteCtrlSession(sessionCode)) + if (r is CR.RemoteCtrlConnected) return r.remoteCtrl + apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) + return null + } suspend fun listRemoteCtrls(): List? { val r = sendCmd(CC.ListRemoteCtrls()) @@ -1843,6 +1854,22 @@ object ChatController { chatModel.newRemoteHostPairing.value = null switchUIRemoteHost(null) } + is CR.RemoteCtrlFound -> { + // TODO multicast + Log.d(TAG, "RemoteCtrlFound: ${r.remoteCtrl}") + } + is CR.RemoteCtrlSessionCode -> { + val state = UIRemoteCtrlSessionState.PendingConfirmation(remoteCtrl_ = r.remoteCtrl_, sessionCode = r.sessionCode) + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlConnected -> { + // TODO currently it is returned in response to command, so it is redundant + val state = UIRemoteCtrlSessionState.Connected(remoteCtrl = r.remoteCtrl, sessionCode = chatModel.remoteCtrlSession.value?.sessionCode ?: "") + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlStopped -> { + switchToLocalSession() + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1866,6 +1893,23 @@ object ChatController { } } + fun switchToLocalSession() { + val m = chatModel + m.remoteCtrlSession.value = null + withBGApi { + val users = listUsers() + m.users.clear() + m.users.addAll(users) + getUserChatData() + val statuses = apiGetNetworkStatuses() + if (statuses != null) { + chatModel.networkStatuses.clear() + val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() + chatModel.networkStatuses.putAll(ss) + } + } + } + private fun activeUser(rhId: Long?, user: UserLike): Boolean = rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId @@ -3474,7 +3518,10 @@ data class RemoteCtrlInfo ( val remoteCtrlId: Long, val ctrlDeviceName: String, val sessionState: RemoteCtrlSessionState? -) +) { + val deviceViewName: String + get() = ctrlDeviceName.ifEmpty { remoteCtrlId.toString() } +} @Serializable data class RemoteHostInfo( @@ -4558,6 +4605,7 @@ sealed class AgentErrorType { is SMP -> "SMP ${smpErr.string}" // is NTF -> "NTF ${ntfErr.string}" is XFTP -> "XFTP ${xftpErr.string}" + is RCP -> "RCP ${rcpErr.string}" is BROKER -> "BROKER ${brokerErr.string}" is AGENT -> "AGENT ${agentErr.string}" is INTERNAL -> "INTERNAL $internalErr" @@ -4568,6 +4616,7 @@ sealed class AgentErrorType { @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType() // @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() @Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType() + @Serializable @SerialName("XFTP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @@ -4738,6 +4787,38 @@ sealed class XFTPErrorType { @Serializable @SerialName("INTERNAL") object INTERNAL: XFTPErrorType() } +@Serializable +sealed class RCErrorType { + val string: String get() = when (this) { + is INTERNAL -> "INTERNAL $internalErr" + is IDENTITY -> "IDENTITY" + is NO_LOCAL_ADDRESS -> "NO_LOCAL_ADDRESS" + is TLS_START_FAILED -> "TLS_START_FAILED" + is EXCEPTION -> "EXCEPTION $EXCEPTION" + is CTRL_AUTH -> "CTRL_AUTH" + is CTRL_NOT_FOUND -> "CTRL_NOT_FOUND" + is CTRL_ERROR -> "CTRL_ERROR $ctrlErr" + is VERSION -> "VERSION" + is ENCRYPT -> "ENCRYPT" + is DECRYPT -> "DECRYPT" + is BLOCK_SIZE -> "BLOCK_SIZE" + is SYNTAX -> "SYNTAX $syntaxErr" + } + @Serializable @SerialName("internal") data class INTERNAL(val internalErr: String): RCErrorType() + @Serializable @SerialName("identity") object IDENTITY: RCErrorType() + @Serializable @SerialName("noLocalAddress") object NO_LOCAL_ADDRESS: RCErrorType() + @Serializable @SerialName("tlsStartFailed") object TLS_START_FAILED: RCErrorType() + @Serializable @SerialName("exception") data class EXCEPTION(val exception: String): RCErrorType() + @Serializable @SerialName("ctrlAuth") object CTRL_AUTH: RCErrorType() + @Serializable @SerialName("ctrlNotFound") object CTRL_NOT_FOUND: RCErrorType() + @Serializable @SerialName("ctrlError") data class CTRL_ERROR(val ctrlErr: String): RCErrorType() + @Serializable @SerialName("version") object VERSION: RCErrorType() + @Serializable @SerialName("encrypt") object ENCRYPT: RCErrorType() + @Serializable @SerialName("decrypt") object DECRYPT: RCErrorType() + @Serializable @SerialName("blockSize") object BLOCK_SIZE: RCErrorType() + @Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType() +} + @Serializable sealed class ArchiveError { val string: String get() = when (this) { @@ -4772,22 +4853,21 @@ sealed class RemoteHostError { sealed class RemoteCtrlError { val string: String get() = when (this) { is Inactive -> "inactive" + is BadState -> "badState" is Busy -> "busy" is Timeout -> "timeout" is Disconnected -> "disconnected" - is ConnectionLost -> "connectionLost" - is CertificateExpired -> "certificateExpired" - is CertificateUntrusted -> "certificateUntrusted" - is BadFingerprint -> "badFingerprint" + is BadInvitation -> "badInvitation" + is BadVersion -> "badVersion" } @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() + @Serializable @SerialName("badState") object BadState: RemoteCtrlError() @Serializable @SerialName("busy") object Busy: RemoteCtrlError() @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() @Serializable @SerialName("disconnected") class Disconnected(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() - @Serializable @SerialName("connectionLost") class ConnectionLost(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() - @Serializable @SerialName("certificateExpired") class CertificateExpired(val remoteCtrlId: Long): RemoteCtrlError() - @Serializable @SerialName("certificateUntrusted") class CertificateUntrusted(val remoteCtrlId: Long): RemoteCtrlError() - @Serializable @SerialName("badFingerprint") object BadFingerprint: RemoteCtrlError() + @Serializable @SerialName("badInvitation") object BadInvitation: RemoteCtrlError() + @Serializable @SerialName("badVersion") data class BadVersion(val appVersion: String): RemoteCtrlError() + //@Serializable @SerialName("protocolError") data class ProtocolError(val protocolError: RemoteProtocolError): RemoteCtrlError() } enum class NotificationsMode() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index b10a30233f..7d5b1b0196 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -18,6 +18,8 @@ enum class AppPlatform { expect val appPlatform: AppPlatform +expect val deviceName: String + val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 91fb4a6e8b..8ce39eea35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -11,10 +11,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @Composable -expect fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) - -@Composable -fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { +fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { Column( Modifier .fillMaxSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 66cac7204a..caf8ec5cb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -26,6 +26,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.views.remote.ConnectDesktopView import chat.simplex.common.views.remote.connectMobileDevice import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -42,6 +43,7 @@ fun UserPicker( showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, + useFromDesktopClicked: () -> Unit = {}, settingsClicked: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -203,6 +205,15 @@ fun UserPicker( Divider(Modifier.requiredHeight(1.dp)) } } + if (appPlatform.isAndroid) { + UseFromDesktopPickerItem { + ModalManager.start.showCustomModal { close -> + ConnectDesktopView(close) + } + userPickerState.value = AnimatedViewState.GONE + } + Divider(Modifier.requiredHeight(1.dp)) + } if (showSettings) { SettingsPickerItem(settingsClicked) } @@ -363,6 +374,16 @@ fun LocalDeviceRow(active: Boolean) { } } +@Composable +private fun UseFromDesktopPickerItem(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current) + Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 7128d2185c..4ed5ea56b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -52,6 +52,14 @@ fun annotatedStringResource(id: StringResource): AnnotatedString { } } +@Composable +fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString { + val density = LocalDensity.current + return remember(id) { + escapedHtmlToAnnotatedString(id.localized().format(args), density) + } +} + // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt new file mode 100644 index 0000000000..d631836dd6 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -0,0 +1,472 @@ +package chat.simplex.common.views.remote + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewLongClickable +import SectionSpacer +import SectionView +import TextIconSpaced +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.switchToLocalSession +import chat.simplex.common.model.ChatModel.connectedToRemote +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ConnectDesktopView(close: () -> Unit) { + val deviceName = remember { controller.appPrefs.deviceNameForRemoteAccess.state } + val closeWithAlert = { + if (!connectedToRemote()) { + close() + } else { + showDisconnectDesktopAlert(close) + } + } + ModalView(close = closeWithAlert) { + ConnectDesktopLayout( + deviceName = deviceName.value!!, + ) + } + val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } + DisposableEffect(Unit) { + withBGApi { + if (!ntfModeService) platform.androidServiceStart() + } + onDispose { + if (!ntfModeService) platform.androidServiceSafeStop() + } + } +} + +@Composable +private fun ConnectDesktopLayout(deviceName: String) { + val sessionAddress = remember { mutableStateOf("") } + val remoteCtrls = remember { mutableStateListOf() } + val session = remember { chatModel.remoteCtrlSession }.value + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + if (session != null) { + when (session.sessionState) { + is UIRemoteCtrlSessionState.Starting -> ConnectingDesktop(session, null) + is UIRemoteCtrlSessionState.Connecting -> ConnectingDesktop(session, session.sessionState.remoteCtrl_) + is UIRemoteCtrlSessionState.PendingConfirmation -> { + if (controller.appPrefs.confirmRemoteSessions.get() || session.sessionState.remoteCtrl_ == null) { + VerifySession(session, session.sessionState.remoteCtrl_, session.sessionCode!!, remoteCtrls) + } else { + ConnectingDesktop(session, session.sessionState.remoteCtrl_) + LaunchedEffect(Unit) { + verifyDesktopSessionCode(remoteCtrls, session.sessionCode!!) + } + } + } + + is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl) + } + } else { + ConnectDesktop(deviceName, remoteCtrls, sessionAddress) + } + SectionBottomSpacer() + } + DisposableEffect(Unit) { + setDeviceName(deviceName) + updateRemoteCtrls(remoteCtrls) + onDispose { + if (chatModel.remoteCtrlSession.value != null) { + disconnectDesktop() + } + } + } +} + +@Composable +private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList, sessionAddress: MutableState) { + AppBarTitle(stringResource(MR.strings.connect_to_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + ScanDesktopAddressView(sessionAddress) + if (controller.appPrefs.developerTools.get()) { + SectionSpacer() + DesktopAddressView(sessionAddress) + } +} + +@Composable +private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) + SectionView(stringResource(MR.strings.connecting_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList) { + AppBarTitle(stringResource(MR.strings.verify_connection)) + SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + SectionSpacer() + + SectionView(stringResource(MR.strings.verify_code_with_desktop).uppercase()) { + SessionCodeText(sessCode) + } + + SectionSpacer() + + SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { + Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.confirm_verb)) + } + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + val newDesktop = annotatedStringResource(MR.strings.new_desktop) + val text = remember(rc) { + var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo.deviceName) + if (rc == null) { + t = t + AnnotatedString(" ") + newDesktop + } + t + } + Text(text) +} + +@Composable +private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { + val thisDeviceVersion = annotatedStringResource(MR.strings.this_device_version, session.appVersion) + val text = remember(session) { + val v = AnnotatedString(session.ctrlAppInfo.appVersionRange.maxVersion) + var t = AnnotatedString("v$v") + if (v.text != session.appVersion) { + t = t + AnnotatedString(" ") + thisDeviceVersion + } + t + } + Text(text) +} + +@Composable +private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { + AppBarTitle(stringResource(MR.strings.connected_to_desktop)) + SectionView(stringResource(MR.strings.connected_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text(rc.deviceViewName) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun SessionCodeText(code: String) { + SelectionContainer { + Text( + code.substring(0, 23), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp) + ) + } +} + +@Composable +private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList, updateDeviceName: (String) -> Unit) { + DeviceNameField(deviceName) { updateDeviceName(it) } + if (remoteCtrls.isNotEmpty()) { + SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } }) { + Text(generalGetString(MR.strings.linked_desktops)) + } + } +} + +@Composable +private fun ScanDesktopAddressView(sessionAddress: MutableState) { + SectionView(stringResource(MR.strings.scan_qr_code_from_desktop).uppercase()) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(DEFAULT_PADDING) + ) { + QRCodeScanner { text -> + sessionAddress.value = text + processDesktopQRCode(sessionAddress, text) + } + } + } +} + +@Composable +private fun DesktopAddressView(sessionAddress: MutableState) { + val clipboard = LocalClipboardManager.current + SectionView(stringResource(MR.strings.desktop_address).uppercase()) { + if (sessionAddress.value.isEmpty()) { + SettingsActionItem( + painterResource(MR.images.ic_content_paste), + stringResource(MR.strings.paste_desktop_address), + disabled = !clipboard.hasText(), + click = { + sessionAddress.value = clipboard.getText()?.text ?: "" + }, + ) + } else { + Row(Modifier.padding(horizontal = DEFAULT_PADDING).fillMaxWidth()) { + val state = remember { + mutableStateOf(TextFieldValue(sessionAddress.value)) + } + DefaultBasicTextField( + Modifier.fillMaxWidth(), + state, + color = MaterialTheme.colors.secondary, + ) { + state.value = it + } + KeyChangeEffect(state.value.text) { + if (state.value.text.isNotEmpty()) { + sessionAddress.value = state.value.text + } + } + } + } + SettingsActionItem( + painterResource(MR.images.ic_wifi), + stringResource(MR.strings.connect_to_desktop), + disabled = sessionAddress.value.isEmpty(), + click = { + connectDesktopAddress(sessionAddress, sessionAddress.value) + }, + ) + } +} + +@Composable +private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + AppBarTitle(stringResource(MR.strings.linked_desktops)) + SectionView(stringResource(MR.strings.desktop_devices).uppercase()) { + remoteCtrls.forEach { rc -> + val showMenu = rememberSaveable { mutableStateOf(false) } + SectionItemViewLongClickable(click = {}, longClick = { showMenu.value = true }) { + RemoteCtrl(rc) + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), color = Color.Red) { + unlinkDesktop(remoteCtrls, rc) + showMenu.value = false + } + } + } + + } + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.linked_desktop_options).uppercase()) { + PreferenceToggle(stringResource(MR.strings.verify_connections), remember { controller.appPrefs.confirmRemoteSessions.state }.value) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value && false) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun RemoteCtrl(rc: RemoteCtrlInfo) { + Text(rc.deviceViewName) +} + +private fun setDeviceName(name: String) { + withBGApi { + controller.setLocalDeviceName(name) + } +} + +private fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { + withBGApi { + val res = controller.listRemoteCtrls() + if (res != null) { + remoteCtrls.clear() + remoteCtrls.addAll(res) + } + } +} + +private fun processDesktopQRCode(sessionAddress: MutableState, resp: String) { + connectDesktopAddress(sessionAddress, resp) +} + +private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { + withBGApi { + val res = controller.connectRemoteCtrl(desktopAddress = addr) + if (res.first != null) { + val (rc_, ctrlAppInfo, v) = res.first!! + sessionAddress.value = "" + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = ctrlAppInfo, + appVersion = v, + sessionState = UIRemoteCtrlSessionState.Connecting(remoteCtrl_ = rc_) + ) + } else { + val e = res.second ?: return@withBGApi + when { + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorChat && e.chatError.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.chatError.remoteCtrlError.appVersion) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() + else -> { + val errMsg = "${e.responseType}: ${e.details}" + Log.e(TAG, "bad response: $errMsg") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), errMsg) + } + } + } + } +} + +private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList, sessCode: String) { + withBGApi { + val rc = controller.verifyRemoteCtrlSession(sessCode) + if (rc != null) { + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = UIRemoteCtrlSessionState.Connected(remoteCtrl = rc, sessionCode = sessCode)) + } + updateRemoteCtrls(remoteCtrls) + } +} + +@Composable +private fun DisconnectButton(onClick: () -> Unit) { + SectionItemView(onClick) { + Icon(painterResource(MR.images.ic_close), generalGetString(MR.strings.disconnect_remote_host), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.disconnect_remote_host)) + } +} + +private fun disconnectDesktop(close: (() -> Unit)? = null) { + withBGApi { + controller.stopRemoteCtrl() + switchToLocalSession() + close?.invoke() + } +} + +private fun unlinkDesktop(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + withBGApi { + controller.deleteRemoteCtrl(rc.remoteCtrlId) + remoteCtrls.removeAll { it.remoteCtrlId == rc.remoteCtrlId } + } +} + +private fun showUnlinkDesktopAlert(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unlink_desktop_question), + confirmText = generalGetString(MR.strings.unlink_desktop), + destructive = true, + onConfirm = { + unlinkDesktop(remoteCtrls, rc) + } + ) +} + +private fun showDisconnectDesktopAlert(close: (() -> Unit)?) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.disconnect_desktop_question), + text = generalGetString(MR.strings.only_one_device_can_work_at_the_same_time), + confirmText = generalGetString(MR.strings.disconnect_remote_host), + destructive = true, + onConfirm = { disconnectDesktop(close) } + ) +} + +private fun showBadInvitationErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.bad_desktop_address), + ) +} + +private fun showBadVersionAlert(v: String?) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_incompatible_version), + text = generalGetString(MR.strings.desktop_app_version_is_incompatible).format(v ?: "") + ) +} + +private fun showDesktopDisconnectedErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_connection_terminated), + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index d00b9bb67a..0d90e59450 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -141,7 +141,7 @@ fun ConnectMobileLayout( } @Composable -private fun DeviceNameField( +fun DeviceNameField( initialValue: String, onChange: (String) -> Unit ) { 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 5fa3c41478..2d4e5c86b9 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 @@ -29,6 +29,7 @@ import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.remote.ConnectDesktopView import chat.simplex.common.views.remote.ConnectMobileView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -158,6 +159,8 @@ fun SettingsLayout( ChatPreferencesItem(showCustomModal, stopped = stopped) if (appPlatform.isDesktop) { SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) + } 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) } } 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 eb3b9207b7..616e78651d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -954,6 +954,7 @@ CALLS Incognito mode EXPERIMENTAL + Use from desktop Your chat database @@ -1636,6 +1637,7 @@ Verify connection Verify code on mobile This device name + (this device v%s)]]> Connected mobile Connected to mobile Enter this device name… @@ -1644,8 +1646,32 @@ This device Devices New mobile device + Unlink desktop? + Unlink Disconnect + Disconnect desktop? + Only one device can work at the same time Use from desktop in mobile app and scan QR code]]> + Bad desktop address + Incompatible version + Desktop app version %s is not compatible with this app. + Connection terminated + Session code + Connecting to desktop + Connect to desktop + Connected to desktop + Connected desktop + Verify code with desktop + (new)]]> + Linked desktops + Desktop devices + Linked desktop options + Scan QR code from desktop + Desktop address + Verify connections + Discover on network + Paste desktop address + Desktop Coming soon! diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 7193fbe2be..92111f162a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -2,11 +2,15 @@ package chat.simplex.common.platform import chat.simplex.common.model.* import chat.simplex.common.views.call.RcvCallInvitation +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.withBGApi import java.util.* +import chat.simplex.res.MR actual val appPlatform = AppPlatform.DESKTOP +actual val deviceName = generalGetString(MR.strings.desktop_device) + @Suppress("ConstantLocale") val defaultLocale: Locale = Locale.getDefault() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt deleted file mode 100644 index 7ea2ef5368..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package chat.simplex.common.views.chat - -import androidx.compose.runtime.Composable - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - ScanCodeLayout(verifyCode, close) -} From 59392b361b9f7b775bbe6dae0c97386530fc87f3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:06:49 +0000 Subject: [PATCH 092/174] ui: translations (#3392) * Translated using Weblate (Polish) Currently translated at 97.4% (1400 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 99.0% (1423 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Polish) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (French) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Italian) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (German) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (German) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Polish) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (French) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Italian) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * update de: Meine -> Ihre * nl: Gebruiker -> Lid * nl: gebruiker -> lid 2 * ios, nl: gebruiker -> lid * ios, nl: gebruiker -> lid 2 * android: fix strings * ios: export/import localizations --------- Co-authored-by: B.O.S.S Co-authored-by: Eric Co-authored-by: M1K4 Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: Random Co-authored-by: mlanp Co-authored-by: Eryk Michalak Co-authored-by: Denys Rastiegaiev --- .../bg.xcloc/Localized Contents/bg.xliff | 8 + .../cs.xcloc/Localized Contents/cs.xliff | 8 + .../de.xcloc/Localized Contents/de.xliff | 96 +++++++- .../en.xcloc/Localized Contents/en.xliff | 10 + .../es.xcloc/Localized Contents/es.xliff | 8 + .../fi.xcloc/Localized Contents/fi.xliff | 8 + .../fr.xcloc/Localized Contents/fr.xliff | 76 +++++++ .../it.xcloc/Localized Contents/it.xliff | 78 ++++++- .../ja.xcloc/Localized Contents/ja.xliff | 8 + .../nl.xcloc/Localized Contents/nl.xliff | 104 +++++++-- .../pl.xcloc/Localized Contents/pl.xliff | 76 +++++++ .../ru.xcloc/Localized Contents/ru.xliff | 8 + .../th.xcloc/Localized Contents/th.xliff | 8 + .../uk.xcloc/Localized Contents/uk.xliff | 8 + .../Localized Contents/zh-Hans.xliff | 8 + apps/ios/de.lproj/Localizable.strings | 206 ++++++++++++++++- apps/ios/fr.lproj/Localizable.strings | 186 +++++++++++++++ apps/ios/it.lproj/Localizable.strings | 188 ++++++++++++++- apps/ios/nl.lproj/Localizable.strings | 214 ++++++++++++++++-- apps/ios/pl.lproj/Localizable.strings | 186 +++++++++++++++ .../commonMain/resources/MR/de/strings.xml | 91 ++++++-- .../commonMain/resources/MR/fr/strings.xml | 71 +++++- .../commonMain/resources/MR/it/strings.xml | 73 +++++- .../commonMain/resources/MR/nl/strings.xml | 97 ++++++-- .../commonMain/resources/MR/pl/strings.xml | 69 +++++- .../commonMain/resources/MR/uk/strings.xml | 105 ++++++++- .../resources/MR/zh-rCN/strings.xml | 69 +++++- 27 files changed, 1938 insertions(+), 129 deletions(-) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 2b8613f929..77133907fb 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Криптирано съобщение: неочаквана грешка notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Въведете kодa за достъп diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 663dcb022a..fe78e2da82 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Šifrovaná zpráva: neočekávaná chyba notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Zadat heslo diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 00cef659cf..259d7c12f2 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -89,11 +89,12 @@ %@ and %@ + %@ und %@ No comment provided by engineer. %@ and %@ connected - %@ und %@ wurden verbunden + %@ und %@ wurden mit Ihnen verbunden No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ wurde mit Ihnen verbunden No comment provided by engineer. @@ -132,11 +134,12 @@ %@, %@ and %lld members + %@, %@ und %lld Mitglieder No comment provided by engineer. %@, %@ and %lld other members connected - %@, %@ und %lld weitere Mitglieder wurden verbunden + %@, %@ und %lld weitere Mitglieder wurden mit Ihnen verbunden No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld Gruppenereignisse No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld Nachrichten blockiert No comment provided by engineer. %lld messages marked deleted + %lld Nachrichten als gelöscht markiert No comment provided by engineer. %lld messages moderated by %@ + %lld Nachrichten von %@ moderiert No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sek time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Alle neuen Nachrichten von %@ werden verborgen! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Bereits verbunden! No comment provided by engineer. Already joining the group! + Sie sind bereits Mitglied der Gruppe! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Blockieren No comment provided by engineer. Block member + Mitglied blockieren No comment provided by engineer. Block member? + Mitglied blockieren? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Mit Ihnen selbst verbinden? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Mit Ihnen selbst verbinden? +Das ist Ihre eigene SimpleX-Adresse! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Mit Ihnen selbst verbinden? +Das ist Ihr eigener Einmal-Link! No comment provided by engineer. Connect via contact address + Über die Kontakt-Adresse verbinden No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Mit %@ verbinden No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Richtiger Name für %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Gruppe erstellen No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Profil erstellen No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + %lld Nachrichten löschen? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Kontakt löschen und benachrichtigen No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Kontakt löschen? +Das kann nicht rückgängig gemacht werden! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Verschlüsselte Nachricht: Unerwarteter Fehler notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Zugangscode eingeben @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Geben Sie den Gruppennamen ein… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Geben Sie Ihren Namen ein… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Erweitern chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Vollständig dezentralisiert – nur für Mitglieder sichtbar. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Die Gruppe besteht bereits No comment provided by engineer. Group already exists! + Die Gruppe besteht bereits! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Ungültiger Name! No comment provided by engineer. @@ -2884,7 +2927,7 @@ This cannot be undone! It seems like you are already connected via this link. If it is not the case, there was an error (%@). - Es sieht so aus, dass Sie bereits über diesen Link verbunden sind. Wenn das nicht der Fall, gab es einen Fehler (%@). + Es sieht so aus, als ob Sie bereits über diesen Link verbunden sind. Wenn das nicht der Fall ist, gab es einen Fehler (%@). No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Der Gruppe beitreten? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Mit dem aktuellen Profil beitreten No comment provided by engineer. Join your group? This is your link for group %@! + Ihrer Gruppe beitreten? +Das ist Ihr Link für die Gruppe %@! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Die Nachrichten von %@ werden angezeigt! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Gruppe öffnen No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Profilname No comment provided by engineer. Profile name: + Profilname: No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Verbindungsanfrage wiederholen? No comment provided by engineer. Repeat join request? + Verbindungsanfrage wiederholen? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Zum Verbinden antippen No comment provided by engineer. @@ -4876,10 +4930,12 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This is your own SimpleX address! + Das ist Ihre eigene SimpleX-Adresse! No comment provided by engineer. This is your own one-time link! + Das ist Ihr eigener Einmal-Link! No comment provided by engineer. @@ -4926,7 +4982,7 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite **Meine Chat-Profile** ein, um Ihr verborgenes Profil zu sehen. + Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite **Ihre Chat-Profile** ein, um Ihr verborgenes Profil zu sehen. No comment provided by engineer. @@ -4981,14 +5037,17 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Unblock + Freigeben No comment provided by engineer. Unblock member + Mitglied freigeben No comment provided by engineer. Unblock member? + Mitglied freigeben? No comment provided by engineer. @@ -5315,7 +5374,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s You - Meine Daten + Ihre Daten No comment provided by engineer. @@ -5340,31 +5399,39 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s You are already connecting to %@. + Sie sind bereits mit %@ verbunden. No comment provided by engineer. You are already connecting via this one-time link! + Sie sind bereits über diesen Einmal-Link verbunden! No comment provided by engineer. You are already in group %@. + Sie sind bereits Mitglied der Gruppe %@. No comment provided by engineer. You are already joining the group %@. + Sie sind bereits Mitglied der Gruppe %@. No comment provided by engineer. You are already joining the group via this link! + Sie sind über diesen Link bereits Mitglied der Gruppe! No comment provided by engineer. You are already joining the group via this link. + Sie sind über diesen Link bereits Mitglied der Gruppe. No comment provided by engineer. You are already joining the group! Repeat join request? + Sie sind bereits Mitglied dieser Gruppe! +Verbindungsanfrage wiederholen? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Sie haben über diese Adresse bereits eine Verbindung beantragt! No comment provided by engineer. You have already requested connection! Repeat connection request? + Sie haben bereits ein Verbindungsanfrage beantragt! +Verbindungsanfrage wiederholen? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Sie werden verbunden, sobald das Endgerät des Gruppenlink-Hosts online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Sie werden mit allen Gruppenmitgliedern verbunden. No comment provided by engineer. @@ -5586,7 +5658,7 @@ Repeat connection request? Your SimpleX address - Meine SimpleX-Adresse + Ihre SimpleX-Adresse No comment provided by engineer. @@ -5611,7 +5683,7 @@ Repeat connection request? Your chat profiles - Meine Chat-Profile + Ihre Chat-Profile No comment provided by engineer. @@ -5655,16 +5727,17 @@ Sie können es in den Einstellungen ändern. Your preferences - Meine Präferenzen + Ihre Präferenzen No comment provided by engineer. Your privacy - Meine Privatsphäre + Ihre Privatsphäre No comment provided by engineer. Your profile + Mein Profil No comment provided by engineer. @@ -5701,7 +5774,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. Your settings - Meine Einstellungen + Ihre Einstellungen No comment provided by engineer. @@ -5761,6 +5834,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. and %lld other events + und %lld weitere Ereignisse No comment provided by engineer. @@ -5780,6 +5854,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. blocked + blockiert No comment provided by engineer. @@ -5954,6 +6029,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. deleted contact + Gelöschter Kontakt rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 696465adcf..aa128d59d9 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1994,6 +1994,16 @@ This cannot be undone! Encrypted message: unexpected error notification + + Encryption re-negotiation error + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Enter Passcode diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index a27d02aa68..4a76e3ddb4 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Mensaje cifrado: error inesperado notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Introduce Código diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 10e249cfdf..2661dc10a1 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1960,6 +1960,14 @@ This cannot be undone! Salattu viesti: odottamaton virhe notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Syötä pääsykoodi diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 381a50fe8f..6a5f397557 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ et %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ connecté(e) No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ et %lld membres No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld événements de groupe No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld messages bloqués No comment provided by engineer. %lld messages marked deleted + %lld messages marqués comme supprimés No comment provided by engineer. %lld messages moderated by %@ + %lld messages modérés par %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sec time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Tous les nouveaux messages de %@ seront cachés ! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Déjà en connexion ! No comment provided by engineer. Already joining the group! + Groupe déjà rejoint ! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Bloquer No comment provided by engineer. Block member + Bloquer ce membre No comment provided by engineer. Block member? + Bloquer ce membre ? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Se connecter à soi-même ? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Se connecter à soi-même ? +C'est votre propre adresse SimpleX ! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Se connecter à soi-même ? +Il s'agit de votre propre lien unique ! No comment provided by engineer. Connect via contact address + Se connecter via l'adresse de contact No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Se connecter avec %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Corriger le nom pour %@ ? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Créer un groupe No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Créer le profil No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + Supprimer %lld messages ? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Supprimer et en informer le contact No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Supprimer le contact ? +Cette opération ne peut être annulée ! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Message chiffrée : erreur inattendue notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Entrer le code d'accès @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Entrer un nom de groupe… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Entrez votre nom… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Développer chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Entièrement décentralisé – visible que par ses membres. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Le groupe existe déjà No comment provided by engineer. Group already exists! + Ce groupe existe déjà ! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Nom invalide ! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Rejoindre le groupe ? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Rejoindre avec le profil actuel No comment provided by engineer. Join your group? This is your link for group %@! + Rejoindre votre groupe ? +Voici votre lien pour le groupe %@ ! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Les messages de %@ seront affichés ! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Ouvrir le groupe No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Nom du profil No comment provided by engineer. Profile name: + Nom du profil : No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Répéter la demande de connexion ? No comment provided by engineer. Repeat join request? + Répéter la requête d'adhésion ? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Tapez pour vous connecter No comment provided by engineer. @@ -4876,10 +4930,12 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. This is your own SimpleX address! + Voici votre propre adresse SimpleX ! No comment provided by engineer. This is your own one-time link! + Voici votre propre lien unique ! No comment provided by engineer. @@ -4981,14 +5037,17 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Unblock + Débloquer No comment provided by engineer. Unblock member + Débloquer ce membre No comment provided by engineer. Unblock member? + Débloquer ce membre ? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien You are already connecting to %@. + Vous êtes déjà en train de vous connecter à %@. No comment provided by engineer. You are already connecting via this one-time link! + Vous êtes déjà connecté(e) via ce lien unique ! No comment provided by engineer. You are already in group %@. + Vous êtes déjà dans le groupe %@. No comment provided by engineer. You are already joining the group %@. + Vous êtes déjà en train de rejoindre le groupe %@. No comment provided by engineer. You are already joining the group via this link! + Vous êtes déjà en train de rejoindre le groupe via ce lien ! No comment provided by engineer. You are already joining the group via this link. + Vous êtes déjà en train de rejoindre le groupe via ce lien. No comment provided by engineer. You are already joining the group! Repeat join request? + Vous êtes déjà membre de ce groupe ! +Répéter la demande d'adhésion ? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Vous avez déjà demandé une connexion via cette adresse ! No comment provided by engineer. You have already requested connection! Repeat connection request? + Vous avez déjà demandé une connexion ! +Répéter la demande de connexion ? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Vous serez connecté(e) lorsque l'appareil de l'hôte du lien de groupe sera en ligne, veuillez patienter ou vérifier plus tard ! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Vous vous connecterez à tous les membres du groupe. No comment provided by engineer. @@ -5665,6 +5737,7 @@ Vous pouvez modifier ce choix dans les Paramètres. Your profile + Votre profil No comment provided by engineer. @@ -5761,6 +5834,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. and %lld other events + et %lld autres événements No comment provided by engineer. @@ -5780,6 +5854,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. blocked + blocké No comment provided by engineer. @@ -5954,6 +6029,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. deleted contact + contact supprimé rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 1faabca974..72cda836ac 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ e %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ si è connesso/a No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ e %lld membri No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld eventi del gruppo No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld messaggi bloccati No comment provided by engineer. %lld messages marked deleted + %lld messaggi contrassegnati eliminati No comment provided by engineer. %lld messages moderated by %@ + %lld messaggi moderati da %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sec time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Tutti i nuovi messaggi da %@ verrranno nascosti! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Già in connessione! No comment provided by engineer. Already joining the group! + Già in ingresso nel gruppo! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Blocca No comment provided by engineer. Block member + Blocca membro No comment provided by engineer. Block member? + Bloccare il membro? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Connettersi a te stesso? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Connettersi a te stesso? +Questo è il tuo indirizzo SimpleX! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Connettersi a te stesso? +Questo è il tuo link una tantum! No comment provided by engineer. Connect via contact address + Connettere via indirizzo del contatto No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Connettersi con %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Correggere il nome a %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Crea gruppo No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Crea profilo No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + Eliminare %lld messaggi? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Elimina e avvisa il contatto No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Eliminare il contatto? +Non è reversibile! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Messaggio crittografato: errore imprevisto notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Inserisci il codice di accesso @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Inserisci il nome del gruppo… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Inserisci il tuo nome… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Espandi chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Completamente decentralizzato: visibile solo ai membri. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Il gruppo esiste già No comment provided by engineer. Group already exists! + Il gruppo esiste già! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Nome non valido! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Entrare nel gruppo? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Entra con il profilo attuale No comment provided by engineer. Join your group? This is your link for group %@! + Entrare nel tuo gruppo? +Questo è il tuo link per il gruppo %@! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + I messaggi da %@ verranno mostrati! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Apri gruppo No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Nome del profilo No comment provided by engineer. Profile name: + Nome del profilo: No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Ripetere la richiesta di connessione? No comment provided by engineer. Repeat join request? + Ripetere la richiesta di ingresso? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Tocca per connettere No comment provided by engineer. @@ -4876,10 +4930,12 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. This is your own SimpleX address! + Questo è il tuo indirizzo SimpleX! No comment provided by engineer. This is your own one-time link! + Questo è il tuo link una tantum! No comment provided by engineer. @@ -4981,14 +5037,17 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Unblock + Sblocca No comment provided by engineer. Unblock member + Sblocca membro No comment provided by engineer. Unblock member? + Sbloccare il membro? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e You are already connecting to %@. + Ti stai già connettendo a %@. No comment provided by engineer. You are already connecting via this one-time link! + Ti stai già connettendo tramite questo link una tantum! No comment provided by engineer. You are already in group %@. + Sei già nel gruppo %@. No comment provided by engineer. You are already joining the group %@. + Stai già entrando nel gruppo %@. No comment provided by engineer. You are already joining the group via this link! + Stai già entrando nel gruppo tramite questo link! No comment provided by engineer. You are already joining the group via this link. + Stai già entrando nel gruppo tramite questo link. No comment provided by engineer. You are already joining the group! Repeat join request? + Stai già entrando nel gruppo! +Ripetere la richiesta di ingresso? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Hai già richiesto la connessione tramite questo indirizzo! No comment provided by engineer. You have already requested connection! Repeat connection request? + Hai già richiesto la connessione! +Ripetere la richiesta di connessione? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Verrai connesso/a quando il dispositivo dell'host del gruppo sarà in linea, attendi o controlla più tardi! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Ti connetterai a tutti i membri del gruppo. No comment provided by engineer. @@ -5665,6 +5737,7 @@ Puoi modificarlo nelle impostazioni. Your profile + Il tuo profilo No comment provided by engineer. @@ -5761,6 +5834,7 @@ I server di SimpleX non possono vedere il tuo profilo. and %lld other events + e altri %lld eventi No comment provided by engineer. @@ -5780,6 +5854,7 @@ I server di SimpleX non possono vedere il tuo profilo. blocked + bloccato No comment provided by engineer. @@ -5814,7 +5889,7 @@ I server di SimpleX non possono vedere il tuo profilo. changed role of %1$@ to %2$@ - cambiato il ruolo di %1$@ in %2$@ + ha cambiato il ruolo di %1$@ in %2$@ rcv group event chat item @@ -5954,6 +6029,7 @@ I server di SimpleX non possono vedere il tuo profilo. deleted contact + contatto eliminato rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 9f688d1ff5..b574faecb1 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1963,6 +1963,14 @@ This cannot be undone! 暗号化されたメッセージ : 予期しないエラー notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode パスコードを入力 diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 257b2ff0ce..92cb85456f 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ en %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ verbonden No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ en %lld leden No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld groep gebeurtenissen No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld berichten geblokkeerd No comment provided by engineer. %lld messages marked deleted + %lld berichten gemarkeerd als verwijderd No comment provided by engineer. %lld messages moderated by %@ + %lld berichten gemodereerd door %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sec time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Alle nieuwe berichten van %@ worden verborgen! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Al bezig met verbinden! No comment provided by engineer. Already joining the group! + Al lid van de groep! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Blokkeren No comment provided by engineer. Block member + Lid blokkeren No comment provided by engineer. Block member? + Lid blokkeren? No comment provided by engineer. @@ -977,7 +991,7 @@ Change member role? - Rol van gebruiker wijzigen? + Rol van lid wijzigen? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Verbinding maken met jezelf? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Verbinding maken met jezelf? +Dit is uw eigen SimpleX adres! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Verbinding maken met jezelf? +Dit is uw eigen eenmalige link! No comment provided by engineer. Connect via contact address + Verbinding maken via contactadres No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Verbonden met %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Juiste naam voor %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Groep aanmaken No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Maak een profiel aan No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + %lld berichten verwijderen? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Contact verwijderen en op de hoogte stellen No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Verwijder contact? +Dit kan niet ongedaan gemaakt worden! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Versleuteld bericht: onverwachte fout notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Voer toegangscode in @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Groep naam invoeren… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Vul uw naam in… No comment provided by engineer. @@ -2031,7 +2069,7 @@ This cannot be undone! Error adding member(s) - Fout bij het toevoegen van gebruiker(s) + Fout bij het toevoegen van leden No comment provided by engineer. @@ -2161,7 +2199,7 @@ This cannot be undone! Error removing member - Fout bij verwijderen van gebruiker + Fout bij verwijderen van lid No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Uitbreiden chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Volledig gedecentraliseerd – alleen zichtbaar voor leden. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Groep bestaat al No comment provided by engineer. Group already exists! + Groep bestaat al! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Ongeldige naam! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Deelnemen aan groep? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Word lid met huidig profiel No comment provided by engineer. Join your group? This is your link for group %@! + Sluit u aan bij uw groep? +Dit is jouw link voor groep %@! No comment provided by engineer. @@ -3077,22 +3124,22 @@ This is your link for group %@! Member - Gebruiker + Lid No comment provided by engineer. Member role will be changed to "%@". All group members will be notified. - De rol van gebruiker wordt gewijzigd in "%@". Alle groepsleden worden op de hoogte gebracht. + De rol van lid wordt gewijzigd in "%@". Alle groepsleden worden op de hoogte gebracht. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. - De rol van gebruiker wordt gewijzigd in "%@". Het lid ontvangt een nieuwe uitnodiging. + De rol van lid wordt gewijzigd in "%@". Het lid ontvangt een nieuwe uitnodiging. No comment provided by engineer. Member will be removed from group - this cannot be undone! - Gebruiker wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! + Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Berichten van %@ worden getoond! No comment provided by engineer. @@ -3415,7 +3463,7 @@ This is your link for group %@! Only group owners can enable files and media. - Alleen groepseigenaren kunnen bestanden en media inschakelen. + Alleen groep eigenaren kunnen bestanden en media inschakelen. No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Open groep No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Profielnaam No comment provided by engineer. Profile name: + Profielnaam: No comment provided by engineer. @@ -3927,12 +3978,12 @@ This is your link for group %@! Remove member - Gebruiker verwijderen + Lid verwijderen No comment provided by engineer. Remove member? - Gebruiker verwijderen? + Lid verwijderen? No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Verbindingsverzoek herhalen? No comment provided by engineer. Repeat join request? + Deelnameverzoek herhalen? No comment provided by engineer. @@ -4075,7 +4128,7 @@ This is your link for group %@! Save and notify group members - Opslaan en Groep leden melden + Opslaan en groep leden melden No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Tik om verbinding te maken No comment provided by engineer. @@ -4689,12 +4743,12 @@ This is your link for group %@! Tap to join - Tik om mee te doen + Tik om lid te worden No comment provided by engineer. Tap to join incognito - Tik om incognito deel te nemen + Tik om incognito lid te worden No comment provided by engineer. @@ -4876,10 +4930,12 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This is your own SimpleX address! + Dit is uw eigen SimpleX adres! No comment provided by engineer. This is your own one-time link! + Dit is uw eigen eenmalige link! No comment provided by engineer. @@ -4981,14 +5037,17 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Unblock + Deblokkeren No comment provided by engineer. Unblock member + Lid deblokkeren No comment provided by engineer. Unblock member? + Lid deblokkeren? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak You are already connecting to %@. + U maakt al verbinding met %@. No comment provided by engineer. You are already connecting via this one-time link! + Je maakt al verbinding via deze eenmalige link! No comment provided by engineer. You are already in group %@. + Je zit al in groep %@. No comment provided by engineer. You are already joining the group %@. + Je bent al lid van de groep %@. No comment provided by engineer. You are already joining the group via this link! + Je wordt al lid van de groep via deze link! No comment provided by engineer. You are already joining the group via this link. + Je wordt al lid van de groep via deze link. No comment provided by engineer. You are already joining the group! Repeat join request? + Je sluit je al aan bij de groep! +Deelnameverzoek herhalen? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + U heeft al een verbinding aangevraagd via dit adres! No comment provided by engineer. You have already requested connection! Repeat connection request? + Je hebt al verbinding aangevraagd! +Verbindingsverzoek herhalen? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + U wordt verbonden wanneer het apparaat van de groep link host online is. Wacht even of controleer het later opnieuw! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Je maakt verbinding met alle leden. No comment provided by engineer. @@ -5665,6 +5737,7 @@ U kunt dit wijzigen in Instellingen. Your profile + Jouw profiel No comment provided by engineer. @@ -5761,6 +5834,7 @@ SimpleX servers kunnen uw profiel niet zien. and %lld other events + en %lld andere gebeurtenissen No comment provided by engineer. @@ -5780,6 +5854,7 @@ SimpleX servers kunnen uw profiel niet zien. blocked + geblokkeerd No comment provided by engineer. @@ -5954,6 +6029,7 @@ SimpleX servers kunnen uw profiel niet zien. deleted contact + verwijderd contact rcv direct event chat item @@ -6168,7 +6244,7 @@ SimpleX servers kunnen uw profiel niet zien. member - gebruiker + lid member role diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 03ac0250a0..5ec6d3ee36 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ i %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ połączony No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ i %lld członków No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld wydarzeń grupy No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld wiadomości zablokowanych No comment provided by engineer. %lld messages marked deleted + %lld wiadomości oznaczonych do usunięcia No comment provided by engineer. %lld messages moderated by %@ + %lld wiadomości zmoderowanych przez %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sek time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Wszystkie nowe wiadomości z %@ zostaną ukryte! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Już połączony! No comment provided by engineer. Already joining the group! + Już dołączono do grupy! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Zablokuj No comment provided by engineer. Block member + Zablokuj członka No comment provided by engineer. Block member? + Zablokować członka? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Połączyć się ze sobą? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Połączyć się ze sobą? +To jest twój własny adres SimpleX! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Połączyć się ze sobą? +To jest twój jednorazowy link! No comment provided by engineer. Connect via contact address + Połącz przez adres kontaktowy No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Połącz z %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Poprawić imię na %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Utwórz grupę No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Utwórz profil No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + Usunąć %lld wiadomości? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Usuń i powiadom kontakt No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Usunąć kontakt? +To nie może być cofnięte! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Zaszyfrowana wiadomość: nieoczekiwany błąd notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Wprowadź Pin @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Wpisz nazwę grupy… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Wpisz swoją nazwę… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Rozszerz chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + W pełni zdecentralizowana – widoczna tylko dla członków. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Grupa już istnieje No comment provided by engineer. Group already exists! + Grupa już istnieje! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Nieprawidłowa nazwa! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Dołączyć do grupy? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Dołącz z obecnym profilem No comment provided by engineer. Join your group? This is your link for group %@! + Dołączyć do twojej grupy? +To jest twój link do grupy %@! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Wiadomości od %@ zostaną pokazane! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Grupa otwarta No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Nazwa profilu No comment provided by engineer. Profile name: + Nazwa profilu: No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Powtórzyć prośbę połączenia? No comment provided by engineer. Repeat join request? + Powtórzyć prośbę dołączenia? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Dotknij aby połączyć No comment provided by engineer. @@ -4876,10 +4930,12 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This is your own SimpleX address! + To jest twój własny adres SimpleX! No comment provided by engineer. This is your own one-time link! + To jest twój jednorazowy link! No comment provided by engineer. @@ -4981,14 +5037,17 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Unblock + Odblokuj No comment provided by engineer. Unblock member + Odblokuj członka No comment provided by engineer. Unblock member? + Odblokować członka? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc You are already connecting to %@. + Już się łączysz z %@. No comment provided by engineer. You are already connecting via this one-time link! + Już jesteś połączony z tym jednorazowym linkiem! No comment provided by engineer. You are already in group %@. + Już jesteś w grupie %@. No comment provided by engineer. You are already joining the group %@. + Już dołączasz do grupy %@. No comment provided by engineer. You are already joining the group via this link! + Już dołączasz do grupy przez ten link! No comment provided by engineer. You are already joining the group via this link. + Już dołączasz do grupy przez ten link. No comment provided by engineer. You are already joining the group! Repeat join request? + Już dołączasz do grupy! +Powtórzyć prośbę dołączenia? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Już prosiłeś o połączenie na ten adres! No comment provided by engineer. You have already requested connection! Repeat connection request? + Już prosiłeś o połączenie! +Powtórzyć prośbę połączenia? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Zostaniesz połączony, gdy urządzenie hosta grupy będzie online, proszę czekać lub sprawdzić później! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Zostaniesz połączony ze wszystkimi członkami grupy. No comment provided by engineer. @@ -5665,6 +5737,7 @@ Możesz to zmienić w Ustawieniach. Your profile + Twój profil No comment provided by engineer. @@ -5761,6 +5834,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. and %lld other events + i %lld innych wydarzeń No comment provided by engineer. @@ -5780,6 +5854,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. blocked + zablokowany No comment provided by engineer. @@ -5954,6 +6029,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. deleted contact + usunięto kontakt rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 672a9071c3..402fdc9658 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Зашифрованное сообщение: неожиданная ошибка notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Введите Код diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 44342f0885..e1451378a5 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1946,6 +1946,14 @@ This cannot be undone! ข้อความที่ encrypt: ข้อผิดพลาดที่ไม่คาดคิด notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode ใส่รหัสผ่าน diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index b4ce70f7df..b3b5e9d39a 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1956,6 +1956,14 @@ This cannot be undone! Зашифроване повідомлення: несподівана помилка notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Введіть пароль diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index fba15d68cf..929b54a631 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! 加密消息:意外错误 notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode 输入密码 diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 2fe8d87b69..00489be829 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -119,11 +119,17 @@ "%@ %@" = "%@ %@"; /* No comment provided by engineer. */ -"%@ and %@ connected" = "%@ und %@ wurden verbunden"; +"%@ and %@" = "%@ und %@"; + +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ und %@ wurden mit Ihnen verbunden"; /* copied message info, at Datenschutz & Sicherheit - Meine Privatsphäre + Ihre Privatsphäre App-Bildschirm schützen Bilder automatisch akzeptieren Link-Vorschau senden @@ -712,7 +712,7 @@ Die Gruppeneinladung ist abgelaufen hat %1$s eingeladen. - beigetreten + verbunden hat die Gruppe verlassen änderte die Rolle von %s auf %s änderte Ihre Rolle auf %s @@ -813,8 +813,8 @@ Empfängeradresse wechseln Geheime Gruppe erstellen - Die Gruppe ist vollständig dezentralisiert – sie ist nur für Mitglieder sichtbar. - Anzeigename der Gruppe: + Vollständig dezentralisiert – nur für Mitglieder sichtbar. + Geben Sie den Gruppennamen ein: Vollständiger Gruppenname: Ihr Chat-Profil wird an die Gruppenmitglieder gesendet @@ -1001,7 +1001,7 @@ Transport-Isolation Chat-Profil löschen\? Fehler beim Löschen des Benutzerprofils - Meine Chat-Profile + Ihre Chat-Profile Verbindung Chat-Profil Dateien für alle Chat-Profile löschen @@ -1084,7 +1084,7 @@ Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind. Begrüßungsmeldung Sie können ein Benutzerprofil verbergen oder stummschalten – für das Menü gedrückt halten. - Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite \"Meine Chat-Profile\" ein, um Ihr verborgenes Profil zu sehen. + Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite \"Ihre Chat-Profile\" ein, um Ihr verborgenes Profil zu sehen. Migrations-Bestätigung ungültig Aktualisieren und den Chat öffnen Datenbank-Aktualisierungen bestätigen @@ -1451,9 +1451,9 @@ SimpleX kann nicht im Hintergrund ablaufen. Sie erhalten Benachrichtigungen nur dann, solange die App läuft. Die App kann nach einer Minute im Hintergrund geschlossen werden. App-Akkuverbrauch / Unbeschränkt , um Benachrichtigungen zu aktivieren.]]> - %s, %s und %d weitere Mitglieder wurden verbunden - %s und %s wurden verbunden - %s, %s und %s wurden verbunden + %s, %s und %d weitere Mitglieder wurden mit Ihnen verbunden + %s und %s wurden mit Ihnen verbunden + %s, %s und %s wurden mit Ihnen verbunden Nachrichtenentwurf Letzte Nachrichten anzeigen Die Datenbank wird verschlüsselt und das Passwort in den Einstellungen gespeichert. @@ -1487,4 +1487,59 @@ \n- Schneller und stabiler. Direktnachricht senden Direkt miteinander verbunden + Erweitern + Verbindungsanfrage wiederholen? + Gelöschter Kontakt + Sie sind bereits mit %1$s verbunden. + Fehler + Sie sind über diesen Link bereits Mitglied der Gruppe. + Gruppe erstellen + Profil erstellen + %s und %s + Ihrer Gruppe beitreten? + Sie sind bereits Mitglied in der Gruppe %1$s. + Das ist Ihr eigener Einmal-Link! + %d Nachrichten als gelöscht markiert + Gruppe besteht bereits! + Bereits verbunden! + Das Video kann nicht dekodiert werden. Bitte probieren Sie ein anderes Video aus, oder kontaktieren Sie die Entwickler. + %s wurde mit Ihnen verbunden + und %d weitere Ereignisse + Über einen Link verbinden? + Sie sind bereits Mitglied der Gruppe! + %s, %s und %d Mitglieder + %d Nachrichten von %s moderiert + Mitglied freigeben + Mit Ihnen selbst verbinden? + Zum Verbinden antippen + Sie sind bereits Mitglied in der Gruppe %1$s. + Das ist Ihre eigene SimpleX-Adresse! + Richtiger Name für %s? + %d Nachrichten löschen? + Mit %1$s verbinden? + Mitglied entfernen + Blockieren + Mitglied freigeben? + %d Nachrichten blockiert + Mitglied blockieren + Verbindungsanfrage wiederholen? + Mitglied entfernen? + Kontakt löschen und benachrichtigen + Sie sind bereits über diesen Einmal-Link verbunden! + Gruppe öffnen + Die Nachrichten von %s werden angezeigt! + Fehler beim Senden der Einladung + Sie haben einen ungültigen Datei-Pfad geteilt. Bitte melden Sie diesen Fehler den App-Entwicklern. + Mitglied blockieren? + %d Gruppenereignisse + Ungültiger Name! + Das ist Ihr Link für die Gruppe %1$s! + Freigeben + Ungültiger Datei-Pfad + Sie haben über diese Adresse bereits eine Verbindung beantragt! + Die Konsole in einem neuen Fenster anzeigen + Alle neuen Nachrichten von %s werden ausgeblendet! + blockiert + Fehler bei der Neuverhandlung der Verschlüsselung + Neuverhandlung der Verschlüsselung fehlgeschlagen \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 590ba49db8..cdf07dff6d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1,13 +1,13 @@ k - Se connecter via le lien du contact \? + Se connecter via l\'adresse du contact ? SimpleX Votre profil va être envoyé au contact qui vous a envoyé ce lien. - Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe. + Vous vous connecterez à tous les membres du groupe. Se connecter - Se connecter via le lien du groupe \? - Se connecter via un lien d\'invitation \? + Rejoindre le groupe ? + Se connecter via un lien unique ? erreur connexion connecté @@ -427,7 +427,7 @@ Tous vos contacts resteront connectés. Partager le lien Supprimer l\'adresse - Nom affiché : + Nom du profil : Nom complet : Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil. Supprimer l\'image @@ -436,7 +436,7 @@ La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité. Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs. Créer le profil - Nom affiché + Saisissez votre nom : Comment utiliser markdown a + b coloré @@ -835,7 +835,7 @@ reçu, non autorisé Autorise votre contact à envoyer des messages éphémères. directe - Le groupe est entièrement décentralisé – il n\'est visible que par ses membres. + Entièrement décentralisé – visible que par ses membres. Les membres du groupes peuvent envoyer des messages éphémères. Revenir en arrière Interdire l’envoi de messages éphémères. @@ -849,7 +849,7 @@ Seulement votre contact peut envoyer des messages éphémères. Vous et votre contact êtes tous deux en mesure d\'envoyer des messages éphémères. Les messages vocaux sont interdits dans ce groupe. - Nom affiché du groupe : + Saisir le nom du groupe : indirecte (%1$s) Groupe Connexion @@ -1406,4 +1406,59 @@ Envoyer un message direct pour vous connecter envoyer un message direct s\'est connecté.e de manière directe + Développer + Répéter la demande de connexion ? + contact supprimé + Vous êtes déjà connecté(e) à %1$s. + Erreur + Vous êtes déjà en train de rejoindre le groupe via ce lien. + Créer un groupe + Créer le profil + %s et %s + Rejoindre votre groupe ? + Vous êtes déjà en train de rejoindre le groupe %1$s. + Voici votre propre lien unique ! + %d messages marqués comme supprimés + Ce groupe existe déjà ! + Déjà en connexion ! + La vidéo ne peut pas être décodée. Veuillez essayer une autre vidéo ou contacter les développeurs. + %s connecté(e) + et %d autres événements + Se connecter via un lien ? + Groupe déjà rejoint ! + %s, %s et %d membres + %d messages modérés par %s + Débloquer ce membre + Se connecter à soi-même ? + Tapez pour vous connecter + Vous êtes déjà dans le groupe %1$s. + Voici votre propre adresse SimpleX ! + Corriger le nom pour %s ? + Supprimer %d messages ? + Se connecter avec %1$s ? + Retirer le membre + Bloquer + Débloquer ce membre ? + %d messages bloqués + Bloquer ce membre + Répéter la requête d\'adhésion ? + Retirer ce membre ? + Supprimer et en informer le contact + Vous êtes déjà connecté(e) via ce lien unique ! + Ouvrir le groupe + Les messages de %s seront affichés ! + Erreur lors de l\'envoi de l\'invitation + Vous avez partagé un chemin de fichier non valide. Signalez le problème aux développeurs de l\'application. + Bloquer ce membre ? + %d événements de groupe + Nom invalide ! + Voici votre lien pour le groupe %1$s ! + Débloquer + Chemin du fichier invalide + Vous avez déjà demandé une connexion via cette adresse ! + Afficher la console dans une nouvelle fenêtre + Tous les nouveaux messages provenant de %s seront cachés ! + blocké + Erreur lors de la renégociation du chiffrement + La renégociation du chiffrement a échoué. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 10af1170b1..3cdd4676b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -11,8 +11,8 @@ In attesa dell\'immagine SimpleX k - Connettere via link di invito\? - Connettere via link del gruppo\? + Connettere via link una tantum? + Entrare nel gruppo? Il tuo profilo verrà inviato al contatto da cui hai ricevuto questo link. Connetti connesso @@ -200,9 +200,9 @@ Conferma Ripristina OK - Connettere via link del contatto\? + Connettere via indirizzo del contatto? Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). - Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri. + Ti connetterai a tutti i membri del gruppo. connessione %1$d Descrizione via %1$s @@ -291,7 +291,7 @@ La password del database è necessaria per aprire la chat. Elimina I messaggi diretti tra i membri sono vietati in questo gruppo. - Nome da mostrare + Inserisci il tuo nome: Aggiungi un contatto: per creare il tuo codice QR una tantum per il tuo contatto.]]> Scansiona codice QR: per connetterti al contatto che ti mostra il codice QR.]]> File @@ -346,7 +346,7 @@ Crea Crea profilo Elimina immagine - Nome da mostrare: + Nome del profilo: Il nome da mostrare non può contenere spazi. Modifica immagine Esci senza salvare @@ -415,7 +415,7 @@ File: %s Gruppo inattivo indirizzo cambiato per te - cambiato il ruolo di %s in %s + ha cambiato il ruolo di %s in %s cambiato il tuo ruolo in %s cambio indirizzo… cambio indirizzo per %s… @@ -458,7 +458,7 @@ Errore nella rimozione del membro Errore nel salvataggio del profilo del gruppo Gruppo - Nome da mostrare del gruppo: + Inserisci il nome del gruppo: Il profilo del gruppo è memorizzato sui dispositivi dei membri, non sui server. sempre Sia tu che il tuo contatto potete eliminare irreversibilmente i messaggi inviati. @@ -828,7 +828,7 @@ Cambia indirizzo di ricezione Sistema Scadenza connessione TCP - Il gruppo è completamente decentralizzato: è visibile solo ai membri. + Completamente decentralizzato: visibile solo ai membri. Il ruolo verrà cambiato in \"%s\". Tutti i membri del gruppo riceveranno una notifica. Il ruolo verrà cambiato in \"%s\". Il membro riceverà un nuovo invito. Aggiorna @@ -1406,4 +1406,59 @@ Invia messaggio diretto per connetterti invia messaggio diretto si è connesso/a direttamente + Espandi + Ripetere la richiesta di connessione? + contatto eliminato + Ti stai già connettendo a %1$s. + Errore + Stai già entrando nel gruppo tramite questo link. + Crea gruppo + Crea profilo + %s e %s + Entrare nel tuo gruppo? + Stai già entrando nel gruppo %1$s. + Questo è il tuo link una tantum! + %d messaggi contrassegnati eliminati + Il gruppo esiste già! + Già in connessione! + Il video non può essere decodificato. Prova un video diverso o contatta gli sviluppatori. + %s si è connesso/a + e altri %d eventi + Connettere via link? + Stai già entrando nel gruppo! + %s, %s e %d membri + %d messaggi moderati da %s + Sblocca membro + Connettersi a te stesso? + Tocca per connettere + Sei già nel gruppo %1$s. + Questo è il tuo indirizzo SimpleX! + Correggere il nome a %s? + Eliminare %d messaggi? + Connettersi con %1$s? + Rimuovi membro + Blocca + Sbloccare il membro? + %d messaggi bloccati + Blocca membro + Ripetere la richiesta di ingresso? + Rimuovere il membro? + Elimina e avvisa il contatto + Ti stai già connettendo tramite questo link una tantum! + Apri gruppo + I messaggi da %s verranno mostrati! + Errore di invio dell\'invito + Hai condiviso un percorso di file non valido. Segnala il problema agli sviluppatori dell\'app. + Bloccare il membro? + %d eventi del gruppo + Nome non valido! + Questo è il tuo link per il gruppo %1$s! + Sblocca + Percorso file non valido + Hai già richiesto la connessione tramite questo indirizzo! + Mostra console in una nuova finestra + Tutti i nuovi messaggi di %s verranno nascosti! + bloccato + Errore di rinegoziazione crittografia + Rinegoziazione crittografia fallita. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 7cbb9bf757..522b22f7ab 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -155,9 +155,9 @@ Verbind verbonden Verbinden - Verbinden via contact link\? - Verbinden via groep link\? - Verbinden via uitnodiging link\? + Verbinden via contact link? + Deelnemen aan groep? + Verbinden via eenmalige link? Contact naam Contact verborgen: Decodeerfout @@ -261,7 +261,7 @@ Scan QR-code.]]> Ontwikkel gereedschap Apparaatverificatie is uitgeschakeld. SimpleX Vergrendelen uitschakelen. - Weergavenaam + Vul uw naam in: Apparaatverificatie is niet ingeschakeld. Je kunt SimpleX Vergrendelen inschakelen via Instellingen zodra je apparaatverificatie hebt ingeschakeld. Directe berichten tussen leden zijn verboden in deze groep. %d bestand(en) met een totale grootte van %s @@ -273,7 +273,7 @@ Verdwijnende berichten Verbinding verbreken Verbinding verbroken - Weergavenaam: + Profielnaam: Weergavenaam mag geen spatie bevatten. %d min %d maanden @@ -300,7 +300,7 @@ Fout bij het importeren van de chat database Database versleutelen\? Groep niet gevonden! - Weergave naam groep: + Groep naam invoeren: Dubbele weergavenaam! Fout bij verzenden van bericht Fout bij wisselen van profiel! @@ -340,7 +340,7 @@ Fout bij verwijderen groep link Groep link Fout bij wisselen van rol - Fout bij verwijderen van gebruiker + Fout bij verwijderen van lid Groep Volledige naam groep: Groep voorkeuren @@ -362,7 +362,7 @@ Groep profiel bewerken Schakel TCP keep-alive in Versleutelen - Fout bij het toevoegen van gebruiker(s) + Fout bij het toevoegen van leden Voer de server handmatig in Fout bij het accepteren van een contactverzoek Groep uitnodiging verlopen @@ -482,12 +482,12 @@ \n3. De verbinding is verbroken. Deel nemen aan groep Verlaten - Gebruiker + Lid link voorbeeld afbeelding - GEBRUIKER + LID BERICHTEN EN BESTANDEN Openen in mobiele app en tik vervolgens op Verbinden in de app.]]> - Gebruiker wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! + Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! Fout bij bezorging van bericht Bericht wordt gemarkeerd voor verwijdering. De ontvanger(s) kunnen dit bericht onthullen. Netwerk status @@ -717,7 +717,7 @@ Opslaan en Contact melden Je huidige profiel Opslaan en Contacten melden - Opslaan en Groepsleden melden + Opslaan en groepsleden melden staking Het berichten- en applicatieplatform dat uw privacy en veiligheid beschermt. Het profiel wordt alleen gedeeld met uw contacten. @@ -775,7 +775,7 @@ De rol wordt gewijzigd in \"%s\". Iedereen in de groep wordt op de hoogte gebracht. Ontvang via Groep profiel opslaan - De groep is volledig gedecentraliseerd – het is alleen zichtbaar voor de leden. + Volledig gedecentraliseerd – alleen zichtbaar voor leden. Timeout van TCP-verbinding Spraak berichten Spraak berichten zijn verboden in dit gesprek. @@ -791,10 +791,10 @@ Databasefout herstellen Onbekende fout Verkeerd wachtwoord! - U bent uitgenodigd voor de groep. Word lid om in contact te komen met groepsleden. - Tik om mee te doen - Tik om incognito deel te nemen - Je bent lid geworden van deze groep. Verbinding maken met uitnodigend groepslid. + U bent uitgenodigd voor de groep. Word lid om in contact te komen met de groepsleden. + Tik om lid te worden + Tik om incognito lid te worden + Je bent lid geworden van deze groep. Verbinding maken met uitnodigend lid. Je hebt een groep uitnodiging verzonden jij bent vertrokken je bent van adres veranderd @@ -802,7 +802,7 @@ Sla het uitnodigen van leden over %d contact(en) geselecteerd Verwijderen - Gebruiker verwijderen + Lid verwijderen Rol Direct bericht sturen De rol wordt gewijzigd in \"%s\". De gebruiker ontvangt een nieuwe uitnodiging. @@ -831,7 +831,7 @@ SimpleX U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. Je profiel wordt verzonden naar het contact van wie je deze link hebt ontvangen. - U wordt lid van de groep waar deze link naar verwijst en maakt verbinding met de groepsleden. + Je maakt verbinding met alle groepsleden. Uitvoeren bij geopende app Toon voorbeeld SimpleX Chat oproepen @@ -949,7 +949,7 @@ %1$d bericht(en) overgeslagen gemodereerd gemodereerd door %s - Bericht van lid verwijderen\? + Bericht van lid verwijderen? Modereren Het bericht wordt verwijderd voor alle leden. Het bericht wordt gemarkeerd als gemodereerd voor alle leden. @@ -1263,7 +1263,7 @@ Annuleer het wijzigen van het adres Afbreken Geen gefilterde gesprekken - Alleen groepseigenaren kunnen bestanden en media inschakelen. + Alleen groep eigenaren kunnen bestanden en media inschakelen. Bestanden en media zijn verboden in deze groep. Favoriet Bestanden en media verboden! @@ -1404,4 +1404,59 @@ Stuur een direct bericht om verbinding te maken stuur een direct bericht direct verbonden + Uitbreiden + Verbindingsverzoek herhalen? + verwijderd contact + Fout + Groep aanmaken + Maak een profiel aan + %s en %s + Deelnemen aan uw groep? + %d berichten gemarkeerd als verwijderd + Groep bestaat al! + Al bezig met verbinden! + De video kan niet worden gedecodeerd. Probeer een andere video of neem contact op met de ontwikkelaars. + %s verbonden + en %d andere gebeurtenissen + Verbinden via link? + Al lid van de groep! + %s, %s en %d leden + %d berichten gemodereerd door %s + Verbinding maken met jezelf? + Tik om verbinding te maken + Juiste naam voor %s? + %d berichten verwijderen? + Verbinding maken met %1$s? + Lid verwijderen + Blokkeren + %d berichten geblokkeerd + Lid blokkeren + Deelnameverzoek herhalen? + Lid verwijderen? + Contact verwijderen en op de hoogte stellen + Open groep + Berichten van %s worden getoond! + Fout bij verzenden van uitnodiging + Lid blokkeren? + %d groep gebeurtenissen + Ongeldige naam! + Ongeldig bestandspad + Console in nieuw venster weergeven + Alle nieuwe berichten van %s worden verborgen! + geblokkeerd + Je bent al verbonden met %1$s. + Je wordt al lid van de groep via deze link. + Je bent al lid van de groep %1$s. + Dit is uw eigen eenmalige link! + Lid deblokkeren + Je zit al in groep %1$s. + Dit is uw eigen SimpleX adres! + Lid deblokkeren? + Je maakt al verbinding via deze eenmalige link! + Je hebt een ongeldig bestandslocatie gedeeld. Rapporteer het probleem aan de app-ontwikkelaars. + Dit is jouw link voor groep %1$s! + Deblokkeren + U heeft al een verbinding aangevraagd via dit adres! + Fout bij heronderhandeling van codering + Opnieuw onderhandelen over de codering is mislukt. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 4e14345e94..e61f040a89 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -9,9 +9,9 @@ łączenie… połączenie ustanowione połączenie %1$d - Połączyć się przez link kontaktowy\? - Połącz się przez link grupowy\? - Połączyć się przez link zapraszający\? + Połączyć się przez adres kontaktowy? + Dołączyć do grupy? + Połączyć się przez link jednorazowy? usunięty błąd nieprawidłowy czat @@ -367,7 +367,7 @@ Usuń adres Usunąć adres\? Usuń obraz - Wyświetlana nazwa: + Nazwa profilu: Edytuj obraz Wyjdź bez zapisywania Pełna nazwa: @@ -723,7 +723,7 @@ Błąd usuwania członka DLA KONSOLI Grupa - Wyświetlana nazwa grupy: + Wprowadź nazwę grupy: Pełna nazwa grupy: Nazwa lokalna CZŁONEK @@ -741,7 +741,7 @@ SERWERY Przełącz Zmień adres odbioru - Grupa jest w pełni zdecentralizowana – jest widoczna tylko dla członków. + W pełni zdecentralizowana – widoczna tylko dla członków. Rola zostanie zmieniona na \"%s\". Członek otrzyma nowe zaproszenie. Wiadomość powitalna Nie można usunąć profilu użytkownika! @@ -1033,7 +1033,7 @@ usunąłeś %1$s Twój profil jest przechowywany na Twoim urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie widzą Twojego profilu. udostępniłeś jednorazowy link incognito - Dołączysz do grupy, do której odnosi się ten link i połączysz się z jej członkami. + Zostaniesz połączony ze wszystkimi członkami grupy. Twoje serwery SMP Zostaniesz połączony, gdy Twoje żądanie połączenia zostanie zaakceptowane, proszę czekać lub sprawdzić później! Błąd ładowania serwerów SMP @@ -1406,4 +1406,59 @@ Wyślij wiadomość bezpośrednią aby połączyć wyślij wiadomość bezpośrednią połącz bezpośrednio + usunięto kontakt + Utwórz grupę + Utwórz profil + %d wiadomości oznaczonych do usunięcia + Już połączony! + i %d innych wydarzeń + Połączyć przez link? + Już dołączono do grupy! + %d wiadomości zmoderowanych przez %s + Połączyć się ze sobą? + Poprawić nazwę do %s? + Usunąć %d wiadomości? + Połączyć z %1$s? + Zablokuj + %d wiadomości zablokowanych + Zablokuj członka + Usuń i powiadom kontakt + Zablokować członka? + %d wydarzeń grupy + Wszystkie nowe wiadomości z %s będą ukryte! + zablokowany + Rozszerz + Powtórzyć prośbę połączenia? + Już jesteś połączony z %1$s. + Błąd + Już dołączasz do grupy przez ten link. + %s i %s + Dołączyć do twojej grupy? + Już dołączasz do grupy %1$s. + To jest twój jednorazowy link! + Grupa już istnieje! + Wideo nie może zostać zdekodowane, spróbuj inne wideo lub skontaktuj się z deweloperami. + %s połączony + %s, %s i %d członków + Odblokuj członka + Dotknij aby połączyć + Już jesteś w grupie %1$s. + To jest twój własny adres SimpleX! + Usuń członka + Odblokować członka? + Powtórzyć prośbę dołączenia? + Usunąć członka? + Już jesteś połączony z tym jednorazowym linkiem! + Grupa otwarta + Wiadomości z %s zostaną wyświetlone! + Błąd wysyłania zaproszenia + Udostępniłeś nieprawidłową ścieżkę pliku. Zgłoś problem do deweloperów aplikacji. + Nieprawidłowa nazwa! + To jest twój link zaproszenia do grupy %1$s! + Odblokuj + Nieprawidłowa ścieżka pliku + Już prosiłeś o połączenie na ten adres! + Pokaż konsolę w nowym oknie + Błąd renegocjacji szyfrowania + Renegocjacja szyfrowania nie powiodła się. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index fbf62d4f35..dd8d9ac857 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -116,11 +116,11 @@ SimpleX Дзвонити можете як ви, так і ваш контакт. k - Підключитися за контактним посиланням\? - Підключитися за посиланням-запрошенням\? - Підключитися за груповим посиланням\? + Підключитися через контактну адресу? + Підключитися за одноразовим посиланням? + Приєднатися до групи? Ваш профіль буде надіслано контакту, від якого ви отримали це посилання. - Ви приєднаєтеся до групи, на яку посилається це посилання, і з\'єднаєтеся з її учасниками. + Ви з\'єднаєтеся з усіма учасниками групи. Підключіться підключений помилка @@ -265,7 +265,7 @@ Повне ім\'я: Ви керуєте своїм чатом! Платформа для обміну повідомленнями та додатків, що захищає вашу конфіденційність і безпеку. - Відображуване ім\'я + Введіть своє ім\'я: виклик у процесі починаючи… Перша платформа без жодних ідентифікаторів користувачів – приватна за дизайном. @@ -411,7 +411,7 @@ Кілька профілів чату Основна версія: v%s Видалити адресу\? - Відображуване ім\'я: + Ім\'я профілю: чекаємо на підтвердження… Переосмислення конфіденційності Люди можуть підключатися до вас лише за посиланнями, якими ви ділитеся. @@ -488,7 +488,7 @@ Видалити посилання Перемикач Помилка, що змінює роль - Відображувана назва групи: + Введіть назву групи: Повна назва групи: Помилка збереження профілю групи Скидання до налаштувань за замовчуванням @@ -643,7 +643,8 @@ Використовуєте сервери SimpleX Chat\? Сервери ICE (по одному на лінію) Помилка збереження серверів ICE - Для з\'єднання будуть потрібні хости onion. + Onion hosts will be required for connection. +\nPlease note: you will not be able to connect to the servers without .onion address. Onion хости будуть використовуватися за наявності. Для з\'єднання будуть потрібні хости onion. Показати опції розробника @@ -1101,7 +1102,7 @@ Введіть вітальне повідомлення… Змінити адресу отримання Створити секретну групу - Група повністю децентралізована - її бачать лише учасники. + Повністю децентралізована - видима лише для учасників. Тайм-аут підключення TCP Більше не показувати Вторинний @@ -1372,4 +1373,90 @@ Переузгодьте шифрування\? Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з\'єднання! Учасники групи можуть надсилати файли та медіа. + База даних буде зашифрована, а ключова фраза збережена в налаштуваннях. + Розгорнути + Повторити запит на підключення? + Помилка повторного узгодження шифрування + видалений контакт + Ви вже з\'єднані з %1$s. + Відкрити + Шифрування збережених файлів і носіїв + Помилка + Помилка при створенні контакту користувача + Ви вже приєдналися до групи за цим посиланням. + Створити групу + Зверніть увагу: ретранслятори повідомлень і файлів підключаються через проксі SOCKS. Дзвінки та надсилання попередніх переглядів посилань використовують пряме з’єднання.]]> + Створити профіль + %s та %s + Приєднатися до групи? + Ви вже приєдналися до групи %1$s. + Шифрування локальних файлів + Це ваше власне одноразове посилання! + %d повідомлень позначено як видалені + Новий десктопний застосунок! + 6 нових мов інтерфейсу + Група вже існує! + Застосунок шифрує нові локальні файли (крім відео). + Вже під\'єднуємося! + Випадкова фраза зберігається у налаштуваннях у вигляді відкритого тексту. +\nВи можете змінити його пізніше. + Надішліть пряме повідомлення, щоб підключитися + Відео не може бути декодовано. Будь ласка, спробуйте інше відео або зверніться до розробників. + %s підключено + ще інших подій - %d + Знаходьте та приєднуйтесь до груп + Підключитися за посиланням? + Пароль для шифрування бази даних буде оновлено і збережено в налаштуваннях. + Вже долучаємось до групи! + члени %s, %s та %d + %d повідомлень модерує %s + Видалити парольну фразу з налаштувань? + Розблокувати + Використовуйте випадкову парольну фразу + Підключитися до себе? + Зберегти парольну фразу в налаштуваннях + Спрощений режим інкогніто + Натисніть, щоб підключитися + Ключова фраза для налаштування бази даних + Ви вже в групі %1$s. + Це ваша власна SimpleX адреса! + Повторне узгодження шифрування не вдалося. + Виправити ім\'я на %s? + Видалити %d повідомлень? + Підключитися до %1$s? + Видалити учасника + Встановити пароль до бази даних + Заблокувати + Розблокувати учасника? + %d повідомлень заблоковано + Заблокувати учасника + Відкрийте теку з базою даних + Повторити запит на приєднання? + Видалити учасника? + Видалити та повідомити контакт + Арабська, Болгарська, Фінська, Іврит, Тайська та Українська – завдяки користувачам і Weblate. + Ви вже підключаєтеся за цим одноразовим посиланням! + Відкрити групу + Будуть показані повідомлення від %s! + Створіть новий профіль у десктопному застосунку. 💻 + Помилка надсилання запрошення + Після зміни пароля або перезапуску програми він буде збережений у налаштуваннях у вигляді відкритого тексту. + Ви надали невірний шлях до файлу. Повідомте про проблему розробникам програми. + Заблокувати учасника? + %d групових подій + Неправильне ім\'я! + Увімкніть інкогніто при підключенні. + Це ваше посилання для групи %1$s! + Розблокувати + Неправильний шлях до файлу + - підключитися до служби каталогів (БЕТА)! +\n- квитанції про доставку (до 20 учасників). +\n- швидше і стабільніше. + Пароль зберігається у налаштуваннях у вигляді відкритого тексту. + Ви вже надсилали запит на підключення за цією адресою! + надіслати пряме повідомлення + Показати консоль у новому вікні + Всі повідомлення від %s будуть приховані + підключений безпосередньо + заблоковано \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 1d266beaf4..a4f990455f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -62,9 +62,9 @@ 删除消息? 删除消息 连接 - 通过邀请链接进行连接? - 通过联系人链接进行连接? - 通过群组链接连接? + 通过一次性链接进行连接? + 通过联系人地址进行连接? + 加群吗? 通过群组链接/二维码连接 总是通过中继连接 允许您的联系人不可撤回地删除已发送消息。 @@ -358,7 +358,7 @@ 核心版本: v%s 显示名: 全名: - 显示名 + 输入你的名字: 已结束 群组已删除 将为所有成员删除群组——此操作无法撤消! @@ -410,7 +410,7 @@ 保存 ICE 服务器错误 发送消息错误 完整链接 - 群组显示名称: + 输入群组名: 群组邀请已过期 将为您删除群组——此操作无法撤消! 群组资料存储在成员的设备上,而不是服务器上。 @@ -735,7 +735,7 @@ %s 未验证 感谢用户——通过 Weblate 做出贡献! 第一个没有任何用户标识符的平台——专为隐私保护设计。 - 该小组是完全分散式的——它只对成员可见。 + 完全去中心化 - 仅对成员可见。 图像无法解码。 请尝试不同的图像或联系开发者。 主题 此操作无法撤消——所有接收和发送的文件和媒体都将被删除。 低分辨率图片将保留。 @@ -918,7 +918,7 @@ 间接(%1$s) 在移动应用程序中打开按钮。]]> SimpleX - 您将加入此链接指向的群组并连接到其群组成员。 + 你将连接到所有群成员。 通过群组链接 通过一次性链接 通过联系地址链接 @@ -1406,4 +1406,59 @@ 发送私信来连接 发送私信 已直连 + 展开 + 重复连接请求吗? + 已删除联系人 + 你已经在连接到 %1$s。 + 错误 + 你已经在通过此链接加入该群。 + 建群 + 创建个人资料 + %s 和 %s + 加入你的群吗? + 你已经在加入 %1$s 群。 + 这是你自己的一次性链接! + %d 条消息被标记为删除 + 群已存在! + 已经在连接了! + 无法解码该视频。请尝试不同视频或联络开发者。 + %s 已连接 + 及其他 %d 个事件 + 通过链接进行连接吗? + 已经加入了该群组! + %s、 %s 和 %d 名成员 + %s 审核了 %d 条消息 + 解封成员 + 连接到你自己? + 轻按连接 + 你已经在%1$s 群内。 + 这是你自己的 SimpleX 地址! + 更正名称为 %s? + 删除 %d 条消息吗? + 和 %1$s 连接吗? + 删除成员 + 封禁 + 解封成员吗? + 拦截了 %d 条消息 + 封禁成员 + 重复加入请求吗? + 删除成员吗? + 删除并通知联系人 + 你已经在通过这个一次性链接进行连接! + 打开群 + 将显示来自 %s 的消息! + 发送邀请出错 + 你分享了无效的文件路径。请将此问题报告给应用开发者。 + 封禁成员吗? + %d 个群事件 + 无效名称! + 这是给你的 %1$s 群链接! + 解封 + 无效的文件路径 + 你已经请求通过此地址进行连接! + 在新窗口中显示控制台 + 所有来自 %s 的新消息都将被隐藏! + 已封禁 + 加密重协商错误 + 加密重协商失败了。 \ No newline at end of file From bf8457fb40615a494263108424429c44ecfa6a08 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:14:09 +0000 Subject: [PATCH 093/174] website: translations (#3396) * Translated using Weblate (French) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ * Translated using Weblate (Italian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ * Translated using Weblate (Polish) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Translated using Weblate (Russian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ * Translated using Weblate (French) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ * Translated using Weblate (Italian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ * Translated using Weblate (Polish) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Translated using Weblate (Russian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ --------- Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: M1K4 Co-authored-by: Random Co-authored-by: Eric Co-authored-by: B.O.S.S Co-authored-by: Timur Bagautdinov --- website/langs/fr.json | 6 +- website/langs/it.json | 6 +- website/langs/nl.json | 6 +- website/langs/pl.json | 15 ++- website/langs/ru.json | 255 ++++++++++++++++++++++++++++++++++++- website/langs/zh_Hans.json | 6 +- 6 files changed, 277 insertions(+), 17 deletions(-) diff --git a/website/langs/fr.json b/website/langs/fr.json index b4d0ea17af..304630a091 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -35,12 +35,12 @@ "hero-overlay-1-title": "Comment fonctionne SimpleX ?", "hero-overlay-2-title": "Pourquoi les IDs d'utilisateur sont nuisibles pour votre vie privée ?", "feature-1-title": "Messages chiffrés E2E avec édition et format markdown", - "feature-2-title": "Images et fichiers
chiffrés E2E", - "feature-3-title": "Groupes secrets décentralisés —
seuls ses membres connaissent leur existence", + "feature-2-title": "Images, vidéos et fichiers
chiffrés E2E", + "feature-3-title": "Groupes chiffrés E2E décentralisés — où seuls les membres en connaissent l'existence", "feature-4-title": "Messages vocaux chiffrés E2E", "feature-5-title": "Conversations secrètes éphémères", "feature-6-title": "Appels audio et vidéo
chiffrés E2E", - "feature-7-title": "Base de données chiffrée portable — déplacez votre profil vers un autre appareil", + "feature-7-title": "Stockage de l'app portable chiffré — déplacez votre profil vers un autre appareil", "feature-8-title": "Mode incognito —
unique à SimpleX Chat", "simplex-network-overlay-1-title": "Comparaison avec des protocoles de messagerie P2P", "simplex-private-1-title": "2 couches de
chiffrement de bout en bout", diff --git a/website/langs/it.json b/website/langs/it.json index b8a83aee52..1f3ad7fdda 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -154,12 +154,12 @@ "home": "Home", "developers": "Sviluppatori", "simplex-explained-tab-1-p-1": "Puoi creare contatti e gruppi, ed avere conversazioni bidirezionali, come in qualsiasi altro messenger.", - "feature-2-title": "Immagini e file
crittografati E2E", + "feature-2-title": "Immagini, video e file
crittografati E2E", "simplex-private-card-4-point-2": "Per usare SimpleX tramite Tor, installa l'app Orbot e attiva il proxy SOCKS5 (o VPN su iOS).", "reference": "Riferimenti", "simplex-explained-tab-3-text": "3. Cosa vedono i server", "chat-bot-example": "Esempio di chat bot", - "feature-3-title": "Gruppi segreti decentralizzati —
solo gli utenti sanno che esistono", + "feature-3-title": "Gruppi decentralizzati crittografati E2E — solo gli utenti sanno che esistono", "blog": "Blog", "simplex-explained-tab-2-p-1": "Per ogni connessione usi due code di messaggi distinte per inviare e ricevere i messaggi attraverso server diversi.", "simplex-explained-tab-2-p-2": "I server passano i messaggi solo in una direzione, senza avere il quadro completo della conversazione dell'utente o delle connessioni.", @@ -169,7 +169,7 @@ "feature-5-title": "Conversazioni segrete a tempo", "feature-6-title": "Chiamate audio e video
crittografate E2E", "simplex-private-6-title": "Scambio di chiavi
fuori banda", - "feature-7-title": "Database crittografato trasferibile — sposta il tuo profilo su un altro dispositivo", + "feature-7-title": "Archiviazione dell'app crittografata e trasferibile — sposta il profilo su un altro dispositivo", "simplex-private-card-1-point-2": "Cryptobox NaCL in ogni coda per evitare correlazioni di traffico tra code di messaggi se il TLS è compromesso.", "simplex-private-card-3-point-3": "Ripresa della connessione disattivata per evitare attacchi alla sessione.", "hero-overlay-card-1-p-4": "Questo design impedisce la fuoriuscita di metadati degli utenti a livello di applicazione. Per aumentare ulteriormente la privacy e proteggere il tuo indirizzo IP puoi connetterti ai server di messaggistica tramite Tor.", diff --git a/website/langs/nl.json b/website/langs/nl.json index 0cb3428563..51c5855b51 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -28,8 +28,8 @@ "hero-overlay-1-title": "Hoe werkt SimpleX?", "hero-overlay-2-title": "Waarom zijn gebruikers-ID's slecht voor de privacy?", "feature-1-title": "E2E-versleutelde berichten met markdown en bewerking", - "feature-2-title": "E2E-versleutelde
afbeeldingen en bestanden", - "feature-3-title": "Gedecentraliseerde geheime groepen -
alleen gebruikers weten dat ze bestaan", + "feature-2-title": "E2E-versleutelde
afbeeldingen,videos en bestanden", + "feature-3-title": "E2E-gecodeerde gedecentraliseerde groepen - alleen gebruikers weten dat ze bestaan", "feature-4-title": "E2E-versleutelde spraakberichten", "feature-5-title": "Verdwijnende geheime gesprekken", "feature-6-title": "E2E-versleutelde
audio- en videogesprekken", @@ -60,7 +60,7 @@ "hero-p-1": "Andere apps hebben gebruikers-ID's: Signal, Matrix, Session, Briar, Jami, Cwtch, enz.
SimpleX niet, zelfs geen willekeurige getallen.
Dit verbetert uw privacy.", "hero-2-header-desc": "De video laat zien hoe je verbinding maakt met een vriend via een persoonlijk of videolink gedeelde eenmalige QR-code. U kunt ook verbinding maken door een uitnodigingslink te delen.", "hero-header": "Privacy opnieuw gedefinieerd", - "feature-7-title": "Portable versleutelde database — verplaats je profiel naar een ander apparaat", + "feature-7-title": "Draagbare gecodeerde app-opslag - profiel naar een ander apparaat verplaatsen", "simplex-private-card-1-point-1": "Protocol met double-ratchet -
OTR-berichten met perfecte voorwaartse geheimhouding en inbraak herstel.", "simplex-private-6-title": "Out-of-band
sleuteluitwisseling", "simplex-private-7-title": "Bericht integriteit
verificatie", diff --git a/website/langs/pl.json b/website/langs/pl.json index f9b9936848..ba0c72da7e 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -27,11 +27,11 @@ "hero-overlay-1-title": "Jak działa SimpleX?", "hero-overlay-2-title": "Dlaczego identyfikatory użytkowników (ID) są złe dla prywatności?", "feature-1-title": "Wiadomości szyfrowane przez E2E ze składnią markdown i edycją", - "feature-3-title": "Zdecentralizowane tajne grupy —
tylko użytkownicy wiedzą, że te grupy istnieją", + "feature-3-title": "Zaszyfrowane E2E zdecentralizowane grupy — tylko użytkownicy wiedzą, że te grupy istnieją", "feature-4-title": "Wiadomości głosowe zaszyfrowane przez E2E", "feature-5-title": "Znikające wiadomości", "feature-6-title": "Połączenia audio i wideo
szyfrowane przez E2E", - "feature-7-title": "Przenośna zaszyfrowana baza danych — przenieś swój profil na inne urządzenie", + "feature-7-title": "Przenośna zaszyfrowana pamięć aplikacji — przenieś profil na inne urządzenie", "simplex-private-4-title": "Możliwy
dostęp za pośrednictwem sieci Tor", "simplex-private-5-title": "Wielowarstwowy
padding treści", "simplex-private-6-title": "Wymiana kluczy
out-of-band", @@ -49,7 +49,7 @@ "simplex-explained-tab-2-p-2": "Serwery przekazują wiadomości tylko w jedną stronę, nie mając pełnego obrazu konwersacji i połączeń użytkownika.", "simplex-explained-tab-3-p-2": "Użytkownicy mogą jeszcze bardziej zwiększyć prywatność metadanych, używając Tor do uzyskania dostępu do serwerów, co zapobiega korelacji na podstawie adresu IP.", "hero-p-1": "Inne aplikacje mają identyfikatory użytkowników (ID): Signal, Matrix, Session, Briar, Jami, Cwtch itp.
SimpleX nie, nie ma nawet losowych numerów.
To radykalnie poprawia Twoją prywatność.", - "feature-2-title": "Obrazy i pliki
zaszyfrowane przez E2E", + "feature-2-title": "Obrazy, wideo i pliki
zaszyfrowane przez E2E", "feature-8-title": "Tryb incognito —
unikalny dla SimpleX Chat", "simplex-network-overlay-1-title": "Porównanie z protokołami komunikacyjnymi P2P", "simplex-private-1-title": "Dwie warstwy
szyfrowania typu end-to-end", @@ -243,5 +243,12 @@ "f-droid-org-repo": "Repo F-Droid.org", "stable-versions-built-by-f-droid-org": "Wersje stabilne zbudowane przez F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "Wydania na tym repo są 1-2 dni później", - "comparison-section-list-point-4a": "Przekaźniki SimpleX nie mogą skompromitować szyfrowania e2e. Zweryfikuj kody bezpieczeństwa aby złagodzić atak na kanał pozapasmowy" + "comparison-section-list-point-4a": "Przekaźniki SimpleX nie mogą skompromitować szyfrowania e2e. Zweryfikuj kody bezpieczeństwa aby złagodzić atak na kanał pozapasmowy", + "hero-overlay-3-title": "Ocena bezpieczeństwa", + "hero-overlay-card-3-p-2": "Trail of Bits przejrzał komponenty kryptograficzne i sieciowe platformy SimpleX w listopadzie 2022.", + "hero-overlay-card-3-p-3": "Przeczytaj więcej w ogłoszeniach.", + "jobs": "Dołącz do zespołu", + "hero-overlay-3-textlink": "Ocena bezpieczeństwa", + "hero-overlay-card-3-p-1": "Trail of Bits jest wiodącą firmą konsultingową w zakresie bezpieczeństwa i technologii, której klientami są duże firmy technologiczne, agencje rządowe i główne projekty blockchain.", + "docs-dropdown-9": "Pliki do pobrania" } diff --git a/website/langs/ru.json b/website/langs/ru.json index 0967ef424b..73fba95480 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -1 +1,254 @@ -{} +{ + "copy-the-command-below-text": "скопируйте приведенную ниже команду и используйте ее в чате:", + "copyright-label": "© 2020-2023 SimpleX | Проект с открытым исходным кодом", + "chat-bot-example": "Пример Чат бота", + "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", + "simplex-private-card-1-point-2": "Криптобокс NaCL в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометации TLS.", + "contact-hero-p-1": "Открытые ключи и адрес очереди сообщений по этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", + "guide-dropdown-5": "Управление данными", + "scan-the-qr-code-with-the-simplex-chat-app": "Отсканируйте QR-код с помощью приложения SimpleX Chat", + "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными посредниками для доставки сообщений.", + "simplex-unique-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", + "feature-7-title": "Портативное, зашифрованное хранилище в приложении — можно перенести профиль на другое устройство", + "no-federated": "Нет - федеративный", + "hero-2-header": "Как начать общаться приватно", + "simplex-unique-overlay-card-3-p-3": "В отличие от серверов федеративных сетей (электронной почты, XMPP или Matrix), серверы SimpleX не хранят учетные записи пользователей, они только ретранслируют сообщения, защищая конфиденциальность обеих сторон.", + "hero-subheader": "Первый мессенджер
не нуждающийся в идентификаторах
пользователя", + "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй - это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистан под подозревается в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", + "signing-key-fingerprint": "Отпечаток ключа подписи (SHA-256)", + "simplex-network-2-desc": "Серверные узлы SimpleX НЕ хранят профили пользователей, контакты и доставленные сообщения, НЕ подключаются друг к другу, и НЕ имеют каталога серверов.", + "simplex-privacy": "Конфиденциальность SimpleX", + "docs-dropdown-5": "Свой XFTP Сервер", + "simplex-private-card-3-point-2": "Отпечаток сервера и привязка канала предотвращают MITM атаки и Атаки повторного воспроизведения.", + "docs-dropdown-3": "Доступ к в базе данных чата", + "installing-simplex-chat-to-terminal": "Установка SimpleX Chat в терминале", + "use-this-command": "Используйте эту команду:", + "simplex-explained": "Простое объяснение вкратце", + "to-make-a-connection": "Чтобы установить соединение:", + "comparison-section-list-point-6": "Хотя P2P распределены, они не являются федеративными, то есть P2P - работают как единая сеть", + "hero-overlay-2-textlink": "Как работает SimpleX?", + "simplex-chat-via-f-droid": "SimpleX Chat в F-Droid", + "privacy-matters-overlay-card-1-p-1": "Многие крупные компании используют информацию о том, с кем вы связаны, чтобы оценить ваш доход, продавать вам больше товаров, которые вам на самом деле не нужны, и определять из этой информации, выгодные для них цены.", + "privacy-matters-1-overlay-1-title": "Конфиденциальность экономит ваши деньги", + "simplex-private-card-5-point-1": "SimpleX использует заполнение содержимого для каждого уровня шифрования, чтобы предотвратить атаки на размер сообщения.", + "privacy-matters-2-overlay-1-linkText": "Конфиденциальность дает вам власть", + "hero-overlay-3-title": "Оценка безопасности", + "enter-your-email-address": "Email адрес", + "simplex-explained-tab-1-text": "1. Как это видят пользователи", + "tap-to-close": "Нажмите, чтобы закрыть", + "simplex-unique-card-4-p-2": "Вы можете использовать SimpleX со своими собственными серверами или с серверами, предоставленными нами — и при этом подключаться к любому пользователю SimpleX.", + "hero-overlay-card-3-p-2": "В ноябре 2022 года Trail of Bits провела обзор криптографии и сетевых компонентов SimpleX.", + "feature-1-title": "Сообщения зашифрованные E2E-шифрованием
с поддержкой markdown и редактированием", + "comparison-point-4-text": "Единая или Централизованная сеть", + "guide-dropdown-9": "Установление соединений", + "simplex-unique-1-overlay-1-title": "Полная конфиденциальность вашей личности, профиля, контактов и других метаданных", + "hero-overlay-card-2-p-4": "SimpleX защищает от этих атак, поскольку в его конструкции нет никаких идентификаторов пользователей. И, если вы используете режим инкогнито, у вас будет другое отображаемое имя для каждого контакта, что позволит избежать какого-либо пересечения между ними.", + "privacy-matters-overlay-card-2-p-2": "Чтобы быть объективным и принимать независимые решения, вам необходимо контролировать свое информационное пространство. Это возможно только в том случае, если вы используете приватную, коммуникационную платформу, которая не имеет доступа к вашему социальному графу.", + "hero-overlay-card-2-p-1": "Когда у пользователей есть постоянные идентификаторы, даже если это просто случайное число, например идентификатор сеанса, существует риск того, что провайдер или злоумышленник могут наблюдайте за тем, как подключены пользователи и сколько сообщений они отправляют.", + "feature-3-title": "Децентрализованные группы — только
их участники знают, что они существуют", + "glossary": "Глоссарий", + "simplex-network-overlay-1-title": "Сравнение с протоколами обмена сообщениями P2P", + "comparison-section-list-point-7": "Сети P2P либо имеют центральный орган управления, либо вся сеть может быть скомпрометирована", + "simplex-unique-overlay-card-4-p-1": "Вы можете использовать SimpleX со своими собственными серверами или предоставленными нами серверами, при этом имея возможность общаться с любым пользователем.", + "simplex-explained-tab-3-p-1": "Серверы имеют отдельные, Анонимные учётные данные для каждой очереди и не знают, к каким пользователям они принадлежат.", + "docs-dropdown-1": "Платформа SimpleX", + "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым, Сквозным шифрованием.", + "simplex-chat-for-the-terminal": "SimpleX Chat для терминала", + "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и большинство существующих реализаций не используют внеполосные сообщения для первоначального обмена ключами. SimpleX использует внеполосные сообщения или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", + "the-instructions--source-code": "SimpleX Chat.", + "simplex-network-section-desc": "SimpleX Chat обеспечивает наилучшую конфиденциальность, сочетая преимущества P2P и федеративных сетей.", + "privacy-matters-section-subheader": "Сохранение конфиденциальности ваших метаданных — с кем вы общаетесь — защищает вас от:", + "if-you-already-installed": "Если вы уже установили", + "simplex-explained-tab-3-p-2": "Пользователи могут еще больше повысить свою конфиденциальность скрыв свой IP-адрес, например используя сеть Tor для доступа к серверам.", + "join": "Присоединяйся к", + "privacy-matters-section-header": "Почему приватность важна", + "hero-overlay-1-textlink": "Почему идентификаторы пользователя - вредны для приватности?", + "on-this-page": "На этой странице", + "privacy-matters-overlay-card-1-p-2": "Интернет-магазины знают, что люди с более низкими доходами с большей вероятностью совершают срочные покупки, поэтому они могут устанавливать более высокие цены или отменять скидки.", + "simplex-unique-3-overlay-1-title": "Контроль и безопасность ваших данных", + "protocol-3-text": "Протоколы P2P", + "simplex-private-card-6-point-2": "Чтобы предотвратить это, приложения SimpleX передают одноразовые ключи внеполосно, когда вы делитесь адресом в виде ссылки или QR-кода.", + "no": "Нет", + "contact-hero-header": "Вы получили адрес для подключения в SimpleX Chat", + "feature-8-title": "Режим инкогнито —
уникальный для SimpleX Chat", + "why-simplex": "Что делает SimpleX уникальным", + "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", + "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на вашем телефоне или планшете.", + "simplex-unique-2-overlay-1-title": "Лучшая защита от спама и злоупотреблений", + "simplex-private-6-title": "Внеполосный
Обмен ключами", + "join-us-on-GitHub": "Присоединяйтесь к нам на GitHub", + "hero-overlay-card-3-p-3": "Подробнее читайте в анонсе.", + "comparison-section-header": "Сравнение с другими протоколами", + "invitation-hero-header": "Вы получили одноразовую ссылку для подключения в SimpleX Chat", + "no-secure": "Нет - безопасно", + "hero-overlay-card-1-p-2": "Для доставки сообщений вместо идентификаторов пользователей, используемых всеми другими платформами, SimpleX использует временные, анонимные, попарные идентификаторы очередей сообщений, отдельно для каждого из ваших контактов — нет долгосрочных идентификаторов.", + "simplex-explained-tab-1-p-2": "Как это может работать с однонаправленными очередями и без идентификаторов профиля пользователя?", + "simplex-network-1-header": "В отличие от P2P-сетей", + "jobs": "Присоединиться к команде", + "simplex-private-card-7-point-2": "Если какое-либо сообщение будет добавлено, удалено или изменено, получатель будет предупрежден об этом.", + "simplex-unique-3-title": "Только вы контролируете
свои данные", + "guide-dropdown-3": "Секретные группы", + "no-resilient": "Нет - устойчив", + "hide-info": "Спрятать информацию", + "privacy-matters-overlay-card-3-p-4": "Недостаточно просто использовать мессенджер со сквозным шифрованием, мы все должны использовать мессенджеры, которые защищают конфиденциальность наших личных сетей — с какими людьми мы связаны.", + "releases-to-this-repo-are-done-1-2-days-later": "Выпуск новых версий в этом репозитории выходит с задержкой в 1-2 дня", + "comparison-point-1-text": "Требуется глобальный идентификатор", + "comparison-section-list-point-5": "Не защищает конфиденциальность пользовательских метаданных", + "hero-overlay-card-2-p-2": "Затем они могли бы сопоставить эту информацию с существующими общедоступными социальными сетями и определить некоторые реальные личности.", + "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные графики для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"премия за бедность\".", + "comparison-point-3-text": "Зависимость от DNS", + "yes": "Да", + "docs-dropdown-6": "Сервера WebRTC", + "newer-version-of-eng-msg": "Существует более новая версия этой страницы на английском языке.", + "install-simplex-app": "Установите приложение SimpleX", + "hero-overlay-3-textlink": "Оценка безопасности", + "comparison-point-2-text": "Возможность MITM", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Открытые ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", + "guide-dropdown-8": "Настройки приложения", + "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной картины общения пользователя или подключений.", + "smp-protocol": "Протокол SMP", + "open-simplex-app": "Откройте приложение SimpleX", + "see-simplex-chat": "Инструкции по загрузке или компиляции SimpleX Chat из исходного кода приведены в", + "terminal-cli": "Приложение для терминала (CLI)", + "comparison-section-list-point-1": "Обычно требуется номера телефона, в некоторых случаях — имя пользователя", + "simplex-explained-tab-3-text": "3. Что видят сервера", + "github-repository": "репозиторий Github", + "feature-5-title": "Исчезающие сообщения", + "connect-in-app": "Подключитесь в приложении", + "menu": "Меню", + "simplex-private-card-4-point-1": "Чтобы защитить свой IP-адрес вы можете подключаться к серверам через сеть Tor или какую-либо другую транспортную оверлейную сеть.", + "privacy-matters-3-title": "Судебное преследование не виновных", + "comparison-point-5-text": "Атака на центральный компонент или другая сетевая атака", + "click-to-see": "Нажать здесь, чтобы увидеть", + "donate-here-to-help-us": "Пожертвуйте здесь, чтобы помочь нам", + "simplex-private-1-title": "2-уровневое
сквозное шифрование", + "simplex-unique-card-1-p-2": "В отличие от любой другой существующей платформы обмена сообщениями, SimpleX не имеет идентификаторов пользователей — нету даже случайных цифр.", + "privacy-matters-2-overlay-1-title": "Конфиденциальность дает вам власть", + "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", + "simplex-unique-4-overlay-1-title": "Полностью децентрализованная — пользователи владеют сетью SimpleX", + "guide-dropdown-2": "Отправка сообщений", + "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по его смягчению требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не имеет функционала по обмену серверами, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку по всей сети - невозможно.", + "simplex-private-2-title": "Дополнительный уровень
шифрования сервера", + "hero-overlay-card-1-p-4": "Такая конструкция предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты вашего IP-адреса вы можете подключиться к серверам обмена сообщениями через сеть Tor.", + "f-droid-org-repo": "Репозиторий F-Droid.org", + "guide-dropdown-4": "Профили чата", + "simplex-network-2-header": "В отличие от федеративных сетей", + "see-here": "подробней тут", + "simplex-private-3-title": "Безопасный аутентифицированный
протокол TLS", + "comparison-section-list-point-3": "Открытый ключ или какой-либо другой глобально уникальный идентификатор", + "hero-overlay-card-2-p-3": "Даже в самых приватных приложениях, использующих скрытые сервисы Tor v3, если вы общаетесь с двумя разными контактами через один и тот же профиль, они могут доказать, что они являются связаны с одним и тем же человеком.", + "simplex-private-4-title": "Вариант доступа
через сеть Tor", + "privacy-matters-1-title": "Реклама и ценовая дискриминация", + "simplex-unique-card-3-p-1": "SimpleX хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных — их можно перенести на другое устройство.", + "hero-overlay-1-title": "Как работает SimpleX?", + "stable-versions-built-by-f-droid-org": "Стабильные версии, созданные F-Droid.org", + "contact-hero-p-3": "Воспользуйтесь ссылками ниже, чтобы загрузить приложение.", + "simplex-network": "Сеть SimpleX", + "privacy-matters-3-overlay-1-title": "Конфиденциальность защищает вашу свободу", + "docs-dropdown-7": "Перевести SimpleX Chat", + "back-to-top": "Вернуться к началу", + "simplex-network-1-desc": "Все сообщения отправляются через серверы, что обеспечивает лучшую конфиденциальность метаданных и надежную асинхронную доставку сообщений, избегая при этом многих", + "simplex-chat-repo": "Репозиторий SimpleX Chat", + "simplex-private-card-6-point-1": "Многие коммуникационные платформы уязвимы для MITM-атак со стороны серверов или сетевых провайдеров.", + "privacy-matters-3-overlay-1-linkText": "Конфиденциальность защищает вашу свободу", + "simplex-unique-overlay-card-1-p-2": "Для доставки сообщений SimpleX использует попарные, анонимные адреса однонаправленных очередей сообщений, раздельные для полученных и отправленных сообщений, обычно через разные серверы. Использование SimpleX это как иметь отдельный “одноразовый” адрес электронной почты или номер телефона для каждого контакта, при это, не обременяя вас управлять эти вручную.", + "simplex-unique-overlay-card-3-p-4": "Со стороны не видно разницу между отправлением или получением сообщений — если кто-то наблюдает за этим, он не cможет легко определить, кто с кем общается, даже если протокол TLS будет скомпрометирован.", + "docs-dropdown-2": "Доступ к файлам в версии для Android", + "get-simplex": "Скачать SimpleX для ПК", + "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть вас опасности, например за ваши политические взгляды, даже если кажется, что вам \"нечего скрывать\".", + "simplex-unique-2-title": "Вы защищены от
спама и злоупотреблений", + "simplex-unique-overlay-card-4-p-3": "Если вы рассматриваете возможность разработки платформе SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", + "comparison-section-list-point-2": "Адреса на основе DNS", + "stable-and-beta-versions-built-by-developers": "Стабильные и бета-версии, созданные разработчиками", + "simplex-network-3-header": "Сеть SimpleX", + "simplex-unique-card-2-p-1": "Поскольку у вас нет идентификатора или фиксированного адреса на платформе SimpleX, никто не сможет связаться с вами, без вашего явного согласия, только если вы сами поделитесь адресом в виде QR-кода или ссылки.", + "hero-overlay-card-3-p-1": "Trail of Bits - ведущая консалтинговая компания в области безопасности и технологий, клиентами которой являются крупные технологические компании, правительственные агентства и крупные блокчейн проекты.", + "hero-header": "Иной взгляд на приватность", + "comparison-section-list-point-4": "Если операторы серверов скомпрометированы. В Signal, и некоторых других приложениях, есть возможность подтвердить код безопасности", + "simplex-private-card-2-point-1": "Дополнительный уровень серверного шифрования для доставки получателю, чтобы предотвратить корреляцию между полученным и отправленным трафиком сервера, если используемый протокол TLS скомпрометированный.", + "f-droid-page-simplex-chat-repo-section-text": "Чтобы добавить его в свой клиент F-Droid, отсканируйте QR-код или воспользуйтесь этим URL-адресом:", + "guide-dropdown-7": "Приватность и безопасность", + "join-the-REDDIT-community": "Присоединяйтесь к нам в сообществе REDDIT", + "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, обеспечивая лучшую конфиденциальность метаданных, чем другие альтернативы.", + "privacy-matters-2-title": "Манипулирование выборами", + "home": "Домашняя страница", + "chat-protocol": "Протокол чата", + "simplex-private-card-5-point-2": "Это позволяет сообщениям разного размера выглядеть одинаково для серверов и сетевых наблюдателей.", + "hero-overlay-card-1-p-1": "Многие спрашивают:Если у SimpleX нету никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", + "feature-6-title": "Зашифрованные E2E-шифрованием аудио и видео звонки", + "hero-p-1": "Другие приложения имеют ID своих пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не имет, нету даже случайных цифр.
Это значительно повышает вашу приватность.", + "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторов его пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователя.", + "simplex-unique-4-title": "Только вы владеете
сетью SimpleX", + "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", + "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", + "blog": "Новости", + "simplex-private-card-7-point-1": "Чтобы гарантировать целостность, сообщения последовательно нумеруются и включают в себя хэш предыдущего сообщения.", + "simplex-unique-overlay-card-4-p-2": "Платформа SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем возможности увидеть, какие сервисы SimpleX вы сможете создать.", + "simplex-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", + "contact-hero-p-2": "Еще не скачали SimpleX Chat?", + "why-simplex-is": "Почему SimpleX", + "simplex-network-section-header": "Сеть SimpleX", + "simplex-private-10-title": "Временные анонимные парные идентификаторы", + "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит ваши деньги", + "tap-the-connect-button-in-the-app": "Нажмите на кнопку ’подключиться’ в приложении", + "comparison-section-list-point-4a": "Сервера SimpleX не могут скомпрометировать сквозное шифрование", + "unique": "уникальный", + "simplex-network-1-overlay-linktext": "проблем P2P сетей", + "no-private": "Нет - приватно", + "simplex-unique-1-title": "У вас есть полная
конфиденциальность", + "protocol-2-text": "XMPP, Matrix", + "guide": "Руководство", + "simplex-network-overlay-card-1-li-4": "Реализации P2P могут быть заблокированы некоторыми интернет-провайдерами (например, BitTorrent). SimpleX не зависит от транспорта - он может работать по стандартным веб-протоколам, например WebSockets.", + "hero-overlay-2-title": "Почему идентификаторы пользователя - вредны для приватности?", + "simplex-explained-tab-2-text": "2. Как это работает", + "docs-dropdown-4": "Свой SMP Сервер", + "feature-4-title": "Зашифрованные E2E-шифрованием голосовые сообщения", + "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши социальные графики для искажения нашего мнения из реального мира и манипулируют нашими голосами.", + "privacy-matters-overlay-card-2-p-3": "SimpleX - это первая платформа, которая по своей конструкции не имеет никаких идентификаторов пользователей, таким образом защищая график ваших контактов лучше, чем любая известная альтернатива.", + "learn-more": "Учить больше", + "donate": "Пожертвовать", + "simplex-private-8-title": "Смешивание сообщений
для уменьшения корреляции", + "scan-qr-code-from-mobile-app": "Отсканируйте QR-код в мобильном приложении", + "simplex-private-card-3-point-3": "Возобновление соединения отключено для предотвращения сеансовых атак.", + "simplex-private-card-10-point-1": "SimpleX использует временные анонимные попарные адреса и учетные данные для каждого контакта пользователя или члена группы.", + "guide-dropdown-6": "Аудио и видео Звонки", + "more-info": "Больше информации", + "no-decentralized": "Нет - децентрализованный", + "protocol-1-text": "Signal, большие платформы", + "hero-2-header-desc": "В видео показано, как подключиться к своему другу с помощью его одноразового QR-кода, лично или по видеосвязи. Вы также можете подключиться, поделившись ссылкой-приглашением.", + "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и усиливать/увеличивать объём трафика, что приводит к отказу в обслуживании по всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для нагрузки трафика всей сети.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Если вы уже установили SimpleX Chat для терминала", + "docs-dropdown-8": "Служба Каталогов SimpleX", + "simplex-private-card-1-point-1": "Протокол с двойным храповым механизмом —
обмен сообщениями OTR с идеальной секретностью пересылки и восстановлением после взлома.", + "simplex-private-card-8-point-1": "Серверы SimpleX действуют как узлы-миксеры с низкой задержкой — входящие и исходящие сообщения имеют разный порядок.", + "simplex-unique-overlay-card-2-p-1": "Поскольку у вас нет идентификатора на платформе SimpleX, никто не сможет связаться с вами, если вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", + "sign-up-to-receive-our-updates": "Введите ваш Email, чтобы получать рассылку обновлений от нас", + "guide-dropdown-1": "Быстрый старт", + "simplex-explained-tab-2-p-1": "Для каждого подключения вы используете две отдельные очереди обмена сообщениями, то есть отправка и получения сообщений происходит через разные серверы.", + "simplex-private-section-header": "Что делает SimpleX приватным", + "we-invite-you-to-join-the-conversation": "Мы приглашаем вас присоединиться к беседе", + "feature-2-title": "Изображения, видео и файлы
зашифрованные E2E-шифрованием", + "simplex-private-9-title": "Однонаправленные
очереди сообщений", + "simplex-unique-overlay-card-1-p-3": "Этот дизайн защищает конфиденциальность того, с кем вы общаетесь, скрывая это от серверов SimpleX и от любых наблюдателей из вне. Чтобы скрыть свой IP-адрес от серверов, вы можете подключиться к серверам SimpleX через сеть Tor.", + "developers": "Разработчики", + "simplex-private-7-title": "Проверка целостности
сообщения", + "privacy-matters-overlay-card-1-p-4": "Платформа SimpleX защищает конфиденциальность ваших контактов лучше, чем любая другая альтернатива, полностью предотвращая доступ к вашему социальному графику каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", + "hero-overlay-card-1-p-6": "Подробнее читайте в техническом документе SimpleX.", + "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", + "terms-and-privacy-policy": "Условия & Политика Конфиденциальности", + "simplex-network-overlay-card-1-li-1": "Сети P2P полагаются на тот или иной вариант DHT для маршрутизации сообщений. Проекты DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети - O(log N).", + "privacy-matters-section-label": "Убедитесь, что ваш мессенджер не может получить доступ к вашим данным!", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных которую можно перенести на другое устройство.", + "simplex-network-3-desc": "серверы предоставляют однонаправленные очереди для подключения пользователей, но у них нет видимости графика сетевых подключений — это делают только пользователи.", + "simplex-unique-card-1-p-1": "SimpleX защищает конфиденциальность вашего профиля, контактов и метаданных, скрывая их от серверов платформы SimpleX и любых наблюдателей.", + "simplex-private-card-3-point-1": "Для соединений клиент-сервер используется только протокол TLS 1.2/1.3 с надежными алгоритмами.", + "simplex-unique-card-4-p-1": "Сеть SimpleX полностью децентрализована и независима от любой криптовалюты/блокчейна или любой другой платформы, кроме Интернета.", + "features": "Возможности", + "hero-overlay-card-1-p-3": "Вы определяете, какие серверы будете использовать для получения сообщений, а ваши контакты — серверы, которые вы используете для отправки им сообщений. Каждый новый чат, скорее всего, будет вестись на двух разных серверах.", + "docs-dropdown-9": "Скачать", + "simplex-chat-protocol": "Протокол SimpleX Chat", + "simplex-unique-overlay-card-1-p-1": "В отличие от других платформ обмена сообщениями, SimpleX не имеет идентификаторов, присвоенных пользователям. Он не полагается на номера телефонов, доменные адреса (например, электронную почту или XMPP), имена пользователей, открытые ключи или даже случайные числа для идентификации своих пользователей — мы не знаем, сколько людей пользуются нашими SimpleX серверами.", + "reference": "Ссылки", + "f-droid-page-f-droid-org-repo-section-text": "Приложение SimpleX Chat от разработчиков и от репозитория F-Droid.org имеют разные ключи подписи. Если вы хотите сменить одно на другое, вам сначала нужно будет экспортировать базу данных и только потом скачать другое приложение.", + "simplex-private-5-title": "Многоуровневое
Заполнения содержимого" +} diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 78e55a1f72..8fb012963a 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -65,13 +65,13 @@ "hero-overlay-2-textlink": "SimpleX 是如何工作的?", "hero-2-header-desc": "右侧的视频向您展示了如何通过一次性二维码、面对面交流或通过视频交换链接来连接到您的朋友。您同样可以通过共享邀请链接来进行连接。", "hero-overlay-1-title": "SimpleX 是如何工作的?", - "feature-2-title": "端到端加密的图像和文件", + "feature-2-title": "端到端加密的
图片、视频和文件", "feature-1-title": "端到端加密的文字消息,支持markdown和编辑", "feature-4-title": "端到端加密的语音消息", - "feature-3-title": "去中心化的秘密群组——只有用户知道它们的存在", + "feature-3-title": "端到端加密的去中心化的秘密群组 —只有用户知道它们的存在", "feature-5-title": "支持消息自动销毁", "simplex-network-overlay-1-title": "与 P2P 通讯协议的比较", - "feature-7-title": "随身的加密数据库——可将您的个人资料移至另一台设备", + "feature-7-title": "便携的加密应用存储— 可将您的个人资料移至另一台设备", "simplex-private-10-title": "临时匿名成对标识符", "simplex-private-8-title": "通过消息混合减少相关性", "simplex-private-card-1-point-2": "每个队列中的网络与密码学库加密盒(NaCL cryptobox)可防止 TLS 受到威胁时消息队列之间的流量关联。", From 2a8d7b8926760045ae1120fdeacfb8dc3087b87f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 19 Nov 2023 20:48:25 +0000 Subject: [PATCH 094/174] core: add commands that will not be forwarded to connected mobile (#3398) * core: add commands that will not be forwarded to connected mobile * fail if command that must be executed locally sent to remote host --- src/Simplex/Chat.hs | 4 +++- src/Simplex/Chat/Controller.hs | 15 ++++++++++++++- src/Simplex/Chat/Terminal/Input.hs | 3 ++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5c8e812a2b..a6d5576133 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -400,7 +400,9 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId cmd s + Just rhId + | allowRemoteCommand cmd -> execRemoteCommand u rhId cmd s + | otherwise -> pure $ CRChatCmdError u $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index dcfa9b431c..3a97d2c32c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -452,8 +452,20 @@ allowRemoteCommand = \case APIStopChat -> False APIActivateChat -> False APISuspendChat _ -> False - SetTempFolder _ -> False QuitChat -> False + SetTempFolder _ -> False + SetFilesFolder _ -> False + SetRemoteHostsFolder _ -> False + APISetXFTPConfig _ -> False + APISetEncryptLocalFiles _ -> False + APIExportArchive _ -> False + APIImportArchive _ -> False + ExportArchive -> False + APIDeleteStorage -> False + APIStorageEncryption _ -> False + APISetNetworkConfig _ -> False + APIGetNetworkConfig -> False + SetLocalDeviceName _ -> False ListRemoteHosts -> False StartRemoteHost _ -> False SwitchRemoteHost {} -> False @@ -1052,6 +1064,7 @@ data RemoteHostError | RHETimeout | RHEBadState -- ^ Illegal state transition | RHEBadVersion {appVersion :: AppVersion} + | RHELocalCommand -- ^ Command not allowed for remote execution | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected? | RHEProtocolError RemoteProtocolError deriving (Show, Exception) diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 090f06c7b3..ecf1255999 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -56,8 +56,9 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do rh <- readTVarIO $ currentRemoteHost cc let bs = encodeUtf8 $ T.pack s cmd = parseChatCommand bs + rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s - r <- runReaderT (execChatCommand rh bs) cc + r <- runReaderT (execChatCommand rh' bs) cc processResp s cmd r printRespToTerminal ct cc False rh r startLiveMessage cmd r From 85e44dcb77192c5f518aeafa05d2d3aeb451be30 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 13:05:59 +0400 Subject: [PATCH 095/174] core: split group message forwarding tests (#3400) --- tests/ChatTests/Groups.hs | 124 ++++++++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 24 deletions(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index edf3d2fab8..9278d4071c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -105,8 +105,14 @@ chatGroupTests = do it "invited member replaces member contact reference if it already exists" testMemberContactInvitedConnectionReplaced it "share incognito profile" testMemberContactIncognito it "sends and updates profile when creating contact" testMemberContactProfileUpdate - describe "forwarding messages" $ do - it "admin should forward messages between invitee and introduced" testGroupMsgForward + describe "group message forwarding" $ do + it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "forward message edit (x.msg.update)" testGroupMsgForwardEdit + it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction + it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion + it "forward file (x.msg.file.descr)" testGroupMsgForwardFile + it "forward role change (x.grp.mem.role)" testGroupMsgForwardChangeRole + it "forward new member announcement (x.grp.mem.new)" testGroupMsgForwardNewMember where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -3902,18 +3908,9 @@ testMemberContactProfileUpdate = testGroupMsgForward :: HasCallStack => FilePath -> IO () testGroupMsgForward = - testChatCfg4 cfg aliceProfile bobProfile cathProfile danProfile $ - \alice bob cath dan -> withXFTPServer $ do - createGroup3 "team" alice bob cath - - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected - - void $ withCCTransaction bob $ \db -> - DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" - void $ withCCTransaction cath $ \db -> - DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" - void $ withCCTransaction alice $ \db -> - DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" @@ -3937,22 +3934,80 @@ testGroupMsgForward = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +setupGroupForwarding3 :: String -> TestCC -> TestCC -> TestCC -> IO () +setupGroupForwarding3 gName alice bob cath = do + createGroup3 gName alice bob cath + + threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + + void $ withCCTransaction bob $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction cath $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + +testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () +testGroupMsgForwardEdit = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + bob ##> "! #team hello there" bob <# "#team [edited] hello there" alice <# "#team bob> [edited] hello there" cath <# "#team bob> [edited] hello there" -- TODO show as forwarded - cath ##> "+1 #team hello there" + alice ##> "/tail #team 1" + alice <# "#team bob> hello there" + + bob ##> "/tail #team 1" + bob <# "#team hello there" + + cath ##> "/tail #team 1" + cath <# "#team bob> hello there [>>]" + +testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO () +testGroupMsgForwardReaction = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + cath ##> "+1 #team hi there" cath <## "added 👍" - alice <# "#team cath> > bob hello there" + alice <# "#team cath> > bob hi there" alice <## " + 👍" - bob <# "#team cath> > bob hello there" + bob <# "#team cath> > bob hi there" bob <## " + 👍" - bob ##> "\\ #team hello there" +testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeletion = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + bob ##> "\\ #team hi there" bob <## "message marked deleted" - alice <# "#team bob> [marked deleted] hello there" - cath <# "#team bob> [marked deleted] hello there" -- TODO show as forwarded + alice <# "#team bob> [marked deleted] hi there" + cath <# "#team bob> [marked deleted] hi there" -- TODO show as forwarded + +testGroupMsgForwardFile :: HasCallStack => FilePath -> IO () +testGroupMsgForwardFile = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + \alice bob cath -> withXFTPServer $ do + setupGroupForwarding3 "team" alice bob cath bob #> "/f #team ./tests/fixtures/test.jpg" bob <## "use /fc 1 to cancel sending" @@ -3972,12 +4027,26 @@ testGroupMsgForward = 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 = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath cath ##> "/mr #team bob member" cath <## "#team: you changed the role of bob from admin to member" alice <## "#team: cath changed the role of bob from admin to member" bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded +testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO () +testGroupMsgForwardNewMember = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + setupGroupForwarding3 "team" alice bob cath + connectUsers cath dan cath ##> "/a #team dan" cath <## "invitation to join the group #team sent to dan" @@ -3994,14 +4063,21 @@ testGroupMsgForward = bob <## "#team: cath added dan (Daniel) to the group (connecting...)", -- TODO show as forwarded dan <## "#team: member alice (Alice) is connected" ] + dan #> "#team hello all" alice <# "#team dan> hello all" -- bob <# "#team dan> hello all [>>]" cath <# "#team dan> hello all" - + bob #> "#team hi all" alice <# "#team bob> hi all" cath <# "#team bob> hi all [>>]" - -- dan <# "#team bob> hi all" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + -- dan <# "#team bob> hi all [>>]" + + bob ##> "/ms team" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, you, connected", + "cath (Catherine): admin, connected", + "dan (Daniel): member" + ] From ba94f76a9006020d661f4938c956d841bbb2be7a Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:33:43 +0200 Subject: [PATCH 096/174] core: fix remote session stuck in Starting after crashed rcConnect (#3399) --- src/Simplex/Chat/Remote.hs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index f1ff0cada7..1af49a2da0 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -133,7 +133,7 @@ startRemoteHost rh_ = do Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) ctrlAppInfo <- mkCtrlAppInfo - (invitation, rchClient, vars) <- withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast + (invitation, rchClient, vars) <- handleConnectError rhKey . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do rhKeyVar <- newTVarIO rhKey @@ -157,6 +157,11 @@ startRemoteHost rh_ = do unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo + handleConnectError :: ChatMonad m => RHKey -> m a -> m a + handleConnectError rhKey action = action `catchChatError` \err -> do + logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err + cancelRemoteHostSession True rhKey + throwError err handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () handleHostError rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err From 3a510eeaf0795fcc8ac5c6df4cd70f66b7aa611a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:00:55 +0400 Subject: [PATCH 097/174] core: rename forwarded fields (#3401) --- src/Simplex/Chat.hs | 8 ++++---- src/Simplex/Chat/Messages.hs | 10 +++++----- src/Simplex/Chat/Store/Messages.hs | 26 +++++++++++++------------- src/Simplex/Chat/View.hs | 4 ++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 524fae2bd8..91ae259445 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5599,20 +5599,20 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> m (ChatItem c 'MDRcv) -saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content ciFile itemTimed live = do +saveRcvChatItem' user cd msg@RcvMessage {forwardedByMember} sharedMsgId_ brokerTs content ciFile itemTimed live = do createdAt <- liftIO getCurrentTime (ciId, quotedItem) <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure (ciId, quotedItem) - liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs msg.forwardedByGroupMemberId createdAt + liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs forwardedByMember createdAt mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByGroupMemberId currentTs = do +mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByMember currentTs = do let itemText = ciContentToText content itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByGroupMemberId currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index d35ed88185..0cb7f5e82b 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -341,18 +341,18 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, editable :: Bool, - forwardedByGroupMemberId :: Maybe GroupMemberId, + forwardedByMember :: Maybe GroupMemberId, createdAt :: UTCTime, updatedAt :: UTCTime } deriving (Show, Generic) mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt = +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = let editable = case itemContent of CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByGroupMemberId, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt} instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions @@ -816,8 +816,8 @@ data RcvMessage = RcvMessage chatMsgEvent :: AChatMsgEvent, sharedMsgId_ :: Maybe SharedMsgId, msgBody :: MsgBody, - authorGroupMemberId :: Maybe GroupMemberId, - forwardedByGroupMemberId :: Maybe GroupMemberId + authorMember :: Maybe GroupMemberId, + forwardedByMember :: Maybe GroupMemberId } data PendingGroupMessage = PendingGroupMessage diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d98023ad83..343d33484e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -200,7 +200,7 @@ createNewMessageAndRcvMsgDelivery db connOrGroupId newMessage sharedMsgId_ RcvMs pure msg createNewRcvMessage :: forall e. (MsgEncodingI e) => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage -createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsgId_ authorGroupMemberId forwardedByGroupMemberId = +createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsgId_ authorMember forwardedByMember = case connOrGroupId of ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing GroupId groupId -> case sharedMsgId_ of @@ -230,9 +230,9 @@ createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsg (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorGroupMemberId, forwardedByGroupMemberId) + (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) msgId <- insertedRowId db - pure RcvMessage{msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorGroupMemberId, forwardedByGroupMemberId} + pure RcvMessage{msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO () createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do @@ -366,8 +366,8 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv Nothing -> (Just False, Nothing) createNewRcvChatItem :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByGroupMemberId} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem) where @@ -389,7 +389,7 @@ createNewChatItemNoMsg db user chatDirection ciContent itemTs = quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt = do +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs forwardedByMember createdAt = do DB.execute db [sql| @@ -408,7 +408,7 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q pure ciId where itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByGroupMemberId) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing) @@ -594,7 +594,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- CIMeta forwardedByGroupMemberId + -- CIMeta forwardedByMember i.forwarded_by_group_member_id, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, @@ -1074,7 +1074,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction -- this function can be changed so it never fails, not only avoid failure on invalid json toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1110,13 +1110,13 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} toGroupChatItemList :: UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = - either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) +toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = + either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) toGroupChatItemList _ _ _ = [] getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] @@ -1560,7 +1560,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- CIMeta forwardedByGroupMemberId + -- CIMeta forwardedByMember i.forwarded_by_group_member_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 982c5208af..85511e09eb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -449,7 +449,7 @@ viewChats ts tz = concatMap chatPreview . reverse _ -> [] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {forwardedByMember}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> (case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of @@ -489,7 +489,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of Nothing -> item Just t -> item <> styled (colored Red) (" [" <> t <> "]") - withGroupMsgForwarded item = case meta.forwardedByGroupMemberId of + withGroupMsgForwarded item = case forwardedByMember of Nothing -> item Just _ -> item <> styled (colored Yellow) (" [>>]" :: String) withSndFile = withFile viewSentFileInvitation From 68cbc605be280fc51bd9c8db9c0d8ea8cfdb6d61 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:19:00 +0200 Subject: [PATCH 098/174] add remote session sequence to prevent stale state updates (#3390) * add remote session sequence to prevent stale state updates * remote RHStateKey * add StateSeq check to controller * clean up * simplify * undo withRemoteXSession API change * simplify --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 6 +- src/Simplex/Chat/Controller.hs | 7 +- src/Simplex/Chat/Remote.hs | 296 ++++++++++++++++++------------- src/Simplex/Chat/Remote/Types.hs | 2 + tests/RemoteTests.hs | 4 +- 5 files changed, 182 insertions(+), 133 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a6d5576133..049f406809 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -215,6 +215,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls <- atomically TM.empty localDeviceName <- newTVarIO "" -- TODO set in config multicastSubscribers <- newTMVarIO 0 + remoteSessionSeq <- newTVarIO 0 remoteHostSessions <- atomically TM.empty remoteHostsFolder <- newTVarIO Nothing remoteCtrlSession <- newTVarIO Nothing @@ -250,6 +251,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls, localDeviceName, multicastSubscribers, + remoteSessionSeq, remoteHostSessions, remoteHostsFolder, remoteCtrlSession, @@ -377,8 +379,8 @@ restoreCalls = do stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do - readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost False) - atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl False) + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost False . snd) + atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl False . snd) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3a97d2c32c..2ccc2ca12e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -184,9 +184,10 @@ data ChatController = ChatController currentCalls :: TMap ContactId Call, localDeviceName :: TVar Text, multicastSubscribers :: TMVar Int, - remoteHostSessions :: TMap RHKey RemoteHostSession, -- All the active remote hosts + remoteSessionSeq :: TVar Int, + remoteHostSessions :: TMap RHKey (SessionSeq, RemoteHostSession), -- All the active remote hosts remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data - remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers + remoteCtrlSession :: TVar (Maybe (SessionSeq, RemoteCtrlSession)), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, expireCIThreads :: TMap UserId (Maybe (Async ())), @@ -1196,7 +1197,7 @@ toView event = do session <- asks remoteCtrlSession atomically $ readTVar session >>= \case - Just RCSessionConnected {remoteOutputQ} | allowRemoteEvent event -> + Just (_, RCSessionConnected {remoteOutputQ}) | allowRemoteEvent event -> writeTBQueue remoteOutputQ event -- TODO potentially, it should hold some events while connecting _ -> writeTBQueue localQ (Nothing, Nothing, event) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 1af49a2da0..d9ef5bd648 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -21,7 +21,6 @@ import Control.Monad.Reader import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J import qualified Data.Aeson.Types as JT -import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) @@ -29,7 +28,7 @@ import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) import Data.List.NonEmpty (nonEmpty) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -96,33 +95,43 @@ discoveryTimeout = 60000000 -- * Desktop side getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient -getRemoteHostClient rhId = withRemoteHostSession rhKey $ \case - s@RHSessionConnected {rhClient} -> Right (rhClient, s) - _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState +getRemoteHostClient rhId = do + sessions <- asks remoteHostSessions + liftIOEither . atomically $ TM.lookup rhKey sessions >>= \case + Just (_, RHSessionConnected {rhClient}) -> pure $ Right rhClient + Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState + Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing where rhKey = RHId rhId -withRemoteHostSession :: ChatMonad m => RHKey -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a -withRemoteHostSession rhKey state = withRemoteHostSession_ rhKey $ maybe (Left $ ChatErrorRemoteHost rhKey $ RHEMissing) ((second . second) Just . state) - -withRemoteHostSession_ :: ChatMonad m => RHKey -> (Maybe RemoteHostSession -> Either ChatError (a, Maybe RemoteHostSession)) -> m a -withRemoteHostSession_ rhKey state = do +withRemoteHostSession :: ChatMonad m => RHKey -> SessionSeq -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a +withRemoteHostSession rhKey sseq f = do sessions <- asks remoteHostSessions - r <- atomically $ do - s <- TM.lookup rhKey sessions - case state s of - Left e -> pure $ Left e - Right (a, s') -> Right a <$ maybe (TM.delete rhKey) (TM.insert rhKey) s' sessions + r <- atomically $ + TM.lookup rhKey sessions >>= \case + Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing + Just (stateSeq, state) + | stateSeq /= sseq -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState + | otherwise -> case f state of + Right (r, newState) -> Right r <$ TM.insert rhKey (sseq, newState) sessions + Left ce -> pure $ Left ce liftEither r -setNewRemoteHostId :: ChatMonad m => RHKey -> RemoteHostId -> m () -setNewRemoteHostId rhKey rhId = do +-- | Transition session state with a 'RHNew' ID to an assigned 'RemoteHostId' +setNewRemoteHostId :: ChatMonad m => SessionSeq -> RemoteHostId -> m () +setNewRemoteHostId sseq rhId = do sessions <- asks remoteHostSessions - r <- atomically $ do - TM.lookupDelete rhKey sessions >>= \case - Nothing -> pure $ Left $ ChatErrorRemoteHost rhKey RHEMissing - Just s -> Right () <$ TM.insert (RHId rhId) s sessions - liftEither r + liftIOEither . atomically $ do + TM.lookup RHNew sessions >>= \case + Nothing -> err RHEMissing + Just sess@(stateSeq, _) + | stateSeq /= sseq -> err RHEBadState + | otherwise -> do + TM.delete RHNew sessions + TM.insert (RHId rhId) sess sessions + pure $ Right () + where + err = pure . Left . ChatErrorRemoteHost RHNew startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) startRemoteHost rh_ = do @@ -131,16 +140,16 @@ startRemoteHost rh_ = do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId pure (RHId rhId, multicast, Just $ remoteHostInfo rh $ Just RHSStarting, hostPairing) -- get from the database, start multicast if requested Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing - withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) + sseq <- startRemoteHostSession rhKey ctrlAppInfo <- mkCtrlAppInfo - (invitation, rchClient, vars) <- handleConnectError rhKey . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast + (invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do rhKeyVar <- newTVarIO rhKey atomically $ takeTMVar cmdOk - handleHostError rhKeyVar $ waitForHostSession remoteHost_ rhKey rhKeyVar vars + handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} - withRemoteHostSession rhKey $ \case + withRemoteHostSession rhKey sseq $ \case RHSessionStarting -> let inv = decodeLatin1 $ strEncode invitation in Right ((), RHSessionConnecting inv rhs) @@ -157,85 +166,103 @@ startRemoteHost rh_ = do unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo - handleConnectError :: ChatMonad m => RHKey -> m a -> m a - handleConnectError rhKey action = action `catchChatError` \err -> do + handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a + handleConnectError rhKey sessSeq action = action `catchChatError` \err -> do logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err - cancelRemoteHostSession True rhKey + cancelRemoteHostSession (Just sessSeq) rhKey throwError err - handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () - handleHostError rhKeyVar action = action `catchChatError` \err -> do + handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m () + handleHostError sessSeq rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - readTVarIO rhKeyVar >>= cancelRemoteHostSession True - waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () - waitForHostSession remoteHost_ rhKey rhKeyVar vars = do + readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just sessSeq) + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId - withRemoteHostSession rhKey $ \case - RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') -- TODO check it's the same session? + withRemoteHostSession rhKey sseq $ \case + RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - -- display confirmation code, wait for mobile to confirm - let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just $ RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ + let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello - withRemoteHostSession rhKey $ \case - RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? + withRemoteHostSession rhKey sseq $ \case + RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed {sessionCode} + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName sseq RHSConfirmed {sessionCode} let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' toView $ CRNewRemoteHost rhi - disconnected <- toIO $ onDisconnected rhKey' - httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls + -- set up HTTP transport and remote profile protocol + disconnected <- toIO $ onDisconnected rhKey' sseq + httpClient <- liftEitherError (httpError remoteHostId) $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient - withRemoteHostSession rhKey' $ \case + withRemoteHostSession rhKey' sseq $ \case RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) - _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} - upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo - upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName sseq state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ case rhi_ of Nothing -> do storePath <- liftIO randomStorePath rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db - setNewRemoteHostId RHNew remoteHostId + setNewRemoteHostId sseq remoteHostId pure $ remoteHostInfo rh $ Just state Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' pure (rhi :: RemoteHostInfo) {sessionState = Just state} - onDisconnected :: ChatMonad m => RHKey -> m () - onDisconnected rhKey = do - logDebug "HTTP2 client disconnected" - cancelRemoteHostSession True rhKey + onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () + onDisconnected rhKey sseq = do + logDebug $ "HTTP2 client disconnected: " <> tshow (rhKey, sseq) + cancelRemoteHostSession (Just sseq) rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ forever $ do r_ <- liftRH rhId $ remoteRecv rhClient 10000000 forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) - httpError :: RHKey -> HTTP2ClientError -> ChatError - httpError rhKey = ChatErrorRemoteHost rhKey . RHEProtocolError . RPEHTTP2 . tshow + httpError :: RemoteHostId -> HTTP2ClientError -> ChatError + httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow + +startRemoteHostSession :: ChatMonad m => RHKey -> m SessionSeq +startRemoteHostSession rhKey = do + sessions <- asks remoteHostSessions + nextSessionSeq <- asks remoteSessionSeq + liftIOEither . atomically $ + TM.lookup rhKey sessions >>= \case + Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBusy + Nothing -> do + sessionSeq <- stateTVar nextSessionSeq $ \s -> (s, s + 1) + Right sessionSeq <$ TM.insert rhKey (sessionSeq, RHSessionStarting) sessions closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey - cancelRemoteHostSession False rhKey + cancelRemoteHostSession Nothing rhKey -cancelRemoteHostSession :: ChatMonad m => Bool -> RHKey -> m () -cancelRemoteHostSession handlingError rhKey = handleAny (logError . tshow) $ do - chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH +cancelRemoteHostSession :: ChatMonad m => Maybe SessionSeq -> RHKey -> m () +cancelRemoteHostSession sseq_ rhKey = do sessions <- asks remoteHostSessions - session_ <- atomically $ TM.lookupDelete rhKey sessions -- XXX: when invoked from delayed error handler this can wipe the next session instead - forM_ session_ $ \session -> do + crh <- asks currentRemoteHost + deregistered <- atomically $ + TM.lookup rhKey sessions >>= \case + Nothing -> pure Nothing + Just (sessSeq, _) | maybe False (/= sessSeq) sseq_ -> pure Nothing -- ignore cancel from a ghost session handler + Just (_, rhs) -> do + TM.delete rhKey sessions + modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH + pure $ Just rhs + forM_ deregistered $ \session -> do liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) when handlingError $ toView $ CRRemoteHostStopped rhId_ where + handlingError = isJust sseq_ rhId_ = case rhKey of RHNew -> Nothing RHId rhId -> Just rhId @@ -270,7 +297,7 @@ listRemoteHosts = do map (rhInfo sessions) <$> withStore' getRemoteHosts where rhInfo sessions rh@RemoteHost {remoteHostId} = - remoteHostInfo rh (rhsSessionState <$> M.lookup (RHId remoteHostId) sessions) + remoteHostInfo rh $ rhsSessionState . snd <$> M.lookup (RHId remoteHostId) sessions switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo) switchRemoteHost rhId_ = do @@ -279,7 +306,7 @@ switchRemoteHost rhId_ = do rh <- withStore (`getRemoteHost` rhId) sessions <- chatReadVar remoteHostSessions case M.lookup rhKey sessions of - Just RHSessionConnected {tls} -> pure $ remoteHostInfo rh $ Just RHSConnected {sessionCode = tlsSessionCode tls} + Just (_, RHSessionConnected {tls}) -> pure $ remoteHostInfo rh $ Just RHSConnected {sessionCode = tlsSessionCode tls} _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ @@ -352,23 +379,23 @@ liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) -- | Use provided OOB link as an annouce connectRemoteCtrlURI :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrlURI signedInv = handleCtrlError "connectRemoteCtrl" $ do +connectRemoteCtrlURI signedInv = do verifiedInv <- maybe (throwError $ ChatErrorRemoteCtrl RCEBadInvitation) pure $ verifySignedInvitation signedInv - withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) - connectRemoteCtrl verifiedInv + sseq <- startRemoteCtrlSession + connectRemoteCtrl verifiedInv sseq -- ** Multicast findKnownRemoteCtrl :: ChatMonad m => m () -findKnownRemoteCtrl = handleCtrlError "findKnownRemoteCtrl" $ do +findKnownRemoteCtrl = do knownCtrls <- withStore' getRemoteCtrls pairings <- case nonEmpty knownCtrls of Nothing -> throwError $ ChatErrorRemoteCtrl RCENoKnownControllers Just ne -> pure $ fmap (\RemoteCtrl {ctrlPairing} -> ctrlPairing) ne - withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) + sseq <- startRemoteCtrlSession foundCtrl <- newEmptyTMVarIO cmdOk <- newEmptyTMVarIO - action <- async $ handleCtrlError "findKnownRemoteCtrl.discover" $ do + action <- async $ handleCtrlError sseq "findKnownRemoteCtrl.discover" $ do atomically $ takeTMVar cmdOk (RCCtrlPairing {ctrlFingerprint}, inv) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case @@ -376,27 +403,42 @@ findKnownRemoteCtrl = handleCtrlError "findKnownRemoteCtrl" $ do Just rc -> pure rc atomically $ putTMVar foundCtrl (rc, inv) toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching)} - withRemoteCtrlSession $ \case - RCSessionStarting -> Right ((), RCSessionSearching {action, foundCtrl}) + updateRemoteCtrlSession sseq $ \case + RCSessionStarting -> Right RCSessionSearching {action, foundCtrl} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m (RemoteCtrlInfo, CtrlAppInfo) confirmRemoteCtrl rcId = do - (listener, found) <- withRemoteCtrlSession $ \case - RCSessionSearching {action, foundCtrl} -> Right ((action, foundCtrl), RCSessionStarting) -- drop intermediate "Searching" state so connectRemoteCtrl can proceed - _ -> throwError $ ChatErrorRemoteCtrl RCEBadState + session <- asks remoteCtrlSession + (sseq, listener, found) <- liftIOEither $ atomically $ do + readTVar session >>= \case + Just (sseq, RCSessionSearching {action, foundCtrl}) -> do + writeTVar session $ Just (sseq, RCSessionStarting) -- drop intermediate "Searching" state so connectRemoteCtrl can proceed + pure $ Right (sseq, action, foundCtrl) + _ -> pure . Left $ ChatErrorRemoteCtrl RCEBadState uninterruptibleCancel listener (RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController - connectRemoteCtrl verifiedInv >>= \case + connectRemoteCtrl verifiedInv sseq >>= \case (Nothing, _) -> throwChatError $ CEInternalError "connecting with a stored ctrl" (Just rci, appInfo) -> pure (rci, appInfo) -- ** Common -connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) = handleCtrlError "connectRemoteCtrl" $ do +startRemoteCtrlSession :: ChatMonad m => m SessionSeq +startRemoteCtrlSession = do + session <- asks remoteCtrlSession + nextSessionSeq <- asks remoteSessionSeq + liftIOEither . atomically $ + readTVar session >>= \case + Just _ -> pure . Left $ ChatErrorRemoteCtrl RCEBusy + Nothing -> do + sseq <- stateTVar nextSessionSeq $ \s -> (s, s + 1) + Right sseq <$ writeTVar session (Just (sseq, RCSessionStarting)) + +connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> SessionSeq -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq "connectRemoteCtrl" $ do (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ @@ -406,8 +448,8 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk - handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars - updateRemoteCtrlSession $ \case + handleCtrlError sseq "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars + updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionConnecting {remoteCtrlId_ = remoteCtrlId' <$> rc_, rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () @@ -419,7 +461,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq - updateRemoteCtrlSession $ \case + updateRemoteCtrlSession sseq $ \case RCSessionConnecting {rcsWaitSession} -> let remoteCtrlId_ = remoteCtrlId' <$> rc_ in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} @@ -529,7 +571,7 @@ handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileI listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do - session <- chatReadVar remoteCtrlSession + session <- snd <$$> chatReadVar remoteCtrlSession let rcId = sessionRcId =<< session sessState = rcsSessionState <$> session map (rcInfo rcId sessState) <$> withStore' getRemoteCtrls @@ -549,24 +591,26 @@ remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = -- | Take a look at emoji of tlsunique, commit pairing, and start session server verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo -verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do - (client, ctrlName, sessionCode, vars) <- - getRemoteCtrlSession >>= \case - RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) +verifyRemoteCtrlSession execChatCommand sessCode' = do + (sseq, client, ctrlName, sessionCode, vars) <- + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just (sseq, RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation}) -> pure (sseq, rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState - let verified = sameVerificationCode sessCode' sessionCode - timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing - unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode - (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars - rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing - remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO - encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls - http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand encryption remoteOutputQ - void . forkIO $ monitor http2Server - withRemoteCtrlSession $ \case - RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ}) - _ -> Left $ ChatErrorRemoteCtrl RCEBadState - pure $ remoteCtrlInfo rc $ Just RCSConnected {sessionCode = tlsSessionCode tls} + handleCtrlError sseq "verifyRemoteCtrlSession" $ do + let verified = sameVerificationCode sessCode' sessionCode + timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing + unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode + (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars + rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing + remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO + encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls + http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand encryption remoteOutputQ + void . forkIO $ monitor sseq http2Server + updateRemoteCtrlSession sseq $ \case + RCSessionPendingConfirmation {} -> Right RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + pure $ remoteCtrlInfo rc $ Just RCSConnected {sessionCode = tlsSessionCode tls} where upsertRemoteCtrl :: ChatMonad m => Text -> RCCtrlPairing -> m RemoteCtrl upsertRemoteCtrl ctrlName rcCtrlPairing = withStore $ \db -> do @@ -577,28 +621,35 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot let dhPrivKey' = dhPrivKey rcCtrlPairing liftIO $ updateRemoteCtrl db rc ctrlName dhPrivKey' pure rc {ctrlDeviceName = ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} - monitor :: ChatMonad m => Async () -> m () - monitor server = do + monitor :: ChatMonad m => SessionSeq -> Async () -> m () + monitor sseq server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - cancelActiveRemoteCtrl True + cancelActiveRemoteCtrl (Just sseq) stopRemoteCtrl :: ChatMonad m => m () -stopRemoteCtrl = cancelActiveRemoteCtrl False +stopRemoteCtrl = cancelActiveRemoteCtrl Nothing -handleCtrlError :: ChatMonad m => Text -> m a -> m a -handleCtrlError name action = +handleCtrlError :: ChatMonad m => SessionSeq -> Text -> m a -> m a +handleCtrlError sseq name action = action `catchChatError` \e -> do logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl True + cancelActiveRemoteCtrl (Just sseq) throwError e -cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () -cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do - session_ <- withRemoteCtrlSession_ (\s -> pure (s, Nothing)) +-- | Stop session controller, unless session update key is present but stale +cancelActiveRemoteCtrl :: ChatMonad m => Maybe SessionSeq -> m () +cancelActiveRemoteCtrl sseq_ = handleAny (logError . tshow) $ do + var <- asks remoteCtrlSession + session_ <- atomically $ readTVar var >>= \case + Nothing -> pure Nothing + Just (oldSeq, _) | maybe False (/= oldSeq) sseq_ -> pure Nothing + Just (_, s) -> Just s <$ writeTVar var Nothing forM_ session_ $ \session -> do liftIO $ cancelRemoteCtrl handlingError session when handlingError $ toView CRRemoteCtrlStopped + where + handlingError = isJust sseq_ cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case @@ -622,31 +673,24 @@ deleteRemoteCtrl rcId = do -- TODO check it exists withStore' (`deleteRemoteCtrlRecord` rcId) -getRemoteCtrlSession :: ChatMonad m => m RemoteCtrlSession -getRemoteCtrlSession = - chatReadVar remoteCtrlSession >>= maybe (throwError $ ChatErrorRemoteCtrl RCEInactive) pure - checkNoRemoteCtrlSession :: ChatMonad m => m () checkNoRemoteCtrlSession = chatReadVar remoteCtrlSession >>= maybe (pure ()) (\_ -> throwError $ ChatErrorRemoteCtrl RCEBusy) -withRemoteCtrlSession :: ChatMonad m => (RemoteCtrlSession -> Either ChatError (a, RemoteCtrlSession)) -> m a -withRemoteCtrlSession state = withRemoteCtrlSession_ $ maybe (Left $ ChatErrorRemoteCtrl RCEInactive) ((second . second) Just . state) - --- | Atomically process controller state wrt. specific remote ctrl session -withRemoteCtrlSession_ :: ChatMonad m => (Maybe RemoteCtrlSession -> Either ChatError (a, Maybe RemoteCtrlSession)) -> m a -withRemoteCtrlSession_ state = do +-- | Transition controller to a new state, unless session update key is stale +updateRemoteCtrlSession :: ChatMonad m => SessionSeq -> (RemoteCtrlSession -> Either ChatError RemoteCtrlSession) -> m () +updateRemoteCtrlSession sseq state = do session <- asks remoteCtrlSession - r <- - atomically $ stateTVar session $ \s -> - case state s of - Left e -> (Left e, s) - Right (a, s') -> (Right a, s') + r <- atomically $ do + readTVar session >>= \case + Nothing -> pure . Left $ ChatErrorRemoteCtrl RCEInactive + Just (oldSeq, st) + | oldSeq /= sseq -> pure . Left $ ChatErrorRemoteCtrl RCEBadState + | otherwise -> case state st of + Left ce -> pure $ Left ce + Right st' -> Right () <$ writeTVar session (Just (sseq, st')) liftEither r -updateRemoteCtrlSession :: ChatMonad m => (RemoteCtrlSession -> Either ChatError RemoteCtrlSession) -> m () -updateRemoteCtrlSession state = withRemoteCtrlSession $ fmap ((),) . state - utf8String :: [Char] -> ByteString utf8String = encodeUtf8 . T.pack {-# INLINE utf8String #-} diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index c56b2462b0..783a083e55 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -56,6 +56,8 @@ data RemoteSignatures sessPubKey :: C.PublicKeyEd25519 } +type SessionSeq = Int + data RHPendingSession = RHPendingSession { rhKey :: RHKey, rchClient :: RCHostClient, diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index dc2f890a7f..c734c94dbe 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -185,7 +185,7 @@ remoteStoreFileTest = rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just RHSessionConnected {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" desktop ##> "/store remote file 1 tests/fixtures/test.pdf" desktop <## "file test.pdf stored on remote host 1" @@ -311,7 +311,7 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just RHSessionConnected {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" mobileName <- userName mobile From 5b7de8f8c11b5bdaa0a0ff07dfb59a9bee4bcd88 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:20:10 +0000 Subject: [PATCH 099/174] desktop, android: pass remote host to API from the loaded objects, to prevent race conditions (#3397) * desktop, android: pass remote host explicitely to API calls * use remote host ID in model updates * add remote host to chat console * add remote host to notifications functions --- .../java/chat/simplex/app/MainActivity.kt | 2 +- .../main/java/chat/simplex/app/SimplexApp.kt | 2 +- .../common/views/call/CallView.android.kt | 11 +- .../newchat/ConnectViaLinkView.android.kt | 7 +- .../newchat/ScanToConnectView.android.kt | 3 +- .../ScanProtocolServer.android.kt | 4 +- .../kotlin/chat/simplex/common/App.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 116 ++-- .../chat/simplex/common/model/SimpleXAPI.kt | 622 +++++++++--------- .../chat/simplex/common/platform/Core.kt | 2 +- .../simplex/common/platform/NtfManager.kt | 11 +- .../chat/simplex/common/views/TerminalView.kt | 11 +- .../chat/simplex/common/views/WelcomeView.kt | 9 +- .../simplex/common/views/call/CallManager.kt | 5 +- .../chat/simplex/common/views/call/WebRTC.kt | 10 +- .../simplex/common/views/chat/ChatInfoView.kt | 60 +- .../simplex/common/views/chat/ChatView.kt | 94 +-- .../simplex/common/views/chat/ComposeView.kt | 27 +- .../common/views/chat/ContactPreferences.kt | 5 +- .../views/chat/group/AddGroupMembersView.kt | 8 +- .../views/chat/group/GroupChatInfoView.kt | 55 +- .../common/views/chat/group/GroupLinkView.kt | 7 +- .../views/chat/group/GroupMemberInfoView.kt | 68 +- .../views/chat/group/GroupPreferences.kt | 12 +- .../views/chat/group/GroupProfileView.kt | 6 +- .../views/chat/group/WelcomeMessageView.kt | 6 +- .../views/chatlist/ChatListNavLinkView.kt | 157 ++--- .../common/views/chatlist/ChatListView.kt | 9 +- .../views/chatlist/ShareListNavLinkView.kt | 4 +- .../common/views/chatlist/UserPicker.kt | 4 +- .../common/views/database/DatabaseView.kt | 18 +- .../simplex/common/views/helpers/Utils.kt | 2 +- .../common/views/localauth/LocalAuthView.kt | 2 +- .../common/views/newchat/AddContactView.kt | 8 +- .../common/views/newchat/AddGroupView.kt | 12 +- .../views/newchat/ConnectViaLinkView.kt | 2 +- .../newchat/ContactConnectionInfoView.kt | 11 +- .../common/views/newchat/CreateLinkView.kt | 11 +- .../common/views/newchat/NewChatSheet.kt | 9 +- .../common/views/newchat/PasteToConnect.kt | 7 +- .../common/views/newchat/ScanToConnectView.kt | 82 +-- .../views/onboarding/CreateSimpleXAddress.kt | 16 +- .../onboarding/SetupDatabasePassphrase.kt | 2 +- .../views/usersettings/HiddenProfileView.kt | 2 +- .../views/usersettings/NetworkAndServers.kt | 4 +- .../common/views/usersettings/Preferences.kt | 6 +- .../views/usersettings/PrivacySettings.kt | 8 +- .../views/usersettings/ProtocolServerView.kt | 2 +- .../views/usersettings/ProtocolServersView.kt | 21 +- .../views/usersettings/ScanProtocolServer.kt | 6 +- .../usersettings/SetDeliveryReceiptsView.kt | 4 +- .../common/views/usersettings/SettingsView.kt | 2 +- .../views/usersettings/UserAddressView.kt | 10 +- .../views/usersettings/UserProfileView.kt | 4 +- .../views/usersettings/UserProfilesView.kt | 14 +- .../common/views/call/CallView.desktop.kt | 11 +- .../views/chatlist/ChatListView.desktop.kt | 2 +- .../newchat/ConnectViaLinkView.desktop.kt | 5 +- .../newchat/ScanToConnectView.desktop.kt | 3 +- .../ScanProtocolServer.desktop.kt | 4 +- 60 files changed, 853 insertions(+), 776 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 40c04f5088..4c5d595a83 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -126,7 +126,7 @@ fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(uri.toURI(), ChatModel) + if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId, uri.toURI(), ChatModel) } } } 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 d2c4465172..13908f69bf 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 @@ -57,7 +57,7 @@ class SimplexApp: Application(), LifecycleEventObserver { updatingChatsMutex.withLock { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats()) + val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId)) /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ if (chatModel.currentUser.value?.userId == currentUserId) { val currentChatId = chatModel.chatId.value diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 5c7b430ab3..c173463d5e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -115,22 +115,23 @@ actual fun ActiveCallView() { val call = chatModel.activeCall.value if (call != null) { Log.d(TAG, "has active call $call") + val callRh = call.remoteHostId when (val r = apiMsg.resp) { is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) - chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } is WCallResponse.Offer -> withBGApi { - chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } is WCallResponse.Answer -> withBGApi { - chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { @@ -139,7 +140,7 @@ actual fun ActiveCallView() { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) setCallSound(call.soundSpeaker, audioViaBluetooth) } - withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Error) { Log.d(TAG,"call status ${r.state.connectionState} not used") } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt index dcb5055425..1faf115b37 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt @@ -12,7 +12,8 @@ import chat.simplex.common.model.ChatModel import chat.simplex.res.MR @Composable -actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { + // TODO this should close if remote host changes in model val selection = remember { mutableStateOf( runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN) @@ -31,10 +32,10 @@ actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { Column(Modifier.weight(1f)) { when (selection.value) { ConnectViaLinkTab.SCAN -> { - ScanToConnectView(m, close) + ScanToConnectView(m, rhId, close) } ConnectViaLinkTab.PASTE -> { - PasteToConnectView(m, close) + PasteToConnectView(m, rhId, close) } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt index d0cad31210..89477e45a1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt @@ -7,13 +7,14 @@ import chat.simplex.common.model.ChatModel import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } ConnectContactLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt index a1b7b3141d..af5a27be11 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt @@ -7,10 +7,10 @@ import chat.simplex.common.model.ServerCfg import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { +actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } - ScanProtocolServerLayout(onNext) + ScanProtocolServerLayout(rhId, onNext) } 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 41deee7a55..da74a37aaa 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 @@ -138,7 +138,7 @@ fun MainScreen() { } onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) - onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) + onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } if (appPlatform.isAndroid) { 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 af946be3cd..ab8b6af3fd 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 @@ -111,6 +111,7 @@ object ChatModel { // remote controller val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) + val remoteHostId: Long? get() = currentRemoteHost?.value?.remoteHostId val newRemoteHostPairing = mutableStateOf?>(null) val remoteCtrlSession = mutableStateOf(null) @@ -141,16 +142,17 @@ object ChatModel { } // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens - fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null + fun hasChat(rhId: Long?, id: String): Boolean = chats.toList().firstOrNull { it.id == id && it.remoteHostId == rhId } != null + // TODO pass rhId? fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId } - private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } + private fun getChatIndex(rhId: Long?, id: String): Int = chats.toList().indexOfFirst { it.id == id && it.remoteHostId == rhId } fun addChat(chat: Chat) = chats.add(index = 0, chat) - fun updateChatInfo(cInfo: ChatInfo) { - val i = getChatIndex(cInfo.id) + fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) { + val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { val currentCInfo = chats[i].chatInfo var newCInfo = cInfo @@ -172,23 +174,23 @@ object ChatModel { } } - fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) + fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) - fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed) + fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) - fun updateContactConnectionStats(contact: Contact, connectionStats: ConnectionStats) { + fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) { val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats) val updatedContact = contact.copy(activeConn = updatedConn) - updateContact(updatedContact) + updateContact(rhId, updatedContact) } - fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo)) + fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) - private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) { - if (hasChat(cInfo.id)) { - updateChatInfo(cInfo) + private fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { + if (hasChat(rhId, cInfo.id)) { + updateChatInfo(rhId, cInfo) } else if (addMissing) { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf())) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) } } @@ -203,8 +205,8 @@ object ChatModel { } } - fun replaceChat(id: String, chat: Chat) { - val i = getChatIndex(id) + fun replaceChat(rhId: Long?, id: String, chat: Chat) { + val i = getChatIndex(rhId, id) if (i >= 0) { chats[i] = chat } else { @@ -213,9 +215,9 @@ object ChatModel { } } - suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { + suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { chat = chats[i] @@ -224,7 +226,7 @@ object ChatModel { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId - increaseUnreadCounter(currentUser.value!!) + increaseUnreadCounter(rhId, currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId) } else @@ -234,7 +236,7 @@ object ChatModel { popChat_(i) } } else { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { @@ -254,9 +256,9 @@ object ChatModel { } } - suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { + suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat val res: Boolean if (i >= 0) { @@ -266,12 +268,12 @@ object ChatModel { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) if (pItem.isRcvNew && !cItem.isRcvNew) { // status changed from New to Read, update counter - decreaseCounterInChat(cInfo.id) + decreaseCounterInChat(rhId, cInfo.id) } } res = false } else { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) res = true } Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") @@ -313,12 +315,12 @@ object ChatModel { } } - fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) { + fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { if (cItem.isRcvNew) { - decreaseCounterInChat(cInfo.id) + decreaseCounterInChat(rhId, cInfo.id) } // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { chat = chats[i] @@ -337,11 +339,11 @@ object ChatModel { } } - fun clearChat(cInfo: ChatInfo) { + fun clearChat(rhId: Long?, cInfo: ChatInfo) { // clear preview - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { - decreaseUnreadCounter(currentUser.value!!, chats[i].chatStats.unreadCount) + decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) } // clear current chat @@ -351,15 +353,15 @@ object ChatModel { } } - fun updateCurrentUser(newProfile: Profile, preferences: FullChatPreferences? = null) { + fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( profile = newProfile.toLocalProfile(current.profile.profileId), fullPreferences = preferences ?: current.fullPreferences ) - val indexInUsers = users.indexOfFirst { it.user.userId == current.userId } - if (indexInUsers != -1) { - users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount) + val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId } + if (i != -1) { + users[i] = users[i].copy(user = updated) } currentUser.value = updated } @@ -378,16 +380,17 @@ object ChatModel { } } - fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { - val markedRead = markItemsReadInCurrentChat(cInfo, range) + fun markChatItemsRead(chat: Chat, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { + val cInfo = chat.chatInfo + val markedRead = markItemsReadInCurrentChat(chat, range) // update preview - val chatIdx = getChatIndex(cInfo.id) + val chatIdx = getChatIndex(chat.remoteHostId, cInfo.id) if (chatIdx >= 0) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0 - decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + decreaseUnreadCounter(chat.remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, @@ -399,7 +402,8 @@ object ChatModel { } } - private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int { + private fun markItemsReadInCurrentChat(chat: Chat, range: CC.ItemRange? = null): Int { + val cInfo = chat.chatInfo var markedRead = 0 if (chatId.value == cInfo.id) { var i = 0 @@ -423,13 +427,13 @@ object ChatModel { return markedRead } - private fun decreaseCounterInChat(chatId: ChatId) { - val chatIndex = getChatIndex(chatId) + private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { + val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return val chat = chats[chatIndex] val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) - decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, @@ -437,18 +441,18 @@ object ChatModel { ) } - fun increaseUnreadCounter(user: UserLike) { - changeUnreadCounter(user, 1) + fun increaseUnreadCounter(rhId: Long?, user: UserLike) { + changeUnreadCounter(rhId, user, 1) } - fun decreaseUnreadCounter(user: UserLike, by: Int = 1) { - changeUnreadCounter(user, -by) + fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { + changeUnreadCounter(rhId, user, -by) } - private fun changeUnreadCounter(user: UserLike, by: Int) { - val i = users.indexOfFirst { it.user.userId == user.userId } + private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) { + val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } if (i != -1) { - users[i] = UserInfo(users[i].user, users[i].unreadCount + by) + users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) } } @@ -544,14 +548,14 @@ object ChatModel { } } - fun removeChat(id: String) { - chats.removeAll { it.id == id } + fun removeChat(rhId: Long?, id: String) { + chats.removeAll { it.id == id && it.remoteHostId == rhId } } - fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean { + fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { // user member was updated if (groupInfo.membership.groupMemberId == member.groupMemberId) { - updateGroup(groupInfo) + updateGroup(rhId, groupInfo) return false } // update current chat @@ -569,12 +573,12 @@ object ChatModel { } } - fun updateGroupMemberConnectionStats(groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { + fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { val memberConn = member.activeConn if (memberConn != null) { val updatedConn = memberConn.copy(connectionStats = connectionStats) val updatedMember = member.copy(activeConn = updatedConn) - upsertGroupMember(groupInfo, updatedMember) + upsertGroupMember(rhId, groupInfo, updatedMember) } } @@ -612,6 +616,7 @@ enum class ChatType(val type: String) { @Serializable data class User( + val remoteHostId: Long? = null, override val userId: Long, val userContactId: Long, val localDisplayName: String, @@ -711,9 +716,10 @@ interface SomeChat { @Serializable @Stable data class Chat ( + val remoteHostId: Long? = null, val chatInfo: ChatInfo, val chatItems: List, - val chatStats: ChatStats = ChatStats(), + val chatStats: ChatStats = ChatStats() ) { val userCanSend: Boolean get() = when (chatInfo) { 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 253a9fb163..ffb5b4251b 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,6 +4,7 @@ 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.ChatModel.remoteHostId import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* @@ -352,13 +353,13 @@ object ChatController { apiSetXFTPConfig(getXFTPCfg()) apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() - val users = listUsers() + val users = listUsers(null) chatModel.users.clear() chatModel.users.addAll(users) if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true - getUserChatData() + getUserChatData(null) appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true startReceiver() @@ -366,7 +367,7 @@ object ChatController { Log.d(TAG, "startChat: started") } else { updatingChatsMutex.withLock { - val chats = apiGetChats() + val chats = apiGetChats(null) chatModel.updateChats(chats) } Log.d(TAG, "startChat: running") @@ -377,33 +378,33 @@ object ChatController { } } - suspend fun changeActiveUser(toUserId: Long, viewPwd: String?) { + suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { try { - changeActiveUser_(toUserId, viewPwd) + changeActiveUser_(rhId, toUserId, viewPwd) } catch (e: Exception) { Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_active_user_title), e.stackTraceToString()) } } - suspend fun changeActiveUser_(toUserId: Long, viewPwd: String?) { - val currentUser = apiSetActiveUser(toUserId, viewPwd) + suspend fun changeActiveUser_(rhId: Long?, toUserId: Long, viewPwd: String?) { + val currentUser = apiSetActiveUser(rhId, toUserId, viewPwd) chatModel.currentUser.value = currentUser - val users = listUsers() + val users = listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - getUserChatData() + getUserChatData(rhId) val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null) { chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser)) } } - suspend fun getUserChatData() { - chatModel.userAddress.value = apiGetUserAddress() - chatModel.chatItemTTL.value = getChatItemTTL() + suspend fun getUserChatData(rhId: Long?) { + chatModel.userAddress.value = apiGetUserAddress(rhId) + chatModel.chatItemTTL.value = getChatItemTTL(rhId) updatingChatsMutex.withLock { - val chats = apiGetChats() + val chats = apiGetChats(rhId) chatModel.updateChats(chats) } } @@ -428,21 +429,20 @@ object ChatController { } } - suspend fun sendCmd(cmd: CC, customRhId: Long? = null): CR { + suspend fun sendCmd(rhId: Long?, cmd: CC): CR { val ctrl = ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString - chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) + chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val rhId = customRhId?.toInt() ?: chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 - val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c) + val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") if (r.resp is CR.Response || r.resp is CR.Invalid) { Log.d(TAG, "sendCmd response json $json") } - chatModel.addTerminalItem(TerminalItem.resp(r.resp)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp)) r.resp } } @@ -460,16 +460,16 @@ object ChatController { } } - suspend fun apiGetActiveUser(): User? { - val r = sendCmd(CC.ShowActiveUser()) + suspend fun apiGetActiveUser(rh: Long?): User? { + val r = sendCmd(rh, CC.ShowActiveUser()) if (r is CR.ActiveUser) return r.user Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") chatModel.userCreated.value = false return null } - suspend fun apiCreateActiveUser(p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { - val r = sendCmd(CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) + 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)) if (r is CR.ActiveUser) return r.user else if ( r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || @@ -483,65 +483,68 @@ object ChatController { return null } - suspend fun listUsers(): List { - val r = sendCmd(CC.ListUsers()) - if (r is CR.UsersList) return r.users.sortedBy { it.user.chatViewName } + suspend fun listUsers(rh: Long?): List { + val r = sendCmd(rh, CC.ListUsers()) + if (r is CR.UsersList) { + val users = if (rh == null) r.users else r.users.map { it.copy(user = it.user.copy(remoteHostId = rh)) } + return users.sortedBy { it.user.chatViewName } + } Log.d(TAG, "listUsers: ${r.responseType} ${r.details}") throw Exception("failed to list users ${r.responseType} ${r.details}") } - suspend fun apiSetActiveUser(userId: Long, viewPwd: String?): User { - val r = sendCmd(CC.ApiSetActiveUser(userId, viewPwd)) - if (r is CR.ActiveUser) return r.user + suspend fun apiSetActiveUser(rh: Long?, userId: Long, viewPwd: String?): User { + val r = sendCmd(rh, CC.ApiSetActiveUser(userId, viewPwd)) + if (r is CR.ActiveUser) return if (rh == null) r.user else r.user.copy(remoteHostId = rh) Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } - suspend fun apiSetAllContactReceipts(enable: Boolean) { - val r = sendCmd(CC.SetAllContactReceipts(enable)) + suspend fun apiSetAllContactReceipts(rh: Long?, enable: Boolean) { + val r = sendCmd(rh, CC.SetAllContactReceipts(enable)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}") } - suspend fun apiSetUserContactReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { - val r = sendCmd(CC.ApiSetUserContactReceipts(userId, userMsgReceiptSettings)) + suspend fun apiSetUserContactReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserContactReceipts(u.userId, userMsgReceiptSettings)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}") } - suspend fun apiSetUserGroupReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { - val r = sendCmd(CC.ApiSetUserGroupReceipts(userId, userMsgReceiptSettings)) + suspend fun apiSetUserGroupReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserGroupReceipts(u.userId, userMsgReceiptSettings)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}") } - suspend fun apiHideUser(userId: Long, viewPwd: String): User = - setUserPrivacy(CC.ApiHideUser(userId, viewPwd)) + suspend fun apiHideUser(u: User, viewPwd: String): User = + setUserPrivacy(u.remoteHostId, CC.ApiHideUser(u.userId, viewPwd)) - suspend fun apiUnhideUser(userId: Long, viewPwd: String): User = - setUserPrivacy(CC.ApiUnhideUser(userId, viewPwd)) + suspend fun apiUnhideUser(u: User, viewPwd: String): User = + setUserPrivacy(u.remoteHostId, CC.ApiUnhideUser(u.userId, viewPwd)) - suspend fun apiMuteUser(userId: Long): User = - setUserPrivacy(CC.ApiMuteUser(userId)) + suspend fun apiMuteUser(u: User): User = + setUserPrivacy(u.remoteHostId, CC.ApiMuteUser(u.userId)) - suspend fun apiUnmuteUser(userId: Long): User = - setUserPrivacy(CC.ApiUnmuteUser(userId)) + suspend fun apiUnmuteUser(u: User): User = + setUserPrivacy(u.remoteHostId, CC.ApiUnmuteUser(u.userId)) - private suspend fun setUserPrivacy(cmd: CC): User { - val r = sendCmd(cmd) - if (r is CR.UserPrivacy) return r.updatedUser + private suspend fun setUserPrivacy(rh: Long?, cmd: CC): User { + val r = sendCmd(rh, cmd) + if (r is CR.UserPrivacy) return if (rh == null) r.updatedUser else r.updatedUser.copy(remoteHostId = rh) else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } - suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean, viewPwd: String?) { - val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues, viewPwd)) + suspend fun apiDeleteUser(u: User, delSMPQueues: Boolean, viewPwd: String?) { + val r = sendCmd(u.remoteHostId, CC.ApiDeleteUser(u.userId, delSMPQueues, viewPwd)) if (r is CR.CmdOk) return Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}") throw Exception("failed to delete the user ${r.responseType} ${r.details}") } suspend fun apiStartChat(): Boolean { - val r = sendCmd(CC.StartChat(expire = true)) + val r = sendCmd(null, CC.StartChat(expire = true)) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -550,7 +553,7 @@ object ChatController { } suspend fun apiStopChat(): Boolean { - val r = sendCmd(CC.ApiStopChat()) + val r = sendCmd(null, CC.ApiStopChat()) when (r) { is CR.ChatStopped -> return true else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") @@ -558,76 +561,76 @@ object ChatController { } private suspend fun apiSetTempFolder(tempFolder: String) { - val r = sendCmd(CC.SetTempFolder(tempFolder)) + val r = sendCmd(null, CC.SetTempFolder(tempFolder)) if (r is CR.CmdOk) return throw Error("failed to set temp folder: ${r.responseType} ${r.details}") } private suspend fun apiSetFilesFolder(filesFolder: String) { - val r = sendCmd(CC.SetFilesFolder(filesFolder)) + val r = sendCmd(null, CC.SetFilesFolder(filesFolder)) if (r is CR.CmdOk) return throw Error("failed to set files folder: ${r.responseType} ${r.details}") } private suspend fun apiSetRemoteHostsFolder(remoteHostsFolder: String) { - val r = sendCmd(CC.SetRemoteHostsFolder(remoteHostsFolder)) + val r = sendCmd(null, CC.SetRemoteHostsFolder(remoteHostsFolder)) if (r is CR.CmdOk) return throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") } suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { - val r = sendCmd(CC.ApiSetXFTPConfig(cfg)) + 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(CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) suspend fun apiExportArchive(config: ArchiveConfig) { - val r = sendCmd(CC.ApiExportArchive(config)) + val r = sendCmd(null, CC.ApiExportArchive(config)) if (r is CR.CmdOk) return throw Error("failed to export archive: ${r.responseType} ${r.details}") } suspend fun apiImportArchive(config: ArchiveConfig): List { - val r = sendCmd(CC.ApiImportArchive(config)) + val r = sendCmd(null, CC.ApiImportArchive(config)) if (r is CR.ArchiveImported) return r.archiveErrors throw Error("failed to import archive: ${r.responseType} ${r.details}") } suspend fun apiDeleteStorage() { - val r = sendCmd(CC.ApiDeleteStorage()) + val r = sendCmd(null, CC.ApiDeleteStorage()) if (r is CR.CmdOk) return throw Error("failed to delete storage: ${r.responseType} ${r.details}") } suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? { - val r = sendCmd(CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) + val r = sendCmd(null, CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) if (r is CR.CmdOk) return null else if (r is CR.ChatCmdError) return r throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - suspend fun apiGetChats(): List { + suspend fun apiGetChats(rh: Long?): List { val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() } - val r = sendCmd(CC.ApiGetChats(userId)) - if (r is CR.ApiChats) return r.chats + val r = sendCmd(rh, CC.ApiGetChats(userId)) + if (r is CR.ApiChats) return if (rh == null) r.chats else r.chats.map { it.copy(remoteHostId = rh) } Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chats_title), generalGetString(MR.strings.contact_developers)) return emptyList() } - suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { - val r = sendCmd(CC.ApiGetChat(type, id, pagination, search)) - if (r is CR.ApiChat) return r.chat + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) + if (r is CR.ApiChat) return if (rh == null) r.chat else r.chat.copy(remoteHostId = rh) Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) return null } - suspend fun apiSendMessage(rhId: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(rh: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) - val r = sendCmd(cmd, rhId) + val r = sendCmd(rh, cmd) return when (r) { is CR.NewChatItem -> r.chatItem else -> { @@ -639,8 +642,8 @@ object ChatController { } } - suspend fun apiGetChatItemInfo(type: ChatType, id: Long, itemId: Long): ChatItemInfo? { - return when (val r = sendCmd(CC.ApiGetChatItemInfo(type, id, itemId))) { + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { + return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo else -> { apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) @@ -649,38 +652,38 @@ object ChatController { } } - suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { - val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) if (r is CR.ChatItemUpdated) return r.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiChatItemReaction(type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { - val r = sendCmd(CC.ApiChatItemReaction(type, id, itemId, add, reaction)) + suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { + val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, itemId, add, reaction)) if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? { - val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode)) + suspend fun apiDeleteChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? { + val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, itemId, mode)) if (r is CR.ChatItemDeleted) return r Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteMemberChatItem(groupId: Long, groupMemberId: Long, itemId: Long): Pair? { - val r = sendCmd(CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId)) + suspend fun apiDeleteMemberChatItem(rh: Long?, groupId: Long, groupMemberId: Long, itemId: Long): Pair? { + val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId)) if (r is CR.ChatItemDeleted) return r.deletedChatItem.chatItem to r.toChatItem?.chatItem Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun getUserProtoServers(serverProtocol: ServerProtocol): UserProtocolServers? { + suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? { val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null } - val r = sendCmd(CC.APIGetUserProtoServers(userId, serverProtocol)) - return if (r is CR.UserProtoServers) r.servers + val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol)) + return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) } else { Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( @@ -691,9 +694,9 @@ object ChatController { } } - suspend fun setUserProtoServers(serverProtocol: ServerProtocol, servers: List): Boolean { + suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List): Boolean { val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false } - val r = sendCmd(CC.APISetUserProtoServers(userId, serverProtocol, servers)) + val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers)) return when (r) { is CR.CmdOk -> true else -> { @@ -707,9 +710,9 @@ object ChatController { } } - suspend fun testProtoServer(server: String): ProtocolTestFailure? { + suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") - val r = sendCmd(CC.APITestProtoServer(userId, server)) + val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) return when (r) { is CR.ServerTestResult -> r.testFailure else -> { @@ -719,29 +722,22 @@ object ChatController { } } - suspend fun getChatItemTTL(): ChatItemTTL { + suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") - val r = sendCmd(CC.APIGetChatItemTTL(userId)) + val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } - suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) { + suspend fun setChatItemTTL(rh: Long?, chatItemTTL: ChatItemTTL) { val userId = currentUserId("setChatItemTTL") - val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) + val r = sendCmd(rh, CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) if (r is CR.CmdOk) return throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } - suspend fun apiGetNetworkConfig(): NetCfg? { - val r = sendCmd(CC.APIGetNetworkConfig()) - if (r is CR.NetworkConfig) return r.networkConfig - Log.e(TAG, "apiGetNetworkConfig bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean { - val r = sendCmd(CC.APISetNetworkConfig(cfg)) + val r = sendCmd(null, CC.APISetNetworkConfig(cfg)) return when (r) { is CR.CmdOk -> true else -> { @@ -755,8 +751,8 @@ object ChatController { } } - suspend fun apiSetSettings(type: ChatType, id: Long, settings: ChatSettings): Boolean { - val r = sendCmd(CC.APISetChatSettings(type, id, settings)) + suspend fun apiSetSettings(rh: Long?, type: ChatType, id: Long, settings: ChatSettings): Boolean { + val r = sendCmd(rh, CC.APISetChatSettings(type, id, settings)) return when (r) { is CR.CmdOk -> true else -> { @@ -766,88 +762,88 @@ object ChatController { } } - suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = - sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = + sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) - suspend fun apiContactInfo(contactId: Long): Pair? { - val r = sendCmd(CC.APIContactInfo(contactId)) + suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { + val r = sendCmd(rh, CC.APIContactInfo(contactId)) if (r is CR.ContactInfo) return r.connectionStats_ to r.customUserProfile Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId)) + suspend fun apiGroupMemberInfo(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIGroupMemberInfo(groupId, groupMemberId)) if (r is CR.GroupMemberInfo) return Pair(r.member, r.connectionStats_) Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSwitchContact(contactId: Long): ConnectionStats? { - val r = sendCmd(CC.APISwitchContact(contactId)) + suspend fun apiSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { + val r = sendCmd(rh, CC.APISwitchContact(contactId)) if (r is CR.ContactSwitchStarted) return r.connectionStats apiErrorAlert("apiSwitchContact", generalGetString(MR.strings.error_changing_address), r) return null } - suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId)) + suspend fun apiSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APISwitchGroupMember(groupId, groupMemberId)) if (r is CR.GroupMemberSwitchStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSwitchGroupMember", generalGetString(MR.strings.error_changing_address), r) return null } - suspend fun apiAbortSwitchContact(contactId: Long): ConnectionStats? { - val r = sendCmd(CC.APIAbortSwitchContact(contactId)) + suspend fun apiAbortSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { + val r = sendCmd(rh, CC.APIAbortSwitchContact(contactId)) if (r is CR.ContactSwitchAborted) return r.connectionStats apiErrorAlert("apiAbortSwitchContact", generalGetString(MR.strings.error_aborting_address_change), r) return null } - suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) + suspend fun apiAbortSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) if (r is CR.GroupMemberSwitchAborted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(MR.strings.error_aborting_address_change), r) return null } - suspend fun apiSyncContactRatchet(contactId: Long, force: Boolean): ConnectionStats? { - val r = sendCmd(CC.APISyncContactRatchet(contactId, force)) + suspend fun apiSyncContactRatchet(rh: Long?, contactId: Long, force: Boolean): ConnectionStats? { + val r = sendCmd(rh, CC.APISyncContactRatchet(contactId, force)) if (r is CR.ContactRatchetSyncStarted) return r.connectionStats apiErrorAlert("apiSyncContactRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } - suspend fun apiSyncGroupMemberRatchet(groupId: Long, groupMemberId: Long, force: Boolean): Pair? { - val r = sendCmd(CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) + suspend fun apiSyncGroupMemberRatchet(rh: Long?, groupId: Long, groupMemberId: Long, force: Boolean): Pair? { + val r = sendCmd(rh, CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) if (r is CR.GroupMemberRatchetSyncStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSyncGroupMemberRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } - suspend fun apiGetContactCode(contactId: Long): Pair? { - val r = sendCmd(CC.APIGetContactCode(contactId)) + suspend fun apiGetContactCode(rh: Long?, contactId: Long): Pair? { + val r = sendCmd(rh, CC.APIGetContactCode(contactId)) if (r is CR.ContactCode) return r.contact to r.connectionCode Log.e(TAG,"failed to get contact code: ${r.responseType} ${r.details}") return null } - suspend fun apiGetGroupMemberCode(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIGetGroupMemberCode(groupId, groupMemberId)) + suspend fun apiGetGroupMemberCode(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIGetGroupMemberCode(groupId, groupMemberId)) if (r is CR.GroupMemberCode) return r.member to r.connectionCode Log.e(TAG,"failed to get group member code: ${r.responseType} ${r.details}") return null } - suspend fun apiVerifyContact(contactId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(CC.APIVerifyContact(contactId, connectionCode))) { + suspend fun apiVerifyContact(rh: Long?, contactId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(rh, CC.APIVerifyContact(contactId, connectionCode))) { is CR.ConnectionVerified -> r.verified to r.expectedCode else -> null } } - suspend fun apiVerifyGroupMember(groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { + suspend fun apiVerifyGroupMember(rh: Long?, groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(rh, CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { is CR.ConnectionVerified -> r.verified to r.expectedCode else -> null } @@ -855,12 +851,12 @@ object ChatController { - suspend fun apiAddContact(incognito: Boolean): Pair? { + suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiAddContact: no current user") return null } - val r = sendCmd(CC.APIAddContact(userId, incognito)) + val r = sendCmd(rh, CC.APIAddContact(userId, incognito)) return when (r) { is CR.Invitation -> r.connReqInvitation to r.connection else -> { @@ -872,27 +868,27 @@ object ChatController { } } - suspend fun apiSetConnectionIncognito(connId: Long, incognito: Boolean): PendingContactConnection? { - val r = sendCmd(CC.ApiSetConnectionIncognito(connId, incognito)) + suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? { + val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito)) if (r is CR.ConnectionIncognitoUpdated) return r.toConnection Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiConnectPlan(connReq: String): ConnectionPlan? { + suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmd(CC.APIConnectPlan(userId, connReq)) + val r = sendCmd(rh, CC.APIConnectPlan(userId, connReq)) if (r is CR.CRConnectionPlan) return r.connectionPlan Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean { + suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") return false } - val r = sendCmd(CC.APIConnect(userId, incognito, connReq)) + val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { @@ -928,12 +924,12 @@ object ChatController { } } - suspend fun apiConnectContactViaAddress(incognito: Boolean, contactId: Long): Contact? { + suspend fun apiConnectContactViaAddress(rh: Long?, incognito: Boolean, contactId: Long): Contact? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnectContactViaAddress: no current user") return null } - val r = sendCmd(CC.ApiConnectContactViaAddress(userId, incognito, contactId)) + val r = sendCmd(rh, CC.ApiConnectContactViaAddress(userId, incognito, contactId)) when { r is CR.SentInvitationToContact -> return r.contact else -> { @@ -945,8 +941,8 @@ object ChatController { } } - suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean { - val r = sendCmd(CC.ApiDeleteChat(type, id, notify)) + suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean { + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify)) when { r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true @@ -964,24 +960,16 @@ object ChatController { return false } - suspend fun apiClearChat(type: ChatType, id: Long): ChatInfo? { - val r = sendCmd(CC.ApiClearChat(type, id)) + suspend fun apiClearChat(rh: Long?, type: ChatType, id: Long): ChatInfo? { + val r = sendCmd(rh, CC.ApiClearChat(type, id)) if (r is CR.ChatCleared) return r.chatInfo Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiListContacts(): List? { - val userId = kotlin.runCatching { currentUserId("apiListContacts") }.getOrElse { return null } - val r = sendCmd(CC.ApiListContacts(userId)) - if (r is CR.ContactsList) return r.contacts - Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}") - return null - } - - suspend fun apiUpdateProfile(profile: Profile): Pair>? { + suspend fun apiUpdateProfile(rh: Long?, profile: Profile): Pair>? { val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null } - val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) + val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile to emptyList() if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { @@ -991,39 +979,39 @@ object ChatController { return null } - suspend fun apiSetProfileAddress(on: Boolean): User? { + suspend fun apiSetProfileAddress(rh: Long?, on: Boolean): User? { val userId = try { currentUserId("apiSetProfileAddress") } catch (e: Exception) { return null } - return when (val r = sendCmd(CC.ApiSetProfileAddress(userId, on))) { + return when (val r = sendCmd(rh, CC.ApiSetProfileAddress(userId, on))) { is CR.UserProfileNoChange -> null is CR.UserProfileUpdated -> r.user else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") } } - suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? { - val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs)) + suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? { + val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs)) if (r is CR.ContactPrefsUpdated) return r.toContact Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? { - val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias)) + suspend fun apiSetContactAlias(rh: Long?, contactId: Long, localAlias: String): Contact? { + val r = sendCmd(rh, CC.ApiSetContactAlias(contactId, localAlias)) if (r is CR.ContactAliasUpdated) return r.toContact Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSetConnectionAlias(connId: Long, localAlias: String): PendingContactConnection? { - val r = sendCmd(CC.ApiSetConnectionAlias(connId, localAlias)) + suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { + val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) if (r is CR.ConnectionAliasUpdated) return r.toConnection Log.e(TAG, "apiSetConnectionAlias bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiCreateUserAddress(): String? { + suspend fun apiCreateUserAddress(rh: Long?): String? { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } - val r = sendCmd(CC.ApiCreateMyAddress(userId)) + val r = sendCmd(rh, CC.ApiCreateMyAddress(userId)) return when (r) { is CR.UserContactLinkCreated -> r.connReqContact else -> { @@ -1035,17 +1023,17 @@ object ChatController { } } - suspend fun apiDeleteUserAddress(): User? { + suspend fun apiDeleteUserAddress(rh: Long?): User? { val userId = try { currentUserId("apiDeleteUserAddress") } catch (e: Exception) { return null } - val r = sendCmd(CC.ApiDeleteMyAddress(userId)) + val r = sendCmd(rh, CC.ApiDeleteMyAddress(userId)) if (r is CR.UserContactLinkDeleted) return r.user Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}") return null } - private suspend fun apiGetUserAddress(): UserContactLinkRec? { + private suspend fun apiGetUserAddress(rh: Long?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("apiGetUserAddress") }.getOrElse { return null } - val r = sendCmd(CC.ApiShowMyAddress(userId)) + val r = sendCmd(rh, CC.ApiShowMyAddress(userId)) if (r is CR.UserContactLink) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound @@ -1056,9 +1044,9 @@ object ChatController { return null } - suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? { + suspend fun userAddressAutoAccept(rh: Long?, autoAccept: AutoAccept?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null } - val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept)) + val r = sendCmd(rh, CC.ApiAddressAutoAccept(userId, autoAccept)) if (r is CR.UserContactLinkUpdated) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound @@ -1069,8 +1057,8 @@ object ChatController { return null } - suspend fun apiAcceptContactRequest(incognito: Boolean, contactReqId: Long): Contact? { - val r = sendCmd(CC.ApiAcceptContact(incognito, contactReqId)) + suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? { + val r = sendCmd(rh, CC.ApiAcceptContact(incognito, contactReqId)) return when { r is CR.AcceptingContactRequest -> r.contact r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent @@ -1091,76 +1079,76 @@ object ChatController { } } - suspend fun apiRejectContactRequest(contactReqId: Long): Boolean { - val r = sendCmd(CC.ApiRejectContact(contactReqId)) + suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Boolean { + val r = sendCmd(rh, CC.ApiRejectContact(contactReqId)) if (r is CR.ContactRequestRejected) return true Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiSendCallInvitation(contact: Contact, callType: CallType): Boolean { - val r = sendCmd(CC.ApiSendCallInvitation(contact, callType)) + suspend fun apiSendCallInvitation(rh: Long?, contact: Contact, callType: CallType): Boolean { + val r = sendCmd(rh, CC.ApiSendCallInvitation(contact, callType)) return r is CR.CmdOk } - suspend fun apiRejectCall(contact: Contact): Boolean { - val r = sendCmd(CC.ApiRejectCall(contact)) + suspend fun apiRejectCall(rh: Long?, contact: Contact): Boolean { + val r = sendCmd(rh, CC.ApiRejectCall(contact)) return r is CR.CmdOk } - suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { + suspend fun apiSendCallOffer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) - val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer)) + val r = sendCmd(rh, CC.ApiSendCallOffer(contact, callOffer)) return r is CR.CmdOk } - suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { + suspend fun apiSendCallAnswer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { val answer = WebRTCSession(rtcSession, rtcIceCandidates) - val r = sendCmd(CC.ApiSendCallAnswer(contact, answer)) + val r = sendCmd(rh, CC.ApiSendCallAnswer(contact, answer)) return r is CR.CmdOk } - suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: String): Boolean { + suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { val extraInfo = WebRTCExtraInfo(rtcIceCandidates) - val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo)) + val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) return r is CR.CmdOk } - suspend fun apiEndCall(contact: Contact): Boolean { - val r = sendCmd(CC.ApiEndCall(contact)) + suspend fun apiEndCall(rh: Long?, contact: Contact): Boolean { + val r = sendCmd(rh, CC.ApiEndCall(contact)) return r is CR.CmdOk } - suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean { - val r = sendCmd(CC.ApiCallStatus(contact, status)) + suspend fun apiCallStatus(rh: Long?, contact: Contact, status: WebRTCCallStatus): Boolean { + val r = sendCmd(rh, CC.ApiCallStatus(contact, status)) return r is CR.CmdOk } - suspend fun apiGetNetworkStatuses(): List? { - val r = sendCmd(CC.ApiGetNetworkStatuses()) + suspend fun apiGetNetworkStatuses(rh: Long?): List? { + val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) if (r is CR.NetworkStatuses) return r.networkStatuses Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { - val r = sendCmd(CC.ApiChatRead(type, id, range)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, range: CC.ItemRange): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiChatUnread(type: ChatType, id: Long, unreadChat: Boolean): Boolean { - val r = sendCmd(CC.ApiChatUnread(type, id, unreadChat)) + suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { + val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatUnread bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiReceiveFile(rhId: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { + 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(CC.ReceiveFile(fileId, encrypted, inline), rhId ?: -1) + val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline)) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1188,16 +1176,16 @@ object ChatController { } } - suspend fun cancelFile(rhId: Long?, user: User, fileId: Long) { - val chatItem = apiCancelFile(fileId) + suspend fun cancelFile(rh: Long?, user: User, fileId: Long) { + val chatItem = apiCancelFile(rh, fileId) if (chatItem != null) { - chatItemSimpleUpdate(rhId, user, chatItem) + chatItemSimpleUpdate(rh, user, chatItem) cleanupFile(chatItem) } } - suspend fun apiCancelFile(fileId: Long): AChatItem? { - val r = sendCmd(CC.CancelFile(fileId)) + suspend fun apiCancelFile(rh: Long?, fileId: Long): AChatItem? { + val r = sendCmd(rh, CC.CancelFile(fileId)) return when (r) { is CR.SndFileCancelled -> r.chatItem is CR.RcvFileCancelled -> r.chatItem @@ -1208,16 +1196,16 @@ object ChatController { } } - suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { + suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } - val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile)) + val r = sendCmd(rh, CC.ApiNewGroup(userId, incognito, groupProfile)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { - val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole)) + suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { + val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) return when (r) { is CR.SentGroupInvitation -> r.member else -> { @@ -1229,14 +1217,14 @@ object ChatController { } } - suspend fun apiJoinGroup(groupId: Long) { - val r = sendCmd(CC.ApiJoinGroup(groupId)) + suspend fun apiJoinGroup(rh: Long?, groupId: Long) { + val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) when (r) { is CR.UserAcceptedGroupSent -> - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rh, r.groupInfo) is CR.ChatCmdError -> { val e = r.chatError - suspend fun deleteGroup() { if (apiDeleteChat(ChatType.Group, groupId)) { chatModel.removeChat("#$groupId") } } + suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { chatModel.removeChat(rh, "#$groupId") } } if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) { deleteGroup() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.alert_title_group_invitation_expired), generalGetString(MR.strings.alert_message_group_invitation_expired)) @@ -1251,8 +1239,8 @@ object ChatController { } } - suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? = - when (val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))) { + suspend fun apiRemoveMember(rh: Long?, groupId: Long, memberId: Long): GroupMember? = + when (val r = sendCmd(rh, CC.ApiRemoveMember(groupId, memberId))) { is CR.UserDeletedMember -> r.member else -> { if (!(networkErrorAlert(r))) { @@ -1262,8 +1250,8 @@ object ChatController { } } - suspend fun apiMemberRole(groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = - when (val r = sendCmd(CC.ApiMemberRole(groupId, memberId, memberRole))) { + suspend fun apiMemberRole(rh: Long?, groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = + when (val r = sendCmd(rh, CC.ApiMemberRole(groupId, memberId, memberRole))) { is CR.MemberRoleUser -> r.member else -> { if (!(networkErrorAlert(r))) { @@ -1273,22 +1261,22 @@ object ChatController { } } - suspend fun apiLeaveGroup(groupId: Long): GroupInfo? { - val r = sendCmd(CC.ApiLeaveGroup(groupId)) + suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? { + val r = sendCmd(rh, CC.ApiLeaveGroup(groupId)) if (r is CR.LeftMemberUser) return r.groupInfo Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiListMembers(groupId: Long): List { - val r = sendCmd(CC.ApiListMembers(groupId)) + suspend fun apiListMembers(rh: Long?, groupId: Long): List { + val r = sendCmd(rh, CC.ApiListMembers(groupId)) if (r is CR.GroupMembers) return r.group.members Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}") return emptyList() } - suspend fun apiUpdateGroup(groupId: Long, groupProfile: GroupProfile): GroupInfo? { - return when (val r = sendCmd(CC.ApiUpdateGroupProfile(groupId, groupProfile))) { + suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? { + return when (val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile))) { is CR.GroupUpdated -> r.toGroup is CR.ChatCmdError -> { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.chatError") @@ -1305,8 +1293,8 @@ object ChatController { } } - suspend fun apiCreateGroupLink(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(CC.APICreateGroupLink(groupId, memberRole))) { + suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole))) { is CR.GroupLinkCreated -> r.connReqContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { @@ -1317,8 +1305,8 @@ object ChatController { } } - suspend fun apiGroupLinkMemberRole(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(CC.APIGroupLinkMemberRole(groupId, memberRole))) { + suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole))) { is CR.GroupLink -> r.connReqContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { @@ -1329,8 +1317,8 @@ object ChatController { } } - suspend fun apiDeleteGroupLink(groupId: Long): Boolean { - return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) { + suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean { + return when (val r = sendCmd(rh, CC.APIDeleteGroupLink(groupId))) { is CR.GroupLinkDeleted -> true else -> { if (!(networkErrorAlert(r))) { @@ -1341,8 +1329,8 @@ object ChatController { } } - suspend fun apiGetGroupLink(groupId: Long): Pair? { - return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) { + suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { + return when (val r = sendCmd(rh, CC.APIGetGroupLink(groupId))) { is CR.GroupLink -> r.connReqContact to r.memberRole else -> { Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") @@ -1351,8 +1339,8 @@ object ChatController { } } - suspend fun apiCreateMemberContact(groupId: Long, groupMemberId: Long): Contact? { - return when (val r = sendCmd(CC.APICreateMemberContact(groupId, groupMemberId))) { + suspend fun apiCreateMemberContact(rh: Long?, groupId: Long, groupMemberId: Long): Contact? { + return when (val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId))) { is CR.NewMemberContact -> r.contact else -> { if (!(networkErrorAlert(r))) { @@ -1363,8 +1351,8 @@ object ChatController { } } - suspend fun apiSendMemberContactInvitation(contactId: Long, mc: MsgContent): Contact? { - return when (val r = sendCmd(CC.APISendMemberContactInvitation(contactId, mc))) { + suspend fun apiSendMemberContactInvitation(rh: Long?, contactId: Long, mc: MsgContent): Contact? { + return when (val r = sendCmd(rh, CC.APISendMemberContactInvitation(contactId, mc))) { is CR.NewMemberContactSentInv -> r.contact else -> { if (!(networkErrorAlert(r))) { @@ -1375,18 +1363,18 @@ object ChatController { } } - suspend fun allowFeatureToContact(contact: Contact, feature: ChatFeature, param: Int? = null) { + suspend fun allowFeatureToContact(rh: Long?, contact: Contact, feature: ChatFeature, param: Int? = null) { val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param) - val toContact = apiSetContactPrefs(contact.contactId, prefs) + val toContact = apiSetContactPrefs(rh, contact.contactId, prefs) if (toContact != null) { - chatModel.updateContact(toContact) + chatModel.updateContact(rh, toContact) } } - suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName)) + suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(null, CC.SetLocalDeviceName(displayName)) suspend fun listRemoteHosts(): List? { - val r = sendCmd(CC.ListRemoteHosts()) + val r = sendCmd(null, CC.ListRemoteHosts()) if (r is CR.RemoteHostList) return r.remoteHosts apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null @@ -1399,20 +1387,20 @@ object ChatController { } suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair? { - val r = sendCmd(CC.StartRemoteHost(rhId, multicast)) + val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) if (r is CR.RemoteHostStarted) return r.remoteHost_ to r.invitation apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun switchRemoteHost (rhId: Long?): RemoteHostInfo? { - val r = sendCmd(CC.SwitchRemoteHost(rhId)) + val r = sendCmd(null, CC.SwitchRemoteHost(rhId)) if (r is CR.CurrentRemoteHost) return r.remoteHost_ apiErrorAlert("switchRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun stopRemoteHost(rhId: Long?): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) + suspend fun stopRemoteHost(rhId: Long?): Boolean = sendCommandOkResp(null, CC.StopRemoteHost(rhId)) fun stopRemoteHostAndReloadHosts(h: RemoteHostInfo, switchToLocal: Boolean) { withBGApi { @@ -1425,55 +1413,55 @@ object ChatController { } } - suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) + suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteHost(rhId)) suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? { - val r = sendCmd(CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) + val r = sendCmd(null, CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) if (r is CR.RemoteFileStored) return r.remoteFileSource apiErrorAlert("storeRemoteFile", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(CC.GetRemoteFile(rhId, file)) + suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(null, CC.GetRemoteFile(rhId, file)) suspend fun connectRemoteCtrl(desktopAddress: String): Pair { - val r = sendCmd(CC.ConnectRemoteCtrl(desktopAddress)) + val r = sendCmd(null, CC.ConnectRemoteCtrl(desktopAddress)) if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null else if (r is CR.ChatCmdError) return null to r else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") } - suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl()) + suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rcId)) + suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.ConfirmRemoteCtrl(rcId)) suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { - val r = sendCmd(CC.VerifyRemoteCtrlSession(sessionCode)) + val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode)) if (r is CR.RemoteCtrlConnected) return r.remoteCtrl apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun listRemoteCtrls(): List? { - val r = sendCmd(CC.ListRemoteCtrls()) + val r = sendCmd(null, CC.ListRemoteCtrls()) if (r is CR.RemoteCtrlList) return r.remoteCtrls apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl()) + suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.StopRemoteCtrl()) - suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId)) + suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteCtrl(rcId)) - private suspend fun sendCommandOkResp(cmd: CC): Boolean { - val r = sendCmd(cmd) + private suspend fun sendCommandOkResp(rh: Long?, cmd: CC): Boolean { + val r = sendCmd(rh, cmd) val ok = r is CR.CmdOk if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok } suspend fun apiGetVersion(): CoreVersionInfo? { - val r = sendCmd(CC.ShowVersion()) + val r = sendCmd(null, CC.ShowVersion()) return if (r is CR.VersionInfo) { r.versionInfo } else { @@ -1517,30 +1505,30 @@ object ChatController { val r = apiResp.resp val rhId = apiResp.remoteHostId fun active(user: UserLike): Boolean = activeUser(rhId, user) - chatModel.addTerminalItem(TerminalItem.resp(r)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) when (r) { is CR.NewContactConnection -> { if (active(r.user)) { - chatModel.updateContactConnection(r.connection) + chatModel.updateContactConnection(rhId, r.connection) } } is CR.ContactConnectionDeleted -> { if (active(r.user)) { - chatModel.removeChat(r.connection.id) + chatModel.removeChat(rhId, r.connection.id) } } is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) } } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } if (r.contact.directOrUsed) { @@ -1550,11 +1538,11 @@ object ChatController { } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } } @@ -1562,31 +1550,31 @@ object ChatController { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { - if (chatModel.hasChat(contactRequest.id)) { - chatModel.updateChatInfo(cInfo) + if (chatModel.hasChat(rhId, contactRequest.id)) { + chatModel.updateChatInfo(rhId, cInfo) } else { - chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf())) + chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) } } ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - if (active(r.user) && chatModel.hasChat(r.toContact.id)) { + if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) - chatModel.updateChatInfo(cInfo) + chatModel.updateChatInfo(rhId, cInfo) } } is CR.GroupMemberUpdated -> { if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.toMember) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.toMember) } } is CR.ContactsMerged -> { - if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { + if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } - chatModel.removeChat(r.mergedContact.id) + chatModel.removeChat(rhId, r.mergedContact.id) } } is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) @@ -1594,7 +1582,7 @@ object ChatController { is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { - chatModel.updateContact(sub.contact) + chatModel.updateContact(rhId, sub.contact) } val err = sub.contactError if (err == null) { @@ -1618,9 +1606,9 @@ object ChatController { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem if (active(r.user)) { - chatModel.addChatItem(cInfo, cItem) + chatModel.addChatItem(rhId, cInfo, cItem) } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { - chatModel.increaseUnreadCounter(r.user) + chatModel.increaseUnreadCounter(rhId, r.user) } val file = cItem.file val mc = cItem.content.msgContent @@ -1631,7 +1619,7 @@ object ChatController { || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { withApi { receiveFile(rhId, r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } } - if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) { + if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId != rhId)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } @@ -1652,7 +1640,7 @@ object ChatController { is CR.ChatItemDeleted -> { if (!active(r.user)) { if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) { - chatModel.decreaseUnreadCounter(r.user) + chatModel.decreaseUnreadCounter(rhId, r.user) } return } @@ -1671,76 +1659,76 @@ object ChatController { ) } if (r.toChatItem == null) { - chatModel.removeChatItem(cInfo, cItem) + chatModel.removeChatItem(rhId, cInfo, cItem) } else { - chatModel.upsertChatItem(cInfo, r.toChatItem.chatItem) + chatModel.upsertChatItem(rhId, cInfo, r.toChatItem.chatItem) } } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated + chatModel.updateGroup(rhId, r.groupInfo) // update so that repeat group invitations are not duplicated // TODO NtfManager.shared.notifyGroupInvitation } } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) val conn = r.hostContact?.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } is CR.GroupLinkConnecting -> { if (!active(r.user)) return - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.dismissConnReqView(hostConn.id) - chatModel.removeChat(hostConn.id) + chatModel.removeChat(rhId, hostConn.id) } } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.DeletedMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) } is CR.LeftMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.MemberRole -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.MemberRoleUser -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.UserJoinedGroup -> if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.JoinedGroupMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.ConnectedToGroupMember -> { if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } if (r.memberContact != null) { chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) @@ -1748,11 +1736,11 @@ object ChatController { } is CR.GroupUpdated -> if (active(r.user)) { - chatModel.updateGroup(r.toGroup) + chatModel.updateGroup(rhId, r.toGroup) } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) } is CR.RcvFileStart -> chatItemSimpleUpdate(rhId, r.user, r.chatItem) @@ -1789,7 +1777,7 @@ object ChatController { cleanupFile(r.chatItem) } is CR.CallInvitation -> { - chatModel.callManager.reportNewIncomingCall(r.callInvitation) + chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) } is CR.CallOffer -> { // TODO askConfirmation? @@ -1834,13 +1822,13 @@ object ChatController { } } is CR.ContactSwitch -> - chatModel.updateContactConnectionStats(r.contact, r.switchProgress.connectionStats) + chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) is CR.GroupMemberSwitch -> - chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.switchProgress.connectionStats) + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) is CR.ContactRatchetSync -> - chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) + chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) is CR.GroupMemberRatchetSync -> - chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) is CR.RemoteHostSessionCode -> { chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) } @@ -1850,9 +1838,11 @@ object ChatController { switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.RemoteHostStopped -> { - chatModel.currentRemoteHost.value = null chatModel.newRemoteHostPairing.value = null - switchUIRemoteHost(null) + if (chatModel.currentRemoteHost.value != null) { + chatModel.currentRemoteHost.value = null + switchUIRemoteHost(null) + } } is CR.RemoteCtrlFound -> { // TODO multicast @@ -1897,11 +1887,11 @@ object ChatController { val m = chatModel m.remoteCtrlSession.value = null withBGApi { - val users = listUsers() + val users = listUsers(null) m.users.clear() m.users.addAll(users) - getUserChatData() - val statuses = apiGetNetworkStatuses() + getUserChatData(null) + val statuses = apiGetNetworkStatuses(null) if (statuses != null) { chatModel.networkStatuses.clear() val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() @@ -1911,7 +1901,7 @@ object ChatController { } private fun activeUser(rhId: Long?, user: UserLike): Boolean = - rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId + rhId == chatModel.remoteHostId && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value @@ -1929,20 +1919,20 @@ object ChatController { } } - suspend fun leaveGroup(groupId: Long) { - val groupInfo = apiLeaveGroup(groupId) + suspend fun leaveGroup(rh: Long?, groupId: Long) { + val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { - chatModel.updateGroup(groupInfo) + chatModel.updateGroup(rh, groupInfo) } } - private suspend fun chatItemSimpleUpdate(rhId: Long?, user: UserLike, aChatItem: AChatItem) { + private suspend fun chatItemSimpleUpdate(rh: Long?, user: UserLike, aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) } - if (!activeUser(rhId, user)) { + if (!activeUser(rh, user)) { notify() - } else if (chatModel.upsertChatItem(cInfo, cItem)) { + } else if (chatModel.upsertChatItem(rh, cInfo, cItem)) { notify() } } @@ -1969,22 +1959,23 @@ object ChatController { } suspend fun switchUIRemoteHost(rhId: Long?) { + // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null chatModel.currentRemoteHost.value = switchRemoteHost(rhId) reloadRemoteHosts() - val user = apiGetActiveUser() - val users = listUsers() + val user = apiGetActiveUser(rhId) + val users = listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) chatModel.currentUser.value = user chatModel.userCreated.value = true - val statuses = apiGetNetworkStatuses() + val statuses = apiGetNetworkStatuses(rhId) if (statuses != null) { chatModel.networkStatuses.clear() val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() chatModel.networkStatuses.putAll(ss) } - getUserChatData() + getUserChatData(rhId) } fun getXFTPCfg(): XFTPFileConfig { @@ -2540,6 +2531,7 @@ data class UserProtocolServers( @Serializable data class ServerCfg( + val remoteHostId: Long? = null, val server: String, val preset: Boolean, val tested: Boolean? = null, @@ -3610,7 +3602,7 @@ private fun parseChatData(chat: JsonElement): Chat { val chatItems: List = chat.jsonObject["chatItems"]!!.jsonArray.map { decodeObject(ChatItem.serializer(), it) ?: parseChatItem(it) } - return Chat(chatInfo, chatItems, chatStats) + return Chat(remoteHostId = null, chatInfo, chatItems, chatStats) } private fun parseChatItem(j: JsonElement): ChatItem { @@ -3699,7 +3691,6 @@ sealed class CR { @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: UserRef, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() - @Serializable @SerialName("contactsList") class ContactsList(val user: UserRef, val contacts: List): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @@ -3853,7 +3844,6 @@ sealed class CR { is ChatItemNotChanged -> "chatItemNotChanged" is ChatItemReaction -> "chatItemReaction" is ChatItemDeleted -> "chatItemDeleted" - is ContactsList -> "contactsList" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" @@ -4001,7 +3991,6 @@ sealed class CR { is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser") - is ContactsList -> withUser(user, json.encodeToString(contacts)) is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) @@ -4138,28 +4127,29 @@ sealed class GroupLinkPlan { abstract class TerminalItem { abstract val id: Long + abstract val remoteHostId: Long? val date: Instant = Clock.System.now() abstract val label: String abstract val details: String - class Cmd(override val id: Long, val cmd: CC): TerminalItem() { + class Cmd(override val id: Long, override val remoteHostId: Long?, val cmd: CC): TerminalItem() { override val label get() = "> ${cmd.cmdString}" override val details get() = cmd.cmdString } - class Resp(override val id: Long, val resp: CR): TerminalItem() { + class Resp(override val id: Long, override val remoteHostId: Long?, val resp: CR): TerminalItem() { override val label get() = "< ${resp.responseType}" override val details get() = resp.details } companion object { val sampleData = listOf( - Cmd(0, CC.ShowActiveUser()), - Resp(1, CR.ActiveUser(User.sampleData)) + Cmd(0, null, CC.ShowActiveUser()), + Resp(1, null, CR.ActiveUser(User.sampleData)) ) - fun cmd(c: CC) = Cmd(System.currentTimeMillis(), c) - fun resp(r: CR) = Resp(System.currentTimeMillis(), r) + fun cmd(rhId: Long?, c: CC) = Cmd(System.currentTimeMillis(), rhId, c) + fun resp(rhId: Long?, r: CR) = Resp(System.currentTimeMillis(), rhId, r) } } 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 c32137ee61..3d3a91cb32 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 @@ -53,7 +53,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } else if (startChat) { // 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() + val user = chatController.apiGetActiveUser(null) if (user == null) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index a03df5addb..06925e28a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -49,7 +49,8 @@ abstract class NtfManager { null } val apiId = chatId.replace("<@", "").toLongOrNull() ?: return - acceptContactRequest(incognito, apiId, cInfo, isCurrentUser, ChatModel) + // TODO include remote host in notification + acceptContactRequest(null, incognito, apiId, cInfo, isCurrentUser, ChatModel) cancelNotificationsForChat(chatId) } @@ -57,11 +58,12 @@ abstract class NtfManager { withBGApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { - chatModel.controller.changeActiveUser(userId, null) + // TODO include remote host ID in desktop notifications? + chatModel.controller.changeActiveUser(null, userId, null) } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true - if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel) + if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel) } } @@ -69,7 +71,8 @@ abstract class NtfManager { withBGApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { - chatModel.controller.changeActiveUser(userId, null) + // TODO include remote host ID in desktop notifications? + chatModel.controller.changeActiveUser(null, userId, null) } chatModel.chatId.value = null chatModel.clearOverlays.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index a471b5645e..ec2082557a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -47,13 +47,14 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState) { val clipboard = LocalClipboardManager.current LazyColumn(state = listState, reverseLayout = true) { items(reversedTerminalItems) { item -> + val rhId = item.remoteHostId + val rhIdStr = if (rhId == null) "" else "$rhId " Text( - "${item.date.toString().subSequence(11, 19)} ${item.label}", + "$rhIdStr${item.date.toString().subSequence(11, 19)} ${item.label}", style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary), maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 504ecac89d..fb15f0aba4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -170,18 +170,19 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { + val rhId = chatModel.remoteHostId val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName.trim(), "", null) + rhId, Profile(displayName.trim(), "", null) ) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) } else { - val users = chatModel.controller.listUsers() + val users = chatModel.controller.listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - chatModel.controller.getUserChatData() + chatModel.controller.getUserChatData(rhId) close() } } @@ -190,7 +191,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { chatModel.controller.apiCreateActiveUser( - Profile(displayName.trim(), "", null) + null, Profile(displayName.trim(), "", null) ) ?: return@withApi val onboardingStage = chatModel.controller.appPrefs.onboardingStage if (chatModel.users.isEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index f601776f98..d0c9a6e4c6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -43,6 +43,7 @@ class CallManager(val chatModel: ChatModel) { private fun justAcceptIncomingCall(invitation: RcvCallInvitation) { with (chatModel) { activeCall.value = Call( + remoteHostId = invitation.remoteHostId, contact = invitation.contact, callState = CallState.InvitationAccepted, localMedia = invitation.callType.media, @@ -76,7 +77,7 @@ class CallManager(val chatModel: ChatModel) { Log.d(TAG, "CallManager.endCall: ending call...") callCommand.add(WCallCommand.End) showCallView.value = false - controller.apiEndCall(call.contact) + controller.apiEndCall(call.remoteHostId, call.contact) activeCall.value = null } } @@ -90,7 +91,7 @@ class CallManager(val chatModel: ChatModel) { ntfManager.cancelCallNotification() } withApi { - if (!controller.apiRejectCall(invitation.contact)) { + if (!controller.apiRejectCall(invitation.remoteHostId, invitation.contact)) { Log.e(TAG, "apiRejectCall error") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 4be49d4c07..64904ba7a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -11,6 +11,7 @@ import java.util.* import kotlin.collections.ArrayList data class Call( + val remoteHostId: Long? = null, val contact: Contact, val callState: CallState, val localMedia: CallMediaType, @@ -95,7 +96,14 @@ sealed class WCallResponse { @Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) @Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) -@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) { +@Serializable data class RcvCallInvitation( + val remoteHostId: Long? = null, + val user: User, + val contact: Contact, + val callType: CallType, + val sharedKey: String? = null, + val callTs: Instant +) { val callTypeText: String get() = generalGetString(when(callType.media) { CallMediaType.Video -> if (sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call 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 b7c5e66a68..5816c89524 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 @@ -61,6 +61,7 @@ fun ChatInfoView( val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { mutableStateOf(chatModel.contactNetworkStatus(contact)) } + val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } ChatInfoLayout( chat, @@ -81,25 +82,25 @@ fun ChatInfoView( connectionCode, developerTools, onLocalAliasChanged = { - setContactAlias(chat.chatInfo.apiId, it, chatModel) + setContactAlias(chat, it, chatModel) }, openPreferences = { ModalManager.end.showCustomModal { close -> val user = chatModel.currentUser.value if (user != null) { - ContactPreferencesView(chatModel, user, contact.contactId, close) + ContactPreferencesView(chatModel, user, chatRh, contact.contactId, close) } } }, - deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, - clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, + deleteContact = { deleteContactDialog(chat, chatModel, close) }, + clearChat = { clearChatDialog(chat, chatModel, close) }, switchContactAddress = { showSwitchAddressAlert(switchAddress = { withApi { - val cStats = chatModel.controller.apiSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -108,20 +109,20 @@ fun ChatInfoView( abortSwitchContactAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - val cStats = chatModel.controller.apiAbortSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiAbortSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } } }) }, syncContactConnection = { withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -129,10 +130,10 @@ fun ChatInfoView( syncContactConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = { withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = true) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = true) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -146,9 +147,10 @@ fun ChatInfoView( connectionCode, ct.verified, verify = { code -> - chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r -> + chatModel.controller.apiVerifyContact(chatRh, ct.contactId, code)?.let { r -> val (verified, existingCode) = r chatModel.updateContact( + chatRh, ct.copy( activeConn = ct.activeConn?.copy( connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null @@ -195,7 +197,8 @@ sealed class SendReceipts { } } -fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), @@ -206,7 +209,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close, notify = true) + deleteContact(chat, chatModel, close, notify = true) } }) { Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -215,7 +218,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close, notify = false) + deleteContact(chat, chatModel, close, notify = false) } }) { Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -225,7 +228,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close) + deleteContact(chat, chatModel, close) } }) { Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -242,11 +245,13 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> ) } -fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { +fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { + val chatInfo = chat.chatInfo withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify) + val chatRh = chat.remoteHostId + val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(chatRh, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -257,16 +262,18 @@ fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? } } -fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun clearChatDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), text = generalGetString(MR.strings.clear_chat_warning), confirmText = generalGetString(MR.strings.clear_verb), onConfirm = { withApi { - val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId) + val chatRh = chat.remoteHostId + val updatedChatInfo = chatModel.controller.apiClearChat(chatRh, chatInfo.chatType, chatInfo.apiId) if (updatedChatInfo != null) { - chatModel.clearChat(updatedChatInfo) + chatModel.clearChat(chatRh, updatedChatInfo) ntfManager.cancelNotificationsForChat(chatInfo.id) close?.invoke() } @@ -669,9 +676,10 @@ fun ShareAddressButton(onClick: () -> Unit) { ) } -private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi { - chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let { - chatModel.updateContact(it) +private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withApi { + val chatRh = chat.remoteHostId + chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { + chatModel.updateContact(chatRh, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 7097c77d1a..862212217f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -46,7 +46,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value - val rhId = remember { chatModel.currentRemoteHost }.value?.remoteHostId val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { mutableStateOf( @@ -101,11 +100,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } val view = LocalMultiplatformView() - if (activeChat.value == null || user == null) { + val chat = activeChat.value + if (chat == null || user == null) { chatModel.chatId.value = null ModalManager.end.closeModals() } else { - val chat = activeChat.value!! + val chatRh = chat.remoteHostId // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { @@ -167,11 +167,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: var preloadedCode: String? = null var preloadedLink: Pair? = null if (chat.chatInfo is ChatInfo.Direct) { - preloadedContactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) - preloadedCode = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)?.second + preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second } else if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chat.chatInfo.groupInfo, chatModel) - preloadedLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) + preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) } ModalManager.end.showModalCloseable(true) { close -> val chat = remember { activeChat }.value @@ -179,20 +179,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } var code: String? by remember { mutableStateOf(preloadedCode) } KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) { - contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) preloadedContactInfo = contactInfo - code = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)?.second + code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second preloadedCode = code } ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) } else if (chat?.chatInfo is ChatInfo.Group) { var link: Pair? by remember(chat.id) { mutableStateOf(preloadedLink) } KeyChangeEffect(chat.id) { - setGroupMembers((chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) - link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + setGroupMembers(chatRh, (chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) + link = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatModel, link?.first, link?.second, { + GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, { link = it preloadedLink = it }, close) @@ -203,19 +203,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { - val memCode = chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) member to memCode?.second } else { member to null } - setGroupMembers(groupInfo, chatModel) + setGroupMembers(chatRh, groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) } } } @@ -226,7 +226,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (c != null && firstId != null) { withApi { Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") - apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) + apiLoadPrevMessages(c, chatModel, firstId, searchText.value) Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") } } @@ -242,6 +242,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val toChatItem: ChatItem? if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { val r = chatModel.controller.apiDeleteMemberChatItem( + chatRh, groupId = groupInfo.groupId, groupMemberId = groupMember.groupMemberId, itemId = itemId @@ -250,6 +251,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: toChatItem = r?.second } else { val r = chatModel.controller.apiDeleteChatItem( + chatRh, type = cInfo.chatType, id = cInfo.apiId, itemId = itemId, @@ -259,9 +261,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: toChatItem = r?.toChatItem?.chatItem } if (toChatItem == null && deletedChatItem != null) { - chatModel.removeChatItem(cInfo, deletedChatItem) + chatModel.removeChatItem(chatRh, cInfo, deletedChatItem) } else if (toChatItem != null) { - chatModel.upsertChatItem(cInfo, toChatItem) + chatModel.upsertChatItem(chatRh, cInfo, toChatItem) } } }, @@ -272,27 +274,27 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val deletedItems: ArrayList = arrayListOf() for (itemId in itemIds) { val di = chatModel.controller.apiDeleteChatItem( - chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal + chatRh, chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal )?.deletedChatItem?.chatItem if (di != null) { deletedItems.add(di) } } for (di in deletedItems) { - chatModel.removeChatItem(chatInfo, di) + chatModel.removeChatItem(chatRh, chatInfo, di) } } } }, receiveFile = { fileId, encrypted -> - withApi { chatModel.controller.receiveFile(rhId, user, fileId, encrypted) } + withApi { chatModel.controller.receiveFile(chatRh, user, fileId, encrypted) } }, cancelFile = { fileId -> - withApi { chatModel.controller.cancelFile(rhId, user, fileId) } + withApi { chatModel.controller.cancelFile(chatRh, user, fileId) } }, joinGroup = { groupId, onComplete -> withApi { - chatModel.controller.apiJoinGroup(groupId) + chatModel.controller.apiJoinGroup(chatRh, groupId) onComplete.invoke() } }, @@ -300,7 +302,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withBGApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { - chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) + chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.showCallView.value = true chatModel.callCommand.add(WCallCommand.Capabilities(media)) } @@ -321,48 +323,48 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, acceptFeature = { contact, feature, param -> withApi { - chatModel.controller.allowFeatureToContact(contact, feature, param) + chatModel.controller.allowFeatureToContact(chatRh, contact, feature, param) } }, openDirectChat = { contactId -> withApi { - openDirectChat(contactId, chatModel) + openDirectChat(chatRh, contactId, chatModel) } }, updateContactStats = { contact -> withApi { - val r = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + val r = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) if (r != null) { val contactStats = r.first if (contactStats != null) - chatModel.updateContactConnectionStats(contact, contactStats) + chatModel.updateContactConnectionStats(chatRh, contact, contactStats) } } }, updateMemberStats = { groupInfo, member -> withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) if (r != null) { val memStats = r.second if (memStats != null) { - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, memStats) + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) } } } }, syncContactConnection = { contact -> withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } } }, syncMemberConnection = { groupInfo, member -> withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) } } }, @@ -375,6 +377,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: setReaction = { cInfo, cItem, add, reaction -> withApi { val updatedCI = chatModel.controller.apiChatItemReaction( + rh = chatRh, type = cInfo.chatType, id = cInfo.apiId, itemId = cItem.id, @@ -388,10 +391,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, showItemDetails = { cInfo, cItem -> withApi { - val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id) + val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chat.chatInfo.groupInfo, chatModel) + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) } ModalManager.end.closeModals() ModalManager.end.showModal(endButtons = { ShareButton { @@ -405,28 +408,29 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: addMembers = { groupInfo -> hideKeyboard(view) withApi { - setGroupMembers(groupInfo, chatModel) + setGroupMembers(chatRh, groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, false, chatModel, close) + AddGroupMembersView(chatRh, groupInfo, false, chatModel, close) } } }, openGroupLink = { groupInfo -> hideKeyboard(view) withApi { - val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId) + val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) } } }, markRead = { range, unreadCountAfter -> - chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) + chatModel.markChatItemsRead(chat, range, unreadCountAfter) ntfManager.cancelNotificationsForChat(chat.id) withBGApi { chatModel.controller.apiChatRead( + chatRh, chat.chatInfo.chatType, chat.chatInfo.apiId, range @@ -438,7 +442,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (searchText.value == value) return@ChatLayout val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout withApi { - apiFindMessages(c.chatInfo, chatModel, value) + apiFindMessages(c, chatModel, value) searchText.value = value } }, @@ -1254,14 +1258,16 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha val chat = activeChat.value if (chat?.chatStats?.unreadChat != true) return withApi { + val chatRh = chat.remoteHostId val success = chatModel.controller.apiChatUnread( + chatRh, chat.chatInfo.chatType, chat.chatInfo.apiId, false ) if (success && chat.id == activeChat.value?.id) { activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)) - chatModel.replaceChat(chat.id, activeChat.value!!) + chatModel.replaceChat(chatRh, chat.id, activeChat.value!!) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index a9b7014d51..b8076b1477 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -351,9 +351,10 @@ fun ComposeView( } } - suspend fun send(rhId: Long?, cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + val cInfo = chat.chatInfo val aChatItem = chatModel.controller.apiSendMessage( - rhId = rhId, + rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, file = file, @@ -363,7 +364,7 @@ fun ComposeView( ttl = ttl ) if (aChatItem != null) { - chatModel.addChatItem(cInfo, aChatItem.chatItem) + chatModel.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) return aChatItem.chatItem } if (file != null) removeFile(file.filePath) @@ -410,23 +411,25 @@ fun ComposeView( suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() - val contact = chatModel.controller.apiSendMemberContactInvitation(chat.chatInfo.apiId, mc) + val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { - chatModel.updateContact(contact) + chatModel.updateContact(chat.remoteHostId, contact) } } - suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? { + suspend fun updateMessage(ei: ChatItem, chat: Chat, live: Boolean): ChatItem? { + val cInfo = chat.chatInfo val oldMsgContent = ei.content.msgContent if (oldMsgContent != null) { val updatedItem = chatModel.controller.apiUpdateChatItem( + rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, itemId = ei.meta.itemId, mc = updateMsgContent(oldMsgContent), live = live ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + if (updatedItem != null) chatModel.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) return updatedItem?.chatItem } return null @@ -444,9 +447,9 @@ fun ComposeView( sent = null } else if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem - sent = updateMessage(ei, cInfo, live) + sent = updateMessage(ei, chat, live) } else if (liveMessage != null && liveMessage.sent) { - sent = updateMessage(liveMessage.chatItem, cInfo, live) + sent = updateMessage(liveMessage.chatItem, chat, live) } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() @@ -528,7 +531,7 @@ fun ComposeView( localPath = file.filePath ) } - sent = send(remoteHost?.remoteHostId, cInfo, content, if (index == 0) quotedItemId else null, file, + sent = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl ) @@ -538,7 +541,7 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(remoteHost?.remoteHostId, cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) @@ -573,7 +576,7 @@ fun ComposeView( fun allowVoiceToContact() { val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return withApi { - chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice) + chatModel.controller.allowFeatureToContact(chat.remoteHostId, contact, ChatFeature.Voice) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 465603d409..c12982ada5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -25,6 +25,7 @@ import chat.simplex.res.MR fun ContactPreferencesView( m: ChatModel, user: User, + rhId: Long?, contactId: Long, close: () -> Unit, ) { @@ -36,9 +37,9 @@ fun ContactPreferencesView( fun savePrefs(afterSave: () -> Unit = {}) { withApi { val prefs = contactFeaturesAllowedToPrefs(featuresAllowed) - val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs) + val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs) if (toContact != null) { - m.updateContact(toContact) + m.updateContact(rhId, toContact) currentFeaturesAllowed = featuresAllowed } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 90ab1b45ff..ff23d40b82 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.res.MR @Composable -fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { +fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { val selectedContacts = remember { mutableStateListOf() } val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) } var allowModifyMembers by remember { mutableStateOf(true) } @@ -49,16 +49,16 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch searchText, openPreferences = { ModalManager.end.showCustomModal { close -> - GroupPreferencesView(chatModel, groupInfo.id, close) + GroupPreferencesView(chatModel, rhId, groupInfo.id, close) } }, inviteMembers = { allowModifyMembers = false withApi { for (contactId in selectedContacts) { - val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value) + val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { - chatModel.upsertGroupMember(groupInfo, member) + chatModel.upsertGroupMember(rhId, groupInfo, member) } else { break } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 8c9619703b..49d76d8ecd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -41,9 +41,10 @@ import kotlinx.coroutines.launch const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @Composable -fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit) { +fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit) { BackHandler(onBack = close) - val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + // TODO derivedStateOf? + val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } val currentUser = chatModel.currentUser.value val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { @@ -68,25 +69,25 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR groupLink, addMembers = { withApi { - setGroupMembers(groupInfo, chatModel) + setGroupMembers(rhId, groupInfo, chatModel) ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, false, chatModel, close) + AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } } }, showMemberInfo = { member -> withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { - val memCode = chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + val memCode = chatModel.controller.apiGetGroupMemberCode(rhId, groupInfo.apiId, member.groupMemberId) member to memCode?.second } else { member to null } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, stats, code, chatModel, closeCurrent) { closeCurrent() close() } @@ -95,31 +96,33 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR } }, editGroupProfile = { - ModalManager.end.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) } + ModalManager.end.showCustomModal { close -> GroupProfileView(rhId, groupInfo, chatModel, close) } }, addOrEditWelcomeMessage = { - ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) } + ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, rhId, groupInfo, close) } }, openPreferences = { ModalManager.end.showCustomModal { close -> GroupPreferencesView( chatModel, + rhId, chat.id, close ) } }, - deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) }, - clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, - leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, + deleteGroup = { deleteGroupDialog(chat, groupInfo, chatModel, close) }, + clearChat = { clearChatDialog(chat, chatModel, close) }, + leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } } ) } } -fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo val alertTextKey = if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning else MR.strings.delete_group_for_self_cannot_undo_warning @@ -129,9 +132,9 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(chat.remoteHostId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -145,14 +148,14 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM ) } -fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.leave_group_question), text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), confirmText = generalGetString(MR.strings.leave_group_button), onConfirm = { withApi { - chatModel.controller.leaveGroup(groupInfo.groupId) + chatModel.controller.leaveGroup(rhId, groupInfo.groupId) close?.invoke() } }, @@ -160,16 +163,16 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U ) } -private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) { +private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.button_remove_member_question), text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withApi { - val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId) if (updatedMember != null) { - chatModel.upsertGroupMember(groupInfo, updatedMember) + chatModel.upsertGroupMember(rhId, groupInfo, updatedMember) } } }, @@ -260,7 +263,7 @@ fun GroupChatInfoLayout( Divider() val showMenu = remember { mutableStateOf(false) } SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) { - DropDownMenuForMember(member, groupInfo, showMenu) + DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) MemberRow(member, onClick = { showMemberInfo(member) }) } } @@ -413,22 +416,22 @@ private fun MemberVerifiedShield() { } @Composable -private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { +private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { DefaultDropdownMenu(showMenu) { if (member.canBeRemoved(groupInfo)) { ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { - removeMemberAlert(groupInfo, member) + removeMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } if (member.memberSettings.showMessages) { ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { - blockMemberAlert(groupInfo, member) + blockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } else { ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { - unblockMemberAlert(groupInfo, member) + unblockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 809c7c2fd6..02ce90243c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -25,6 +25,7 @@ import chat.simplex.res.MR @Composable fun GroupLinkView( chatModel: ChatModel, + rhId: Long?, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, @@ -38,7 +39,7 @@ fun GroupLinkView( fun createLink() { creatingLink = true withApi { - val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId) + val link = chatModel.controller.apiCreateGroupLink(rhId, groupInfo.groupId) if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second @@ -62,7 +63,7 @@ fun GroupLinkView( val role = groupLinkMemberRole.value if (role != null) { withBGApi { - val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role) + val link = chatModel.controller.apiGroupLinkMemberRole(rhId, groupInfo.groupId, role) if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second @@ -78,7 +79,7 @@ fun GroupLinkView( confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) + val r = chatModel.controller.apiDeleteGroupLink(rhId, groupInfo.groupId) if (r) { groupLink = null onGroupLinkUpdated?.invoke(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 9f52f61deb..00b236c7dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -40,6 +40,7 @@ import kotlinx.datetime.Clock @Composable fun GroupMemberInfoView( + rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats?, @@ -49,7 +50,7 @@ fun GroupMemberInfoView( closeAll: () -> Unit, // Close all open windows up to ChatView ) { BackHandler(onBack = close) - val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } val connStats = remember { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() var progressIndicator by remember { mutableStateOf(false) } @@ -66,7 +67,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withApi { - val c = chatModel.controller.apiGetChat(ChatType.Direct, it) + val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it) if (c != null) { if (chatModel.getContactChat(it) == null) { chatModel.addChat(c) @@ -82,9 +83,9 @@ fun GroupMemberInfoView( createMemberContact = { withApi { progressIndicator = true - val memberContact = chatModel.controller.apiCreateMemberContact(groupInfo.apiId, member.groupMemberId) + val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId) if (memberContact != null) { - val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf()) + val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) chatModel.addChat(memberChat) openLoadedChat(memberChat, chatModel) closeAll() @@ -94,11 +95,11 @@ fun GroupMemberInfoView( } }, connectViaAddress = { connReqUri -> - connectViaMemberAddressAlert(connReqUri) + connectViaMemberAddressAlert(rhId, connReqUri) }, - blockMember = { blockMemberAlert(groupInfo, member) }, - unblockMember = { unblockMemberAlert(groupInfo, member) }, - removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }, + blockMember = { blockMemberAlert(rhId, groupInfo, member) }, + unblockMember = { unblockMemberAlert(rhId, groupInfo, member) }, + removeMember = { removeMemberDialog(rhId, groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout val prevValue = newRole.value @@ -108,8 +109,8 @@ fun GroupMemberInfoView( }) { withApi { kotlin.runCatching { - val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it) - chatModel.upsertGroupMember(groupInfo, mem) + val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it) + chatModel.upsertGroupMember(rhId, groupInfo, mem) }.onFailure { newRole.value = prevValue } @@ -119,10 +120,10 @@ fun GroupMemberInfoView( switchMemberAddress = { showSwitchAddressAlert(switchAddress = { withApi { - val r = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -131,10 +132,10 @@ fun GroupMemberInfoView( abortSwitchMemberAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - val r = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiAbortSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -142,10 +143,10 @@ fun GroupMemberInfoView( }, syncMemberConnection = { withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -153,10 +154,10 @@ fun GroupMemberInfoView( syncMemberConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = { withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = true) + val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = true) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -170,9 +171,10 @@ fun GroupMemberInfoView( connectionCode, mem.verified, verify = { code -> - chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r -> + chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> val (verified, existingCode) = r chatModel.upsertGroupMember( + rhId, groupInfo, mem.copy( activeConn = mem.activeConn?.copy( @@ -196,16 +198,16 @@ fun GroupMemberInfoView( } } -fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.button_remove_member), text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withApi { - val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId) + val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId) if (removedMember != null) { - chatModel.upsertGroupMember(groupInfo, removedMember) + chatModel.upsertGroupMember(rhId, groupInfo, removedMember) } close?.invoke() } @@ -500,11 +502,11 @@ private fun updateMemberRoleDialog( ) } -fun connectViaMemberAddressAlert(connReqUri: String) { +fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) + planAndConnect(chatModel, rhId, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -514,39 +516,39 @@ fun connectViaMemberAddressAlert(connReqUri: String) { } } -fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { +fun blockMemberAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_member_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_member_confirmation), onConfirm = { - toggleShowMemberMessages(gInfo, mem, false) + toggleShowMemberMessages(rhId, gInfo, mem, false) }, destructive = true, ) } -fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { +fun unblockMemberAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.unblock_member_question), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_member_confirmation), onConfirm = { - toggleShowMemberMessages(gInfo, mem, true) + toggleShowMemberMessages(rhId, gInfo, mem, true) }, ) } -fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { +fun toggleShowMemberMessages(rhId: Long?, gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages) - updateMemberSettings(gInfo, member, updatedMemberSettings) + updateMemberSettings(rhId, gInfo, member, updatedMemberSettings) } -fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { +fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { withBGApi { - val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + val success = ChatController.apiSetMemberSettings(rhId, gInfo.groupId, member.groupMemberId, memberSettings) if (success) { - ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings)) + ChatModel.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 4571a38c13..3cdfaad2d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -21,8 +21,12 @@ import chat.simplex.common.model.* import chat.simplex.res.MR @Composable -fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) { - val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } } +fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit,) { + val groupInfo = remember { derivedStateOf { + val ch = m.getChat(chatId) + val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo + if (g == null || ch?.remoteHostId != rhId) null else g + }} val gInfo = groupInfo.value ?: return var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) } var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) } @@ -30,9 +34,9 @@ fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) { fun savePrefs(afterSave: () -> Unit = {}) { withApi { val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) - val g = m.controller.apiUpdateGroup(gInfo.groupId, gp) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) if (g != null) { - m.updateGroup(g) + m.updateGroup(rhId, g) currentPreferences = preferences } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 5376cb092b..f92fd88dc0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -30,15 +30,15 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { +fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { GroupProfileLayout( close = close, groupProfile = groupInfo.groupProfile, saveProfile = { p -> withApi { - val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) if (gInfo != null) { - chatModel.updateGroup(gInfo) + chatModel.updateGroup(rhId, gInfo) close.invoke() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 3be54376d5..577c19648d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -30,7 +30,7 @@ import chat.simplex.res.MR import kotlinx.coroutines.delay @Composable -fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) { +fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () -> Unit) { var gInfo by remember { mutableStateOf(groupInfo) } val welcomeText = remember { mutableStateOf(gInfo.groupProfile.description ?: "") } @@ -41,10 +41,10 @@ fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) { welcome = null } val groupProfileUpdated = gInfo.groupProfile.copy(description = welcome) - val res = m.controller.apiUpdateGroup(gInfo.groupId, groupProfileUpdated) + val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) if (res != null) { gInfo = res - m.updateGroup(res) + m.updateGroup(rhId, res) welcomeText.value = welcome ?: "" } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 13380a664b..7e81faf3d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -62,7 +62,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, - click = { directChatAction(chat.chatInfo.contact, chatModel) }, + click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) }, showMenu, stopped, @@ -72,7 +72,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Group -> ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, - click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) }, + click = { if (!inProgress.value) groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, showMenu, stopped, @@ -81,8 +81,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.ContactRequest -> ChatListNavLinkLayout( chatLinkPreview = { ContactRequestView(chat.chatInfo) }, - click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, - dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, + click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) }, + dropdownMenuItems = { ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, showMenu, stopped, selectedChat @@ -94,10 +94,10 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) + ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) } }, - dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, + dropdownMenuItems = { ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, showMenu, stopped, selectedChat @@ -119,38 +119,38 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } -fun directChatAction(contact: Contact, chatModel: ChatModel) { +fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { - contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close = null, openChat = true) - else -> withBGApi { openChat(ChatInfo.Direct(contact), chatModel) } + contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) + else -> withBGApi { openChat(rhId, ChatInfo.Direct(contact), chatModel) } } } -fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() - else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) } + else -> withBGApi { openChat(rhId, ChatInfo.Group(groupInfo), chatModel) } } } -suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { - val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId) +suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId) if (chat != null) { openLoadedChat(chat, chatModel) } } -suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { - val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) +suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId) if (chat != null) { openLoadedChat(chat, chatModel) } } -suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { +suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) + val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId) if (chat != null) { openLoadedChat(chat, chatModel) Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") @@ -164,22 +164,24 @@ fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatId.value = chat.chatInfo.id } -suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) { +suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) { + val chatInfo = ch.chatInfo val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return + val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return if (chatModel.chatId.value != chat.id) return chatModel.chatItems.addAll(0, chat.chatItems) } -suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) { - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return +suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) { + val chatInfo = ch.chatInfo + val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return if (chatModel.chatId.value != chat.id) return chatModel.chatItems.clear() chatModel.chatItems.addAll(0, chat.chatItems) } -suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { - val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId) +suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { + val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) val currentMembers = chatModel.groupMembers val newMembers = groupMembers.map { newMember -> val currentMember = currentMembers.find { it.id == newMember.id } @@ -230,7 +232,7 @@ fun GroupMenuItems( } GroupMemberStatus.MemAccepted -> { if (groupInfo.membership.memberCurrent) { - LeaveGroupAction(groupInfo, chatModel, showMenu) + LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) @@ -246,7 +248,7 @@ fun GroupMenuItems( ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) ClearChatAction(chat, chatModel, showMenu) if (groupInfo.membership.memberCurrent) { - LeaveGroupAction(groupInfo, chatModel, showMenu) + LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) @@ -310,7 +312,7 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState Unit = { withApi { inProgress.value = true - chatModel.controller.apiJoinGroup(groupInfo.groupId) + chatModel.controller.apiJoinGroup(chat.remoteHostId, groupInfo.groupId) inProgress.value = false } } @@ -370,12 +372,12 @@ fun JoinGroupAction( } @Composable -fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { +fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.leave_group_button), painterResource(MR.images.ic_logout), onClick = { - leaveGroupDialog(groupInfo, chatModel) + leaveGroupDialog(rhId, groupInfo, chatModel) showMenu.value = false }, color = Color.Red @@ -383,13 +385,13 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab } @Composable -fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { +fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.accept_contact_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(incognito = false, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel) showMenu.value = false } ) @@ -398,7 +400,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo painterResource(MR.images.ic_theater_comedy), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(incognito = true, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel) showMenu.value = false } ) @@ -406,7 +408,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo stringResource(MR.strings.reject_contact_button), painterResource(MR.images.ic_close), onClick = { - rejectContactRequest(chatInfo, chatModel) + rejectContactRequest(rhId, chatInfo, chatModel) showMenu.value = false }, color = Color.Red @@ -414,7 +416,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo } @Composable -fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState) { +fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.set_contact_name), painterResource(MR.images.ic_edit), @@ -422,7 +424,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) + ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) } showMenu.value = false }, @@ -431,7 +433,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), onClick = { - deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) { + deleteContactConnectionAlert(rhId, chatInfo.contactConnection, chatModel) { if (chatModel.chatId.value == null) { ModalManager.center.closeModals() ModalManager.end.closeModals() @@ -471,8 +473,9 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { withApi { if (chat.chatStats.unreadCount > 0) { val minUnreadItemId = chat.chatStats.minUnreadItemId - chatModel.markChatItemsRead(chat.chatInfo) + chatModel.markChatItemsRead(chat) chatModel.controller.apiChatRead( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, CC.ItemRange(minUnreadItemId, chat.chatItems.last().id) @@ -481,12 +484,13 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { } if (chat.chatStats.unreadChat) { val success = chatModel.controller.apiChatUnread( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, false ) if (success) { - chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) } } } @@ -498,17 +502,18 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { withApi { val success = chatModel.controller.apiChatUnread( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, true ) if (success) { - chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) } } } -fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_connection_request__question), text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)), @@ -516,19 +521,19 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel Column { SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(incognito = false, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel) }) { Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(incognito = true, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel) }) { Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() - rejectContactRequest(contactRequest, chatModel) + rejectContactRequest(rhId, contactRequest, chatModel) }) { Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) } @@ -537,24 +542,24 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel ) } -fun acceptContactRequest(incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { +fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { withApi { - val contact = chatModel.controller.apiAcceptContactRequest(incognito, apiId) + val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId) if (contact != null && isCurrentUser && contactRequest != null) { - val chat = Chat(ChatInfo.Direct(contact), listOf()) - chatModel.replaceChat(contactRequest.id, chat) + val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) + chatModel.replaceChat(rhId, contactRequest.id, chat) } } } -fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun rejectContactRequest(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { withApi { - chatModel.controller.apiRejectContactRequest(contactRequest.apiId) - chatModel.removeChat(contactRequest.id) + chatModel.controller.apiRejectContactRequest(rhId, contactRequest.apiId) + chatModel.removeChat(rhId, contactRequest.id) } } -fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { +fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_pending_connection__question), text = generalGetString( @@ -565,8 +570,8 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel onConfirm = { withApi { AlertManager.shared.hideAlert() - if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) { - chatModel.removeChat(connection.id) + if (chatModel.controller.apiDeleteChat(rhId, ChatType.ContactConnection, connection.apiId)) { + chatModel.removeChat(rhId, connection.id) onSuccess() } } @@ -575,16 +580,17 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel ) } -fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { +// TODO why is it not used +fun pendingContactAlertDialog(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.alert_title_contact_connection_pending), text = generalGetString(MR.strings.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry), confirmText = generalGetString(MR.strings.button_delete_contact), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + val r = chatModel.controller.apiDeleteChat(rhId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(rhId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -599,6 +605,7 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { fun askCurrentOrIncognitoProfileConnectContactViaAddress( chatModel: ChatModel, + rhId: Long?, contact: Contact, close: (() -> Unit)?, openChat: Boolean @@ -611,9 +618,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( AlertManager.shared.hideAlert() withApi { close?.invoke() - val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = false) + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) if (ok && openChat) { - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } }) { @@ -623,9 +630,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( AlertManager.shared.hideAlert() withApi { close?.invoke() - val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = true) + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) if (ok && openChat) { - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } }) { @@ -641,10 +648,10 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( ) } -suspend fun connectContactViaAddress(chatModel: ChatModel, contactId: Long, incognito: Boolean): Boolean { - val contact = chatModel.controller.apiConnectContactViaAddress(incognito, contactId) +suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactId: Long, incognito: Boolean): Boolean { + val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { - chatModel.updateContact(contact) + chatModel.updateContact(rhId, contact) AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) @@ -654,7 +661,7 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, contactId: Long, inco return false } -fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +fun acceptGroupInvitationAlertDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), @@ -662,12 +669,12 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, onConfirm = { withApi { inProgress?.value = true - chatModel.controller.apiJoinGroup(groupInfo.groupId) + chatModel.controller.apiJoinGroup(rhId, groupInfo.groupId) inProgress?.value = false } }, dismissText = generalGetString(MR.strings.delete_verb), - onDismiss = { deleteGroup(groupInfo, chatModel) } + onDismiss = { deleteGroup(rhId, groupInfo, chatModel) } ) } @@ -679,11 +686,11 @@ fun cantInviteIncognitoAlert() { ) } -fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) { +fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { withApi { - val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId) + val r = chatModel.controller.apiDeleteChat(rhId, ChatType.Group, groupInfo.apiId) if (r) { - chatModel.removeChat(groupInfo.id) + chatModel.removeChat(rhId, groupInfo.id) if (chatModel.chatId.value == groupInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -723,15 +730,15 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo withApi { val res = when (newChatInfo) { is ChatInfo.Direct -> with(newChatInfo) { - chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings) + chatModel.controller.apiSetSettings(chat.remoteHostId, chatType, apiId, contact.chatSettings) } is ChatInfo.Group -> with(newChatInfo) { - chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings) + chatModel.controller.apiSetSettings(chat.remoteHostId, chatType, apiId, groupInfo.chatSettings) } else -> false } if (res && newChatInfo != null) { - chatModel.updateChatInfo(newChatInfo) + chatModel.updateChatInfo(chat.remoteHostId, newChatInfo) if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chat.id) } 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 7883a7a738..1644d286f5 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 @@ -53,7 +53,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val url = chatModel.appOpenUrl.value if (url != null) { chatModel.appOpenUrl.value = null - connectIfOpenedViaUri(url, chatModel) + connectIfOpenedViaUri(chatModel.remoteHostId, url, chatModel) } } if (appPlatform.isDesktop) { @@ -117,7 +117,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } if (searchInList.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) - NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + // TODO disable this button and sheet for the duration of the switch + NewChatSheet(chatModel, chatModel.remoteHostId, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { @@ -317,13 +318,13 @@ private fun ProgressIndicator() { @Composable expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) -fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { +fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = uri } else { withApi { - planAndConnect(chatModel, uri, incognito = null, close = null) + planAndConnect(chatModel, rhId, uri, incognito = null, close = null) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index e423a591da..ad8f93990f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -20,13 +20,13 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat) }, - click = { directChatAction(chat.chatInfo.contact, chatModel) }, + click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, stopped ) is ChatInfo.Group -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat) }, - click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, + click = { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel) }, stopped ) is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index caf8ec5cb0..20e856df6e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -84,7 +84,7 @@ fun UserPicker( .filter { it } .collect { try { - val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser } + val updatedUsers = chatModel.controller.listUsers(chatModel.remoteHostId).sortedByDescending { it.user.activeUser } var same = users.size == updatedUsers.size if (same) { for (i in 0 until minOf(users.size, updatedUsers.size)) { @@ -129,7 +129,7 @@ fun UserPicker( switchingUsersAndHosts.value = true } ModalManager.closeAllModalsEverywhere() - chatModel.controller.changeActiveUser(u.user.userId, null) + chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) job.cancel() switchingUsersAndHosts.value = false } 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 0cca354746..1f4be29664 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 @@ -65,6 +65,8 @@ fun DatabaseView( Box( Modifier.fillMaxSize(), ) { + val user = m.currentUser.value + val rhId = user?.remoteHostId DatabaseLayout( progressIndicator.value, remember { m.chatRunning }.value != false, @@ -80,7 +82,7 @@ fun DatabaseView( chatLastStart, appFilesCountAndSize, chatItemTTL, - m.currentUser.value, + user, m.users, startChat = { startChat(m, chatLastStart, m.chatDbChanged) }, stopChatAlert = { stopChatAlert(m) }, @@ -91,9 +93,9 @@ fun DatabaseView( val oldValue = chatItemTTL.value chatItemTTL.value = it if (it < oldValue) { - setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize) + setChatItemTTLAlert(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } else if (it != oldValue) { - setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize) + setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } }, showSettingsModal @@ -265,7 +267,7 @@ fun DatabaseLayout( } private fun setChatItemTTLAlert( - m: ChatModel, selectedChatItemTTL: MutableState, + m: ChatModel, rhId: Long?, selectedChatItemTTL: MutableState, progressIndicator: MutableState, appFilesCountAndSize: MutableState>, ) { @@ -273,7 +275,7 @@ private fun setChatItemTTLAlert( title = generalGetString(MR.strings.enable_automatic_deletion_question), text = generalGetString(MR.strings.enable_automatic_deletion_message), confirmText = generalGetString(MR.strings.delete_messages), - onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize) }, + onConfirm = { setCiTTL(m, rhId, selectedChatItemTTL, progressIndicator, appFilesCountAndSize) }, onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value }, destructive = true, ) @@ -592,6 +594,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { private fun setCiTTL( m: ChatModel, + rhId: Long?, chatItemTTL: MutableState, progressIndicator: MutableState, appFilesCountAndSize: MutableState>, @@ -600,7 +603,7 @@ private fun setCiTTL( progressIndicator.value = true withApi { try { - m.controller.setChatItemTTL(chatItemTTL.value) + m.controller.setChatItemTTL(rhId, chatItemTTL.value) // Update model on success m.chatItemTTL.value = chatItemTTL.value afterSetCiTTL(m, progressIndicator, appFilesCountAndSize) @@ -623,7 +626,8 @@ private fun afterSetCiTTL( withApi { try { updatingChatsMutex.withLock { - val chats = m.controller.apiGetChats() + // this is using current remote host on purpose - if it changes during update, it will load correct chats + val chats = m.controller.apiGetChats(m.remoteHostId) m.updateChats(chats) } } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 4ed5ea56b4..dbadec32f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -373,7 +373,7 @@ inline fun serializableSaver(): Saver = Saver( fun UriHandler.openVerifiedSimplexUri(uri: String) { val URI = try { URI.create(uri) } catch (e: Exception) { null } if (URI != null) { - connectIfOpenedViaUri(URI, ChatModel) + connectIfOpenedViaUri(chatModel.remoteHostId, URI, ChatModel) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 8b5c2a8336..c64c3dd29a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -63,7 +63,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( if (!displayName.isNullOrEmpty()) { profile = Profile(displayName = displayName, fullName = "") } - val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true) + val createdUser = m.controller.apiCreateActiveUser(null, profile, pastTimestamp = true) m.currentUser.value = createdUser m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) if (createdUser != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index ef3633d1fa..360667fcfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -25,11 +25,13 @@ import chat.simplex.res.MR @Composable fun AddContactView( chatModel: ChatModel, + rhId: Long?, connReqInvitation: String, contactConnection: MutableState ) { val clipboard = LocalClipboardManager.current AddContactLayout( + rhId = rhId, chatModel = chatModel, incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, @@ -52,6 +54,7 @@ fun AddContactView( @Composable fun AddContactLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, @@ -63,9 +66,9 @@ fun AddContactLayout( withApi { val contactConnVal = contactConnection.value if (contactConnVal != null) { - chatModel.controller.apiSetConnectionIncognito(contactConnVal.pccConnId, incognito.value)?.let { + chatModel.controller.apiSetConnectionIncognito(rhId, contactConnVal.pccConnId, incognito.value)?.let { contactConnection.value = it - chatModel.updateContactConnection(it) + chatModel.updateContactConnection(rhId, it) } } } @@ -172,6 +175,7 @@ fun sharedProfileInfo( fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( + rhId = null, chatModel = ChatModel, incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 9b2cedefaa..d60ee75311 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -32,25 +32,25 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { +fun AddGroupView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { AddGroupLayout( createGroup = { incognito, groupProfile -> withApi { - val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile) + val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) if (groupInfo != null) { - chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) + chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id - setGroupMembers(groupInfo, chatModel) + setGroupMembers(rhId, groupInfo, chatModel) close.invoke() if (!groupInfo.incognito) { ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close) + AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close) } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt index a2bc2c4df4..e41c8701e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt @@ -8,4 +8,4 @@ enum class ConnectViaLinkTab { } @Composable -expect fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) +expect fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index d04a85d905..50419cdaff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -30,6 +30,7 @@ import chat.simplex.res.MR @Composable fun ContactConnectionInfoView( chatModel: ChatModel, + rhId: Long?, connReqInvitation: String?, contactConnection: PendingContactConnection, focusAlias: Boolean, @@ -55,8 +56,8 @@ fun ContactConnectionInfoView( connReq = connReqInvitation, contactConnection = contactConnection, focusAlias = focusAlias, - deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) }, - onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) }, + deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, + onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.center.showModal { @@ -165,9 +166,9 @@ fun DeleteButton(onClick: () -> Unit) { ) } -private fun setContactAlias(contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi { - chatModel.controller.apiSetConnectionAlias(contactConnection.pccConnId, localAlias)?.let { - chatModel.updateContactConnection(it) +private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi { + chatModel.controller.apiSetConnectionAlias(rhId, contactConnection.pccConnId, localAlias)?.let { + chatModel.updateContactConnection(rhId, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index 94538655b9..f4252f53b0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -20,7 +20,7 @@ enum class CreateLinkTab { } @Composable -fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { +fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { val selection = remember { mutableStateOf(initialSelection) } val connReqInvitation = rememberSaveable { m.connReqInv } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } @@ -32,7 +32,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { && contactConnection.value == null && !creatingConnReq.value ) { - createInvitation(m, creatingConnReq, connReqInvitation, contactConnection) + createInvitation(m, rhId, creatingConnReq, connReqInvitation, contactConnection) } } /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. @@ -65,10 +65,10 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { Column(Modifier.weight(1f)) { when (selection.value) { CreateLinkTab.ONE_TIME -> { - AddContactView(m, connReqInvitation.value ?: "", contactConnection) + AddContactView(m, rhId,connReqInvitation.value ?: "", contactConnection) } CreateLinkTab.LONG_TERM -> { - UserAddressView(m, viaCreateLinkView = true, close = {}) + UserAddressView(m, rhId, viaCreateLinkView = true, close = {}) } } } @@ -100,13 +100,14 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { private fun createInvitation( m: ChatModel, + rhId: Long?, creatingConnReq: MutableState, connReqInvitation: MutableState, contactConnection: MutableState ) { creatingConnReq.value = true withApi { - val r = m.controller.apiAddContact(incognito = m.controller.appPrefs.incognito.get()) + val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get()) if (r != null) { connReqInvitation.value = r.first contactConnection.value = r.second diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 8ec54e344f..382bc72e4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, rhId: Long?, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { + // TODO close new chat if remote host changes in model if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( newChatSheetState, @@ -41,17 +42,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow ConnectViaLinkView(chatModel, close) } + ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, rhId, close) } }, createGroup = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, close) } + ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, rhId, close) } }, closeNewChatSheet, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index b142b8e16a..d40fa97624 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -24,11 +24,12 @@ import chat.simplex.res.MR import java.net.URI @Composable -fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { +fun PasteToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { val connectionLink = remember { mutableStateOf("") } val clipboard = LocalClipboardManager.current PasteToConnectLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, connectionLink = connectionLink, pasteFromClipboard = { @@ -41,6 +42,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @Composable fun PasteToConnectLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, connectionLink: MutableState, pasteFromClipboard: () -> Unit, @@ -52,7 +54,7 @@ fun PasteToConnectLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -124,6 +126,7 @@ fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( chatModel = ChatModel, + rhId = null, incognitoPref = SharedPreference({ false }, {}), connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 7629256fc3..523b7e5327 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -26,7 +26,7 @@ import chat.simplex.res.MR import java.net.URI @Composable -expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) +expect fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -34,21 +34,22 @@ enum class ConnectionLinkType { suspend fun planAndConnect( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean?, close: (() -> Unit)? ) { - val connectionPlan = chatModel.controller.apiConnectPlan(uri.toString()) + val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri.toString()) if (connectionPlan != null) { when (connectionPlan) { is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { InvitationLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_invitation_link), text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), connectDestructive = false @@ -62,12 +63,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link)), connectDestructive = true @@ -78,7 +79,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact_ if (contact != null) { - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) @@ -93,7 +94,7 @@ suspend fun planAndConnect( is InvitationLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) @@ -104,10 +105,10 @@ suspend fun planAndConnect( ContactAddressPlan.Ok -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_contact_link), text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), connectDestructive = false @@ -121,12 +122,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address)), connectDestructive = true @@ -140,12 +141,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address)), connectDestructive = true @@ -155,7 +156,7 @@ suspend fun planAndConnect( is ContactAddressPlan.ConnectingProhibit -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) @@ -164,7 +165,7 @@ suspend fun planAndConnect( is ContactAddressPlan.Known -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) @@ -175,9 +176,9 @@ suspend fun planAndConnect( val contact = connectionPlan.contactAddressPlan.contact if (incognito != null) { close?.invoke() - connectContactViaAddress(chatModel, contact.contactId, incognito) + connectContactViaAddress(chatModel, rhId, contact.contactId, incognito) } else { - askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close, openChat = false) + askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false) } } } @@ -189,11 +190,11 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } } + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } } ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_group_link), text = AnnotatedString(generalGetString(MR.strings.you_will_join_group)), connectDestructive = false @@ -203,7 +204,7 @@ suspend fun planAndConnect( is GroupLinkPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - ownGroupLinkConfirmConnect(chatModel, uri, incognito, connectionPlan, groupInfo, close) + ownGroupLinkConfirmConnect(chatModel, rhId, uri, incognito, connectionPlan, groupInfo, close) } GroupLinkPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") @@ -212,12 +213,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link)), connectDestructive = true @@ -242,7 +243,7 @@ suspend fun planAndConnect( is GroupLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - openKnownGroup(chatModel, close, groupInfo) + openKnownGroup(chatModel, rhId, close, groupInfo) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) @@ -253,10 +254,10 @@ suspend fun planAndConnect( } else { Log.d(TAG, "planAndConnect, plan error") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan = null, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan = null, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan = null, close, + chatModel, rhId, uri, connectionPlan = null, close, title = generalGetString(MR.strings.connect_plan_connect_via_link), connectDestructive = false ) @@ -266,12 +267,13 @@ suspend fun planAndConnect( suspend fun connectViaUri( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean, connectionPlan: ConnectionPlan?, close: (() -> Unit)? ): Boolean { - val r = chatModel.controller.apiConnect(incognito, uri.toString()) + val r = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (r) { close?.invoke() @@ -298,6 +300,7 @@ fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType fun askCurrentOrIncognitoProfileAlert( chatModel: ChatModel, + rhId: Long?, uri: URI, connectionPlan: ConnectionPlan?, close: (() -> Unit)?, @@ -314,7 +317,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -322,7 +325,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -337,18 +340,19 @@ fun askCurrentOrIncognitoProfileAlert( ) } -fun openKnownContact(chatModel: ChatModel, close: (() -> Unit)?, contact: Contact) { +fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact) { withApi { val c = chatModel.getContactChat(contact.contactId) if (c != null) { close?.invoke() - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } } fun ownGroupLinkConfirmConnect( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean?, connectionPlan: ConnectionPlan?, @@ -363,7 +367,7 @@ fun ownGroupLinkConfirmConnect( // Open group SectionItemView({ AlertManager.shared.hideAlert() - openKnownGroup(chatModel, close, groupInfo) + openKnownGroup(chatModel, rhId, close, groupInfo) }) { Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -372,7 +376,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }) { Text( @@ -385,7 +389,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -394,7 +398,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -411,12 +415,12 @@ fun ownGroupLinkConfirmConnect( ) } -fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupInfo) { +fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) { withApi { val g = chatModel.getGroupChat(groupInfo.groupId) if (g != null) { close?.invoke() - openGroupChat(groupInfo.groupId, chatModel) + openGroupChat(rhId, groupInfo.groupId, chatModel) } } } @@ -424,6 +428,7 @@ fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupI @Composable fun ConnectContactLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, close: () -> Unit ) { @@ -435,7 +440,7 @@ fun ConnectContactLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -487,6 +492,7 @@ fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( chatModel = ChatModel, + rhId = null, incognitoPref = SharedPreference({ false }, {}), close = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 0132223383..49e62fb067 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -23,14 +23,14 @@ import chat.simplex.common.views.newchat.simplexChatLink import chat.simplex.res.MR @Composable -fun CreateSimpleXAddress(m: ChatModel) { +fun CreateSimpleXAddress(m: ChatModel, rhId: Long?) { var progressIndicator by remember { mutableStateOf(false) } val userAddress = remember { m.userAddress } val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { - prepareChatBeforeAddressCreation() + prepareChatBeforeAddressCreation(rhId) } CreateSimpleXAddressLayout( @@ -45,11 +45,11 @@ fun CreateSimpleXAddress(m: ChatModel) { createAddress = { withApi { progressIndicator = true - val connReqContact = m.controller.apiCreateUserAddress() + val connReqContact = m.controller.apiCreateUserAddress(rhId) if (connReqContact != null) { m.userAddress.value = UserContactLinkRec(connReqContact) try { - val u = m.controller.apiSetProfileAddress(true) + val u = m.controller.apiSetProfileAddress(rhId, true) if (u != null) { m.updateUser(u) } @@ -176,18 +176,18 @@ private fun ProgressIndicator() { } } -private fun prepareChatBeforeAddressCreation() { +private fun prepareChatBeforeAddressCreation(rhId: Long?) { if (chatModel.users.isNotEmpty()) return withApi { - val user = chatModel.controller.apiGetActiveUser() ?: return@withApi + val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) } else { - val users = chatModel.controller.listUsers() + val users = chatModel.controller.listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - chatModel.controller.getUserChatData() + chatModel.controller.getUserChatData(rhId) } } } 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 9bc5ae846e..a4ebd23de4 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 @@ -80,7 +80,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { onDispose { if (m.chatRunning.value != true) { withBGApi { - val user = chatController.apiGetActiveUser() + val user = chatController.apiGetActiveUser(null) if (user != null) { m.controller.startChat(user) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 215899d0ff..f3496c850f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -34,7 +34,7 @@ fun HiddenProfileView( saveProfilePassword = { hidePassword -> withBGApi { try { - val u = m.controller.apiHideUser(user.userId, hidePassword) + val u = m.controller.apiHideUser(user, hidePassword) m.updateUser(u) close() } catch (e: Exception) { 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 f2fee926a7..9ee1b39384 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 @@ -168,9 +168,9 @@ fun NetworkAndServersView( ) { AppBarTitle(stringResource(MR.strings.network_and_servers)) SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.SMP, close) }) + 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.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.XFTP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 202602b287..e94c53f644 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -25,11 +25,11 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { fun savePrefs(afterSave: () -> Unit = {}) { withApi { val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences()) - val updated = m.controller.apiUpdateProfile(newProfile) + val updated = m.controller.apiUpdateProfile(user.remoteHostId, newProfile) if (updated != null) { val (updatedProfile, updatedContacts) = updated - m.updateCurrentUser(updatedProfile, preferences) - updatedContacts.forEach(m::updateContact) + m.updateCurrentUser(user.remoteHostId, updatedProfile, preferences) + updatedContacts.forEach { m.updateContact(user.remoteHostId, it) } currentPreferences = preferences } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 84ab87c653..1a5aa49eb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -99,7 +99,7 @@ fun PrivacySettingsView( fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { withApi { val mrs = UserMsgReceiptSettings(enable, clearOverrides) - chatModel.controller.apiSetUserContactReceipts(currentUser.userId, mrs) + chatModel.controller.apiSetUserContactReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable) if (clearOverrides) { @@ -111,7 +111,7 @@ fun PrivacySettingsView( val sendRcpts = contact.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) - chatModel.updateContact(contact) + chatModel.updateContact(currentUser.remoteHostId, contact) } } } @@ -122,7 +122,7 @@ fun PrivacySettingsView( fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) { withApi { val mrs = UserMsgReceiptSettings(enable, clearOverrides) - chatModel.controller.apiSetUserGroupReceipts(currentUser.userId, mrs) + chatModel.controller.apiSetUserGroupReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable) if (clearOverrides) { @@ -134,7 +134,7 @@ fun PrivacySettingsView( val sendRcpts = groupInfo.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) - chatModel.updateGroup(groupInfo) + chatModel.updateGroup(currentUser.remoteHostId, groupInfo) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt index f3896a3de9..4e8da36a7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt @@ -197,7 +197,7 @@ fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair = try { - val r = m.controller.testProtoServer(server.server) + val r = m.controller.testProtoServer(server.remoteHostId, server.server) server.copy(tested = r == null) to r } catch (e: Exception) { Log.e(TAG, "testServerConnection ${e.stackTraceToString()}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index 92246b72fb..cbcb7344f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -28,7 +28,8 @@ import chat.simplex.res.MR import kotlinx.coroutines.launch @Composable -fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () -> Unit) { +fun ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { + // TODO close if remote host changes var presetServers by remember { mutableStateOf(emptyList()) } var servers by remember { mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList()) @@ -51,7 +52,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () } LaunchedEffect(Unit) { - val res = m.controller.getUserProtoServers(serverProtocol) + val res = m.controller.getUserProtoServers(rhId, serverProtocol) if (res != null) { currServers.value = res.protoServers presetServers = res.presetServers @@ -90,7 +91,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () ModalView( close = { if (saveDisabled.value) close() - else showUnsavedChangesAlert({ saveServers(serverProtocol, currServers, servers, m, close) }, close) + else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close) }, ) { ProtocolServersLayout( @@ -118,7 +119,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () SectionItemView({ AlertManager.shared.hideAlert() ModalManager.start.showModalCloseable { close -> - ScanProtocolServer { + ScanProtocolServer(rhId) { close() servers = servers + it m.userSMPServersUnsaved.value = servers @@ -133,7 +134,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () if (!hasAllPresets) { SectionItemView({ AlertManager.shared.hideAlert() - servers = (servers + addAllPresets(presetServers, servers, m)).sortedByDescending { it.preset } + servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset } }) { Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -155,7 +156,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () m.userSMPServersUnsaved.value = null }, saveSMPServers = { - saveServers(serverProtocol, currServers, servers, m) + saveServers(rhId, serverProtocol, currServers, servers, m) }, showServer = ::showServer, ) @@ -289,11 +290,11 @@ private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List, servers: List, m: ChatModel): Boolean = presetServers.all { hasPreset(it, servers) } ?: true -private fun addAllPresets(presetServers: List, servers: List, m: ChatModel): List { +private fun addAllPresets(rhId: Long?, presetServers: List, servers: List, m: ChatModel): List { val toAdd = ArrayList() for (srv in presetServers) { if (!hasPreset(srv, servers)) { - toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true)) + toAdd.add(ServerCfg(remoteHostId = rhId, srv, preset = true, tested = null, enabled = true)) } } return toAdd @@ -346,9 +347,9 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUpd return fs } -private fun saveServers(protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { +private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { withApi { - if (m.controller.setUserProtoServers(protocol, servers)) { + if (m.controller.setUserProtoServers(rhId, protocol, servers)) { currServers.value = servers m.userSMPServersUnsaved.value = null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt index 02582ec93c..ac74bd04d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt @@ -13,10 +13,10 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.res.MR @Composable -expect fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) +expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) @Composable -fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { +fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { Column( Modifier .fillMaxSize() @@ -32,7 +32,7 @@ fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { QRCodeScanner { text -> val res = parseServerAddress(text) if (res != null) { - onNext(ServerCfg(text, false, null, true)) + onNext(ServerCfg(remoteHostId = rhId, text, false, null, true)) } else { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_invalid_address), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index 089ec7713f..b75f522686 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -26,12 +26,12 @@ fun SetDeliveryReceiptsView(m: ChatModel) { if (currentUser != null) { withApi { try { - m.controller.apiSetAllContactReceipts(enable = true) + m.controller.apiSetAllContactReceipts(currentUser.remoteHostId, enable = true) m.currentUser.value = currentUser.copy(sendRcptsContacts = true) m.setDeliveryReceipts.value = false m.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) try { - val users = m.controller.listUsers() + val users = m.controller.listUsers(currentUser.remoteHostId) m.users.clear() m.users.addAll(users) } catch (e: Exception) { 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 2d4e5c86b9..7bd060e8d6 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 @@ -155,7 +155,7 @@ fun SettingsLayout( } val profileHidden = rememberSaveable { mutableStateOf(false) } SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, it.currentUser.value?.remoteHostId, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) ChatPreferencesItem(showCustomModal, stopped = stopped) if (appPlatform.isDesktop) { SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index d03b758565..98989a775a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -33,10 +33,12 @@ import chat.simplex.res.MR @Composable fun UserAddressView( chatModel: ChatModel, + rhId: Long?, viaCreateLinkView: Boolean = false, shareViaProfile: Boolean = false, close: () -> Unit ) { + // TODO close when remote host changes val shareViaProfile = remember { mutableStateOf(shareViaProfile) } var progressIndicator by remember { mutableStateOf(false) } val onCloseHandler: MutableState<(close: () -> Unit) -> Unit> = remember { mutableStateOf({ _ -> }) } @@ -45,7 +47,7 @@ fun UserAddressView( progressIndicator = true withBGApi { try { - val u = chatModel.controller.apiSetProfileAddress(on) + val u = chatModel.controller.apiSetProfileAddress(rhId, on) if (u != null) { chatModel.updateUser(u) } @@ -67,7 +69,7 @@ fun UserAddressView( createAddress = { withApi { progressIndicator = true - val connReqContact = chatModel.controller.apiCreateUserAddress() + val connReqContact = chatModel.controller.apiCreateUserAddress(rhId) if (connReqContact != null) { chatModel.userAddress.value = UserContactLinkRec(connReqContact) @@ -112,7 +114,7 @@ fun UserAddressView( onConfirm = { progressIndicator = true withApi { - val u = chatModel.controller.apiDeleteUserAddress() + val u = chatModel.controller.apiDeleteUserAddress(rhId) if (u != null) { chatModel.userAddress.value = null chatModel.updateUser(u) @@ -126,7 +128,7 @@ fun UserAddressView( }, saveAas = { aas: AutoAcceptState, savedAAS: MutableState -> withBGApi { - val address = chatModel.controller.userAddressAutoAccept(aas.autoAccept) + val address = chatModel.controller.userAddressAutoAccept(rhId, aas.autoAccept) if (address != null) { chatModel.userAddress.value = address savedAAS.value = aas diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index ea4ef79d42..4bc2d92410 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -37,10 +37,10 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { close, saveProfile = { displayName, fullName, image -> withApi { - val updated = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) + val updated = chatModel.controller.apiUpdateProfile(user.remoteHostId, profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) if (updated != null) { val (newProfile, _) = updated - chatModel.updateCurrentUser(newProfile) + chatModel.updateCurrentUser(user.remoteHostId, newProfile) profile = newProfile close() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index 7d3239700f..ec6d4e196e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -57,7 +57,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.end.closeModals() } withBGApi { - m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) + m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) } }, removeUser = { user -> @@ -106,24 +106,24 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.start.showModalCloseable(true) { close -> ProfileActionView(UserProfileAction.UNHIDE, user) { pwd -> withBGApi { - setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) } + setUserPrivacy(m) { m.controller.apiUnhideUser(user, pwd) } close() } } } } else { - withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, searchTextOrPassword.value.trim()) } } + withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, searchTextOrPassword.value.trim()) } } } }, muteUser = { user -> withBGApi { setUserPrivacy(m, onSuccess = { if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert) - }) { m.controller.apiMuteUser(user.userId) } + }) { m.controller.apiMuteUser(user) } } }, unmuteUser = { user -> - withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } } + withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user) } } }, showHiddenProfile = { user -> ModalManager.start.showModalCloseable(true) { close -> @@ -348,14 +348,14 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, de if (users.size < 2) return suspend fun deleteUser(user: User) { - m.controller.apiDeleteUser(user.userId, delSMPQueues, viewPwd) + m.controller.apiDeleteUser(user, delSMPQueues, viewPwd) m.removeUser(user) } try { if (user.activeUser) { val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden } if (newActive != null) { - m.controller.changeActiveUser_(newActive.userId, null) + m.controller.changeActiveUser_(newActive.remoteHostId, newActive.userId, null) deleteUser(user.copy(activeUser = false)) } } else { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index cce8a3ce85..c2665109f5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -50,22 +50,23 @@ actual fun ActiveCallView() { val call = chatModel.activeCall.value if (call != null) { Log.d(TAG, "has active call $call") + val callRh = call.remoteHostId when (val r = apiMsg.resp) { is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) - chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } is WCallResponse.Offer -> withBGApi { - chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } is WCallResponse.Answer -> withBGApi { - chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { @@ -73,7 +74,7 @@ actual fun ActiveCallView() { if (callStatus == WebRTCCallStatus.Connected) { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } - withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Error) { Log.d(TAG, "call status ${r.state.connectionState} not used") } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index d2fa97f860..61e0e0d3ce 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -44,7 +44,7 @@ actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow Unit) { - PasteToConnectView(m, close) +actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { + // TODO this should close if remote host changes in model + PasteToConnectView(m, rhId, close) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt index f202318f16..540de40a95 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt @@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel @Composable -actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { ConnectContactLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt index 464c286316..2d436dbbf0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt @@ -4,6 +4,6 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.ServerCfg @Composable -actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { - ScanProtocolServerLayout(onNext) +actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { + ScanProtocolServerLayout(rhId, onNext) } From 07ef2a0b6473f1ae16ae60372f54505e22d9e5e6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:20:31 +0000 Subject: [PATCH 100/174] android: remove ACCESS_WIFI_STATE (#3391) --- apps/multiplatform/android/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 459b1bc05a..09b33316a4 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ - From c536ca7f0f81dc72905e9e56caccb767652c2621 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:34:24 +0000 Subject: [PATCH 101/174] core: add events not sent to connected remote desktop (#3402) --- src/Simplex/Chat/Controller.hs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2ccc2ca12e..7533649c69 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -686,17 +686,29 @@ data ChatResponse | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) +-- some of these can only be used as command responses allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case - CRRemoteHostList {} -> False - CRRemoteHostConnected {} -> False - CRRemoteHostStopped {} -> False - CRRemoteCtrlList {} -> False - CRRemoteCtrlFound {} -> False + CRChatStarted -> False + CRChatRunning -> False + CRChatStopped -> False + CRChatSuspended -> False + CRRemoteHostList _ -> False + CRCurrentRemoteHost _ -> False + CRRemoteHostStarted {} -> False + CRRemoteHostSessionCode {} -> False + CRNewRemoteHost _ -> False + CRRemoteHostConnected _ -> False + CRRemoteHostStopped _ -> False + CRRemoteFileStored {} -> False + CRRemoteCtrlList _ -> False + CRRemoteCtrlFound _ -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False - CRRemoteCtrlConnected {} -> False + CRRemoteCtrlConnected _ -> False CRRemoteCtrlStopped -> False + CRSQLResult _ -> False + CRSlowSQLQueries {} -> False _ -> True logResponseToFile :: ChatResponse -> Bool From 718436bf55ec2fca22e028b51bc138e1c6fb81e9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 15:27:15 +0400 Subject: [PATCH 102/174] core: don't read all group members where unnecessary (#3403) --- src/Simplex/Chat.hs | 79 +++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5ec22602fd..06a603584b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3252,10 +3252,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure $ updateEntityConnStatus acEntity connStatus Nothing -> pure acEntity - isMember :: MemberId -> GroupInfo -> [GroupMember] -> Bool - isMember memId GroupInfo {membership} members = - sameMemberId memId membership || isJust (find (sameMemberId memId) members) - agentMsgConnStatus :: ACommand 'Agent e -> Maybe ConnStatus agentMsgConnStatus = \case CONF {} -> Just ConnRequested @@ -3533,7 +3529,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () CON -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ @@ -3553,6 +3548,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do 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 intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> @@ -4943,11 +4939,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> m () xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg brokerTs = do checkHostRole m memRole - members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ - if isMember memId gInfo members - then messageError "x.grp.mem.new error: member already exists" - else do + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right _ -> messageError "x.grp.mem.new error: member already exists" + Left _ -> do newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) groupMsgToView gInfo ci @@ -4956,11 +4951,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) = do case memberCategory m of - GCHostMember -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo - if isMember memId gInfo members - then messageWarning "x.grp.mem.intro ignored: member already exists" - else do + GCHostMember -> + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right _ -> messageError "x.grp.mem.intro ignored: member already exists" + Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second @@ -4986,11 +4980,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () xGrpMemInv gInfo@GroupInfo {groupId} m memId introInv = do case memberCategory m of - GCInviteeMember -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo - case find (sameMemberId memId) members of - Nothing -> messageError "x.grp.mem.inv error: referenced member does not exist" - Just reMember -> do + GCInviteeMember -> + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db 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 void . sendGroupMessage' user [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded @@ -4999,14 +4992,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole - members <- withStore' $ \db -> getGroupMembers db user gInfo - toMember <- case find (sameMemberId memId) members of - -- 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. - -- For now, this branch compensates for the lack of delayed message delivery. - Nothing -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced - Just m' -> pure m' + toMember <- + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db 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. + -- For now, this branch compensates for the lack of delayed message delivery. + Left _ -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + Right m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito @@ -5023,11 +5016,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | membership.memberId == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole - | otherwise = do - members <- withStore' $ \db -> getGroupMembers db user gInfo - case find (sameMemberId memId) members of - Just member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole - _ -> messageError "x.grp.mem.role with unknown member ID" + | otherwise = + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db 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 changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" @@ -5081,25 +5073,26 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> m () xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg brokerTs = do - members <- withStore' $ \db -> getGroupMembers db user gInfo if membership.memberId == memId then checkRole membership $ do deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history + members <- withStore' $ \db -> getGroupMembers db user gInfo deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m - else case find (sameMemberId memId) members of - Nothing -> messageError "x.grp.mem.del with unknown member ID" - Just member@GroupMember {groupMemberId, memberProfile} -> - checkRole member $ do - -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave - deleteMemberConnection user member - -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user member - deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} + else + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Left _ -> messageError "x.grp.mem.del with unknown member ID" + Right member@GroupMember {groupMemberId, memberProfile} -> + checkRole member $ do + -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave + deleteMemberConnection user member + -- undeleted "member connected" chat item will prevent deletion of member record + deleteOrUpdateMemberRecord user member + deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = From 53a31ec60ed2af6c93c83b013631b77697ec2718 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:51:40 +0000 Subject: [PATCH 103/174] core: add remote host ID to entities --- .../chat/simplex/common/views/call/CallView.android.kt | 2 ++ .../kotlin/chat/simplex/common/model/ChatModel.kt | 8 +++++--- .../simplex/common/views/call/IncomingCallAlertView.kt | 1 + .../kotlin/chat/simplex/common/views/call/WebRTC.kt | 4 ++-- .../kotlin/chat/simplex/common/views/chat/ChatInfoView.kt | 1 + .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 ++ .../simplex/common/views/chat/group/GroupChatInfoView.kt | 1 + .../simplex/common/views/chatlist/ChatListNavLinkView.kt | 2 ++ 8 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index c173463d5e..51c3623250 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -579,6 +579,7 @@ fun PreviewActiveCallOverlayVideo() { SimpleXTheme { ActiveCallOverlayLayout( call = Call( + remoteHostId = null, contact = Contact.sampleData, callState = CallState.Negotiated, localMedia = CallMediaType.Video, @@ -604,6 +605,7 @@ fun PreviewActiveCallOverlayAudio() { SimpleXTheme { ActiveCallOverlayLayout( call = Call( + remoteHostId = null, contact = Contact.sampleData, callState = CallState.Negotiated, localMedia = CallMediaType.Audio, 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 ab8b6af3fd..179a4e846a 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 @@ -616,7 +616,7 @@ enum class ChatType(val type: String) { @Serializable data class User( - val remoteHostId: Long? = null, + val remoteHostId: Long?, override val userId: Long, val userContactId: Long, val localDisplayName: String, @@ -639,6 +639,7 @@ data class User( companion object { val sampleData = User( + remoteHostId = null, userId = 1, userContactId = 1, localDisplayName = "alice", @@ -715,8 +716,8 @@ interface SomeChat { } @Serializable @Stable -data class Chat ( - val remoteHostId: Long? = null, +data class Chat( + val remoteHostId: Long?, val chatInfo: ChatInfo, val chatItems: List, val chatStats: ChatStats = ChatStats() @@ -749,6 +750,7 @@ data class Chat ( companion object { val sampleData = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf(ChatItem.getSampleData()) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 447236286f..47dd0a27d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -106,6 +106,7 @@ fun PreviewIncomingCallAlertLayout() { SimpleXTheme { IncomingCallAlertLayout( invitation = RcvCallInvitation( + remoteHostId = null, user = User.sampleData, contact = Contact.sampleData, callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 64904ba7a2..6a357d26ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -11,7 +11,7 @@ import java.util.* import kotlin.collections.ArrayList data class Call( - val remoteHostId: Long? = null, + val remoteHostId: Long?, val contact: Contact, val callState: CallState, val localMedia: CallMediaType, @@ -97,7 +97,7 @@ sealed class WCallResponse { @Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @Serializable data class RcvCallInvitation( - val remoteHostId: Long? = null, + val remoteHostId: Long?, val user: User, val contact: Contact, val callType: CallType, 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 5816c89524..694ec2ba18 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 @@ -718,6 +718,7 @@ fun PreviewChatInfoLayout() { SimpleXTheme { ChatInfoLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf() ), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 862212217f..5975f674e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1418,6 +1418,7 @@ fun PreviewChatLayout() { val searchValue = remember { mutableStateOf("") } ChatLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = chatItems, chatStats = Chat.ChatStats() @@ -1490,6 +1491,7 @@ fun PreviewGroupChatLayout() { val searchValue = remember { mutableStateOf("") } ChatLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Group.sampleData, chatItems = chatItems, chatStats = Chat.ChatStats() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 49d76d8ecd..d3b1841fe0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -524,6 +524,7 @@ fun PreviewGroupChatInfoLayout() { SimpleXTheme { GroupChatInfoLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf() ), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 7e81faf3d5..e121f9ee78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -772,6 +772,7 @@ fun PreviewChatListNavLinkDirect() { chatLinkPreview = { ChatPreviewView( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = listOf( ChatItem.getSampleData( @@ -815,6 +816,7 @@ fun PreviewChatListNavLinkGroup() { chatLinkPreview = { ChatPreviewView( Chat( + remoteHostId = null, chatInfo = ChatInfo.Group.sampleData, chatItems = listOf( ChatItem.getSampleData( From 44c88baddea7888fa69724fdee2b2f674da5d8f6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:31:52 +0400 Subject: [PATCH 104/174] remoteHostId in entities kotlin --- .../chat/simplex/app/views/call/IncomingCallActivity.kt | 1 + .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt index 104b654c58..f5c46a0eb5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt @@ -231,6 +231,7 @@ fun PreviewIncomingCallLockScreenAlert() { ) { IncomingCallLockScreenAlertLayout( invitation = RcvCallInvitation( + remoteHostId = null, user = User.sampleData, contact = Contact.sampleData, callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)), 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 ffb5b4251b..33743974cb 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 @@ -2531,7 +2531,7 @@ data class UserProtocolServers( @Serializable data class ServerCfg( - val remoteHostId: Long? = null, + val remoteHostId: Long?, val server: String, val preset: Boolean, val tested: Boolean? = null, @@ -2549,7 +2549,7 @@ data class ServerCfg( get() = server.isBlank() companion object { - val empty = ServerCfg(server = "", preset = false, tested = null, enabled = true) + val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = true) class SampleData( val preset: ServerCfg, @@ -2559,18 +2559,21 @@ data class ServerCfg( val sampleData = SampleData( preset = ServerCfg( + remoteHostId = null, server = "smp://abcd@smp8.simplex.im", preset = true, tested = true, enabled = true ), custom = ServerCfg( + remoteHostId = null, server = "smp://abcd@smp9.simplex.im", preset = false, tested = false, enabled = false ), untested = ServerCfg( + remoteHostId = null, server = "smp://abcd@smp10.simplex.im", preset = false, tested = null, From 72654caca65d8c1e5c6e886b5fd5f17a7e26e13f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:40:24 +0400 Subject: [PATCH 105/174] remoteHostId in backend --- src/Simplex/Chat.hs | 12 +- src/Simplex/Chat/Call.hs | 8 +- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Remote.hs | 6 +- src/Simplex/Chat/Remote/Protocol.hs | 232 +++++++++++++++++++++++++++- src/Simplex/Chat/Remote/Types.hs | 4 +- src/Simplex/Chat/Store/Messages.hs | 20 +-- src/Simplex/Chat/Store/Profiles.hs | 4 +- src/Simplex/Chat/Store/Remote.hs | 1 + src/Simplex/Chat/Store/Shared.hs | 2 +- src/Simplex/Chat/Terminal/Output.hs | 1 - src/Simplex/Chat/Types.hs | 8 +- src/Simplex/Chat/View.hs | 10 +- 13 files changed, 267 insertions(+), 44 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5ec22602fd..329285a6ec 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1069,7 +1069,7 @@ processChatCommand = \case callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage 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} + let call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -1141,7 +1141,7 @@ processChatCommand = \case rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do user <- getUserByContactId db contactId contact <- getContact db user contactId - pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} + pure RcvCallInvitation {remoteHostId = Nothing, user, contact, callType = peerCallType, sharedKey, callTs} APIGetNetworkStatuses -> withUser $ \_ -> CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> @@ -1186,7 +1186,7 @@ processChatCommand = \case servers' = fromMaybe (L.map toServerCfg defServers) $ nonEmpty servers pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p servers' defServers where - toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = True, tested = Nothing, enabled = True} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> processChatCommand $ APIGetUserProtoServers userId aProtocol APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ @@ -4766,7 +4766,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} - call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} + call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} calls <- asks currentCalls -- theoretically, the new call invitation for the current contact can mark the in-progress call as ended -- (and replace it in ChatController) @@ -4774,7 +4774,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} + toView $ CRCallInvitation RcvCallInvitation {remoteHostId = Nothing, user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} toView $ CRNewChatItem user $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci else featureRejected CFCalls where @@ -6314,7 +6314,7 @@ chatCommandP = (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) (pure Nothing) srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) - toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char adminContactReq :: ConnReqContact diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 313442838e..39481db4af 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -20,14 +20,15 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import Simplex.Chat.Types (Contact, ContactId, User) +import Simplex.Chat.Types (Contact, ContactId, User, RemoteHostId) import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call - { contactId :: ContactId, + { remoteHostId :: Maybe RemoteHostId, + contactId :: ContactId, callId :: CallId, chatItemId :: Int64, callState :: CallState, @@ -107,7 +108,8 @@ instance FromField CallId where fromField f = CallId <$> fromField f instance ToField CallId where toField (CallId m) = toField m data RcvCallInvitation = RcvCallInvitation - { user :: User, + { remoteHostId :: Maybe RemoteHostId, + user :: User, contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 9e4c309910..88ad946058 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -266,7 +266,8 @@ data NewChatItem d = NewChatItem -- | type to show one chat with messages data Chat c = Chat - { chatInfo :: ChatInfo c, + { remoteHostId :: Maybe RemoteHostId, + chatInfo :: ChatInfo c, chatItems :: [CChatItem c], chatStats :: ChatStats } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d9ef5bd648..da1b0f9d78 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -225,7 +225,7 @@ startRemoteHost rh_ = do pollEvents rhId rhClient = do oq <- asks outputQ forever $ do - r_ <- liftRH rhId $ remoteRecv rhClient 10000000 + r_ <- liftRH rhId $ remoteRecv rhId rhClient 10000000 forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) httpError :: RemoteHostId -> HTTP2ClientError -> ChatError httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow @@ -359,13 +359,13 @@ processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostClient -> ChatC processRemoteCommand remoteHostId c cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f SendImage chatName f -> sendFile "/img" chatName f - _ -> liftRH remoteHostId $ remoteSend c s + _ -> liftRH remoteHostId $ remoteSend remoteHostId c s where sendFile cmdName chatName (CryptoFile path cfArgs) = do -- don't encrypt in host if already encrypted locally CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption - liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + liftRH remoteHostId $ remoteSend remoteHostId c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] cryptoFileStr CryptoFile {filePath, cryptoArgs} = maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs <> encodeUtf8 (T.pack filePath) diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index c1acee1e0f..e70041e9c7 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -8,6 +8,7 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} module Simplex.Chat.Remote.Protocol where @@ -34,9 +35,12 @@ import Data.Word (Word32) import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H import Network.Transport.Internal (decodeWord32, encodeWord32) +import Simplex.Chat.Call (RcvCallInvitation (..)) import Simplex.Chat.Controller +import Simplex.Chat.Messages (AChat (..), Chat (..)) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types +import Simplex.Chat.Types (RemoteHostId, User (..), UserInfo (..), ServerCfg (..)) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Messaging.Agent.Client (agentDRG) import qualified Simplex.Messaging.Crypto as C @@ -105,18 +109,232 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client -- ** Commands -remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse -remoteSend c cmd = +remoteSend :: RemoteHostId -> RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend rhId c cmd = sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case - RRChatResponse cr -> pure cr + RRChatResponse cr -> pure $ enrichCRRemoteHostId cr rhId r -> badResponse r -remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) -remoteRecv c ms = +remoteRecv :: RemoteHostId -> RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv rhId c ms = sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case - RRChatEvent cr_ -> pure cr_ + RRChatEvent cr_ -> pure $ (`enrichCRRemoteHostId` rhId) <$> cr_ r -> badResponse r +enrichCRRemoteHostId :: ChatResponse -> RemoteHostId -> ChatResponse +enrichCRRemoteHostId cr rhId = case cr of + CRActiveUser {user} -> CRActiveUser {user = enrichUser user} + CRUsersList {users} -> CRUsersList {users = map enrichUserInfo users} + CRChatStarted -> cr + CRChatRunning -> cr + CRChatStopped -> cr + CRChatSuspended -> cr + CRApiChats {user, chats} -> CRApiChats {user = enrichUser user, chats = map enrichAChat chats} + CRChats {chats} -> CRChats {chats = map enrichAChat chats} + CRApiChat {user, chat} -> CRApiChat {user = enrichUser user, chat = enrichAChat chat} + CRChatItems {user, chatName_, chatItems} -> CRChatItems {user = enrichUser user, chatName_, chatItems} + CRChatItemInfo {user, chatItem, chatItemInfo} -> CRChatItemInfo {user = enrichUser user, chatItem, chatItemInfo} + CRChatItemId user chatItemId_ -> CRChatItemId (enrichUser user) chatItemId_ + CRApiParsedMarkdown {formattedText = _ft} -> cr + CRUserProtoServers {user, servers} -> CRUserProtoServers {user = enrichUser user, servers = enrichAUserProtoServers servers} + CRServerTestResult {user, testServer, testFailure} -> CRServerTestResult {user = enrichUser user, testServer, testFailure} + CRChatItemTTL {user, chatItemTTL} -> CRChatItemTTL {user = enrichUser user, chatItemTTL} + CRNetworkConfig {networkConfig = _nc} -> cr + CRContactInfo {user, contact, connectionStats_, customUserProfile} -> CRContactInfo {user = enrichUser user, contact, connectionStats_, customUserProfile} + CRGroupInfo {user, groupInfo, groupSummary} -> CRGroupInfo {user = enrichUser user, groupInfo, groupSummary} + CRGroupMemberInfo {user, groupInfo, member, connectionStats_} -> CRGroupMemberInfo {user = enrichUser user, groupInfo, member, connectionStats_} + CRContactSwitchStarted {user, contact, connectionStats} -> CRContactSwitchStarted {user = enrichUser user, contact, connectionStats} + CRGroupMemberSwitchStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchStarted {user = enrichUser user, groupInfo, member, connectionStats} + CRContactSwitchAborted {user, contact, connectionStats} -> CRContactSwitchAborted {user = enrichUser user, contact, connectionStats} + CRGroupMemberSwitchAborted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchAborted {user = enrichUser user, groupInfo, member, connectionStats} + CRContactSwitch {user, contact, switchProgress} -> CRContactSwitch {user = enrichUser user, contact, switchProgress} + CRGroupMemberSwitch {user, groupInfo, member, switchProgress} -> CRGroupMemberSwitch {user = enrichUser user, groupInfo, member, switchProgress} + CRContactRatchetSyncStarted {user, contact, connectionStats} -> CRContactRatchetSyncStarted {user = enrichUser user, contact, connectionStats} + CRGroupMemberRatchetSyncStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberRatchetSyncStarted {user = enrichUser user, groupInfo, member, connectionStats} + CRContactRatchetSync {user, contact, ratchetSyncProgress} -> CRContactRatchetSync {user = enrichUser user, contact, ratchetSyncProgress} + CRGroupMemberRatchetSync {user, groupInfo, member, ratchetSyncProgress} -> CRGroupMemberRatchetSync {user = enrichUser user, groupInfo, member, ratchetSyncProgress} + CRContactVerificationReset {user, contact} -> CRContactVerificationReset {user = enrichUser user, contact} + CRGroupMemberVerificationReset {user, groupInfo, member} -> CRGroupMemberVerificationReset {user = enrichUser user, groupInfo, member} + CRContactCode {user, contact, connectionCode} -> CRContactCode {user = enrichUser user, contact, connectionCode} + CRGroupMemberCode {user, groupInfo, member, connectionCode} -> CRGroupMemberCode {user = enrichUser user, groupInfo, member, connectionCode} + CRConnectionVerified {user, verified, expectedCode} -> CRConnectionVerified {user = enrichUser user, verified, expectedCode} + CRNewChatItem {user, chatItem} -> CRNewChatItem {user = enrichUser user, chatItem} + CRChatItemStatusUpdated {user, chatItem} -> CRChatItemStatusUpdated {user = enrichUser user, chatItem} + CRChatItemUpdated {user, chatItem} -> CRChatItemUpdated {user = enrichUser user, chatItem} + CRChatItemNotChanged {user, chatItem} -> CRChatItemNotChanged {user = enrichUser user, chatItem} + CRChatItemReaction {user, added, reaction} -> CRChatItemReaction {user = enrichUser user, added, reaction} + CRChatItemDeleted {user, deletedChatItem, toChatItem, byUser, timed} -> CRChatItemDeleted {user = enrichUser user, deletedChatItem, toChatItem, byUser, timed} + CRChatItemDeletedNotFound {user, contact, sharedMsgId} -> CRChatItemDeletedNotFound {user = enrichUser user, contact, sharedMsgId} + CRBroadcastSent {user, msgContent, successes, failures, timestamp} -> CRBroadcastSent {user = enrichUser user, msgContent, successes, failures, timestamp} + CRMsgIntegrityError {user, msgError} -> CRMsgIntegrityError {user = enrichUser user, msgError} + CRCmdAccepted {corr = _corr} -> cr + CRCmdOk {user_} -> CRCmdOk {user_ = enrichUser <$> user_} + CRChatHelp {helpSection = _hs} -> cr + CRWelcome {user} -> CRWelcome {user = enrichUser user} + CRGroupCreated {user, groupInfo} -> CRGroupCreated {user = enrichUser user, groupInfo} + CRGroupMembers {user, group} -> CRGroupMembers {user = enrichUser user, group} + CRContactsList {user, contacts} -> CRContactsList {user = enrichUser user, contacts} + CRUserContactLink {user, contactLink} -> CRUserContactLink {user = enrichUser user, contactLink} + CRUserContactLinkUpdated {user, contactLink} -> CRUserContactLinkUpdated {user = enrichUser user, contactLink} + CRContactRequestRejected {user, contactRequest} -> CRContactRequestRejected {user = enrichUser user, contactRequest} + CRUserAcceptedGroupSent {user, groupInfo, hostContact} -> CRUserAcceptedGroupSent {user = enrichUser user, groupInfo, hostContact} + CRGroupLinkConnecting {user, groupInfo, hostMember} -> CRGroupLinkConnecting {user = enrichUser user, groupInfo, hostMember} + CRUserDeletedMember {user, groupInfo, member} -> CRUserDeletedMember {user = enrichUser user, groupInfo, member} + CRGroupsList {user, groups} -> CRGroupsList {user = enrichUser user, groups} + CRSentGroupInvitation {user, groupInfo, contact, member} -> CRSentGroupInvitation {user = enrichUser user, groupInfo, contact, member} + CRFileTransferStatus user ftStatus -> CRFileTransferStatus (enrichUser user) ftStatus + CRFileTransferStatusXFTP user chatItem -> CRFileTransferStatusXFTP (enrichUser user) chatItem + CRUserProfile {user, profile} -> CRUserProfile {user = enrichUser user, profile} + CRUserProfileNoChange {user} -> CRUserProfileNoChange {user = enrichUser user} + CRUserPrivacy {user, updatedUser} -> CRUserPrivacy {user = enrichUser user, updatedUser = enrichUser updatedUser} + CRVersionInfo {versionInfo = _v, chatMigrations = _cm, agentMigrations = _am} -> cr + CRInvitation {user, connReqInvitation, connection} -> CRInvitation {user = enrichUser user, connReqInvitation, connection} + CRConnectionIncognitoUpdated {user, toConnection} -> CRConnectionIncognitoUpdated {user = enrichUser user, toConnection} + CRConnectionPlan {user, connectionPlan} -> CRConnectionPlan {user = enrichUser user, connectionPlan} + CRSentConfirmation {user} -> CRSentConfirmation {user = enrichUser user} + CRSentInvitation {user, customUserProfile} -> CRSentInvitation {user = enrichUser user, customUserProfile} + CRSentInvitationToContact {user, contact, customUserProfile} -> CRSentInvitationToContact {user = enrichUser user, contact, customUserProfile} + CRContactUpdated {user, fromContact, toContact} -> CRContactUpdated {user = enrichUser user, fromContact, toContact} + CRGroupMemberUpdated {user, groupInfo, fromMember, toMember} -> CRGroupMemberUpdated {user = enrichUser user, groupInfo, fromMember, toMember} + CRContactsMerged {user, intoContact, mergedContact, updatedContact} -> CRContactsMerged {user = enrichUser user, intoContact, mergedContact, updatedContact} + CRContactDeleted {user, contact} -> CRContactDeleted {user = enrichUser user, contact} + CRContactDeletedByContact {user, contact} -> CRContactDeletedByContact {user = enrichUser user, contact} + CRChatCleared {user, chatInfo} -> CRChatCleared {user = enrichUser user, chatInfo} + CRUserContactLinkCreated {user, connReqContact} -> CRUserContactLinkCreated {user = enrichUser user, connReqContact} + CRUserContactLinkDeleted {user} -> CRUserContactLinkDeleted {user = enrichUser user} + CRReceivedContactRequest {user, contactRequest} -> CRReceivedContactRequest {user = enrichUser user, contactRequest} + CRAcceptingContactRequest {user, contact} -> CRAcceptingContactRequest {user = enrichUser user, contact} + CRContactAlreadyExists {user, contact} -> CRContactAlreadyExists {user = enrichUser user, contact} + CRContactRequestAlreadyAccepted {user, contact} -> CRContactRequestAlreadyAccepted {user = enrichUser user, contact} + CRLeftMemberUser {user, groupInfo} -> CRLeftMemberUser {user = enrichUser user, groupInfo} + CRGroupDeletedUser {user, groupInfo} -> CRGroupDeletedUser {user = enrichUser user, groupInfo} + CRRcvFileDescrReady {user, chatItem} -> CRRcvFileDescrReady {user = enrichUser user, chatItem} + CRRcvFileAccepted {user, chatItem} -> CRRcvFileAccepted {user = enrichUser user, chatItem} + CRRcvFileAcceptedSndCancelled {user, rcvFileTransfer} -> CRRcvFileAcceptedSndCancelled {user = enrichUser user, rcvFileTransfer} + CRRcvFileDescrNotReady {user, chatItem} -> CRRcvFileDescrNotReady {user = enrichUser user, chatItem} + CRRcvFileStart {user, chatItem} -> CRRcvFileStart {user = enrichUser user, chatItem} + CRRcvFileProgressXFTP {user, chatItem, receivedSize, totalSize} -> CRRcvFileProgressXFTP {user = enrichUser user, chatItem, receivedSize, totalSize} + CRRcvFileComplete {user, chatItem} -> CRRcvFileComplete {user = enrichUser user, chatItem} + CRRcvFileCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileCancelled {user = enrichUser user, chatItem, rcvFileTransfer} + CRRcvFileSndCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileSndCancelled {user = enrichUser user, chatItem, rcvFileTransfer} + CRRcvFileError {user, chatItem, agentError} -> CRRcvFileError {user = enrichUser user, chatItem, agentError} + CRSndFileStart {user, chatItem, sndFileTransfer} -> CRSndFileStart {user = enrichUser user, chatItem, sndFileTransfer} + CRSndFileComplete {user, chatItem, sndFileTransfer} -> CRSndFileComplete {user = enrichUser user, chatItem, sndFileTransfer} + CRSndFileRcvCancelled {user, chatItem, sndFileTransfer} -> CRSndFileRcvCancelled {user = enrichUser user, chatItem, sndFileTransfer} + CRSndFileCancelled {user, chatItem, fileTransferMeta, sndFileTransfers} -> CRSndFileCancelled {user = enrichUser user, chatItem, fileTransferMeta, sndFileTransfers} + CRSndFileStartXFTP {user, chatItem, fileTransferMeta} -> CRSndFileStartXFTP {user = enrichUser user, chatItem, fileTransferMeta} + CRSndFileProgressXFTP {user, chatItem, fileTransferMeta, sentSize, totalSize} -> CRSndFileProgressXFTP {user = enrichUser user, chatItem, fileTransferMeta, sentSize, totalSize} + CRSndFileCompleteXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCompleteXFTP {user = enrichUser user, chatItem, fileTransferMeta} + CRSndFileCancelledXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCancelledXFTP {user = enrichUser user, chatItem, fileTransferMeta} + CRSndFileError {user, chatItem} -> CRSndFileError {user = enrichUser user, chatItem} + CRUserProfileUpdated {user, fromProfile, toProfile, updateSummary} -> CRUserProfileUpdated {user = enrichUser user, fromProfile, toProfile, updateSummary} + CRUserProfileImage {user, profile} -> CRUserProfileImage {user = enrichUser user, profile} + CRContactAliasUpdated {user, toContact} -> CRContactAliasUpdated {user = enrichUser user, toContact} + CRConnectionAliasUpdated {user, toConnection} -> CRConnectionAliasUpdated {user = enrichUser user, toConnection} + CRContactPrefsUpdated {user, fromContact, toContact} -> CRContactPrefsUpdated {user = enrichUser user, fromContact, toContact} + CRContactConnecting {user, contact} -> CRContactConnecting {user = enrichUser user, contact} + CRContactConnected {user, contact, userCustomProfile} -> CRContactConnected {user = enrichUser user, contact, userCustomProfile} + CRContactAnotherClient {user, contact} -> CRContactAnotherClient {user = enrichUser user, contact} + CRSubscriptionEnd {user, connectionEntity} -> CRSubscriptionEnd {user = enrichUser user, connectionEntity} + CRContactsDisconnected {server = _srv, contactRefs = _cr} -> cr + CRContactsSubscribed {server = _srv, contactRefs = _cr} -> cr + CRContactSubError {user, contact, chatError} -> CRContactSubError {user = enrichUser user, contact, chatError} + CRContactSubSummary {user, contactSubscriptions} -> CRContactSubSummary {user = enrichUser user, contactSubscriptions} + CRUserContactSubSummary {user, userContactSubscriptions} -> CRUserContactSubSummary {user = enrichUser user, userContactSubscriptions} + CRNetworkStatus {networkStatus = _ns, connections = _cns} -> cr + CRNetworkStatuses {user_, networkStatuses} -> CRNetworkStatuses {user_ = enrichUser <$> user_, networkStatuses} + CRHostConnected {protocol = _p, transportHost = _th} -> cr + CRHostDisconnected {protocol = _p, transportHost = _th} -> cr + CRGroupInvitation {user, groupInfo} -> CRGroupInvitation {user = enrichUser user, groupInfo} + CRReceivedGroupInvitation {user, groupInfo, contact, fromMemberRole, memberRole} -> CRReceivedGroupInvitation {user = enrichUser user, groupInfo, contact, fromMemberRole, memberRole} + CRUserJoinedGroup {user, groupInfo, hostMember} -> CRUserJoinedGroup {user = enrichUser user, groupInfo, hostMember} + CRJoinedGroupMember {user, groupInfo, member} -> CRJoinedGroupMember {user = enrichUser user, groupInfo, member} + CRJoinedGroupMemberConnecting {user, groupInfo, hostMember, member} -> CRJoinedGroupMemberConnecting {user = enrichUser user, groupInfo, hostMember, member} + CRMemberRole {user, groupInfo, byMember, member, fromRole, toRole} -> CRMemberRole {user = enrichUser user, groupInfo, byMember, member, fromRole, toRole} + CRMemberRoleUser {user, groupInfo, member, fromRole, toRole} -> CRMemberRoleUser {user = enrichUser user, groupInfo, member, fromRole, toRole} + CRConnectedToGroupMember {user, groupInfo, member, memberContact} -> CRConnectedToGroupMember {user = enrichUser user, groupInfo, member, memberContact} + CRDeletedMember {user, groupInfo, byMember, deletedMember} -> CRDeletedMember {user = enrichUser user, groupInfo, byMember, deletedMember} + CRDeletedMemberUser {user, groupInfo, member} -> CRDeletedMemberUser {user = enrichUser user, groupInfo, member} + CRLeftMember {user, groupInfo, member} -> CRLeftMember {user = enrichUser user, groupInfo, member} + CRGroupEmpty {user, groupInfo} -> CRGroupEmpty {user = enrichUser user, groupInfo} + CRGroupRemoved {user, groupInfo} -> CRGroupRemoved {user = enrichUser user, groupInfo} + CRGroupDeleted {user, groupInfo, member} -> CRGroupDeleted {user = enrichUser user, groupInfo, member} + CRGroupUpdated {user, fromGroup, toGroup, member_} -> CRGroupUpdated {user = enrichUser user, fromGroup, toGroup, member_} + CRGroupProfile {user, groupInfo} -> CRGroupProfile {user = enrichUser user, groupInfo} + CRGroupDescription {user, groupInfo} -> CRGroupDescription {user = enrichUser user, groupInfo} + CRGroupLinkCreated {user, groupInfo, connReqContact, memberRole} -> CRGroupLinkCreated {user = enrichUser user, groupInfo, connReqContact, memberRole} + CRGroupLink {user, groupInfo, connReqContact, memberRole} -> CRGroupLink {user = enrichUser user, groupInfo, connReqContact, memberRole} + CRGroupLinkDeleted {user, groupInfo} -> CRGroupLinkDeleted {user = enrichUser user, groupInfo} + CRAcceptingGroupJoinRequest {user, groupInfo, contact} -> CRAcceptingGroupJoinRequest {user = enrichUser user, groupInfo, contact} + CRAcceptingGroupJoinRequestMember {user, groupInfo, member} -> CRAcceptingGroupJoinRequestMember {user = enrichUser user, groupInfo, member} + CRNoMemberContactCreating {user, groupInfo, member} -> CRNoMemberContactCreating {user = enrichUser user, groupInfo, member} + CRNewMemberContact {user, contact, groupInfo, member} -> CRNewMemberContact {user = enrichUser user, contact, groupInfo, member} + CRNewMemberContactSentInv {user, contact, groupInfo, member} -> CRNewMemberContactSentInv {user = enrichUser user, contact, groupInfo, member} + CRNewMemberContactReceivedInv {user, contact, groupInfo, member} -> CRNewMemberContactReceivedInv {user = enrichUser user, contact, groupInfo, member} + CRContactAndMemberAssociated {user, contact, groupInfo, member, updatedContact} -> CRContactAndMemberAssociated {user = enrichUser user, contact, groupInfo, member, updatedContact} + CRMemberSubError {user, groupInfo, member, chatError} -> CRMemberSubError {user = enrichUser user, groupInfo, member, chatError} + CRMemberSubSummary {user, memberSubscriptions} -> CRMemberSubSummary {user = enrichUser user, memberSubscriptions} + CRGroupSubscribed {user, groupInfo} -> CRGroupSubscribed {user = enrichUser user, groupInfo} + CRPendingSubSummary {user, pendingSubscriptions} -> CRPendingSubSummary {user = enrichUser user, pendingSubscriptions} + CRSndFileSubError {user, sndFileTransfer, chatError} -> CRSndFileSubError {user = enrichUser user, sndFileTransfer, chatError} + CRRcvFileSubError {user, rcvFileTransfer, chatError} -> CRRcvFileSubError {user = enrichUser user, rcvFileTransfer, chatError} + CRCallInvitation {callInvitation} -> CRCallInvitation {callInvitation = enrichRcvCallInvitation callInvitation} + CRCallOffer {user, contact, callType, offer, sharedKey, askConfirmation} -> CRCallOffer {user = enrichUser user, contact, callType, offer, sharedKey, askConfirmation} + CRCallAnswer {user, contact, answer} -> CRCallAnswer {user = enrichUser user, contact, answer} + CRCallExtraInfo {user, contact, extraInfo} -> CRCallExtraInfo {user = enrichUser user, contact, extraInfo} + CRCallEnded {user, contact} -> CRCallEnded {user = enrichUser user, contact} + CRCallInvitations {callInvitations} -> CRCallInvitations {callInvitations = map enrichRcvCallInvitation callInvitations} + CRUserContactLinkSubscribed -> cr + CRUserContactLinkSubError {chatError = _ce} -> cr + CRNtfTokenStatus {status = _s} -> cr + CRNtfToken {token = _t, status = _s, ntfMode = _nm} -> cr + CRNtfMessages {user_, connEntity, msgTs, ntfMessages} -> CRNtfMessages {user_ = enrichUser <$> user_, connEntity, msgTs, ntfMessages} + CRNewContactConnection {user, connection} -> CRNewContactConnection {user = enrichUser user, connection} + CRContactConnectionDeleted {user, connection} -> CRContactConnectionDeleted {user = enrichUser user, connection} + CRRemoteHostList {remoteHosts = _rh} -> cr + CRCurrentRemoteHost {remoteHost_ = _rh} -> cr + CRRemoteHostStarted {remoteHost_ = _rh, invitation = _i} -> cr + CRRemoteHostSessionCode {remoteHost_ = _rh, sessionCode = _sc} -> cr + CRNewRemoteHost {remoteHost = _rh} -> cr + CRRemoteHostConnected {remoteHost = _rh} -> cr + CRRemoteHostStopped {remoteHostId_ = _rh} -> cr + CRRemoteFileStored {remoteHostId = _rhId, remoteFileSource = _rfs} -> cr + CRRemoteCtrlList {remoteCtrls = _rc} -> cr + CRRemoteCtrlFound {remoteCtrl = _rc} -> cr + CRRemoteCtrlConnecting {remoteCtrl_ = _rc, ctrlAppInfo = _cai, appVersion = _av} -> cr + CRRemoteCtrlSessionCode {remoteCtrl_ = _rc, sessionCode = _sc} -> cr + CRRemoteCtrlConnected {remoteCtrl = _rc} -> cr + CRRemoteCtrlStopped -> cr + CRSQLResult {rows = _r} -> cr + CRSlowSQLQueries {chatQueries = _cq, agentQueries = _aq} -> cr + CRDebugLocks {chatLockName = _cl, agentLocks = _al} -> cr + CRAgentStats {agentStats = _as} -> cr + CRAgentSubs {activeSubs = _as, pendingSubs = _ps, removedSubs = _rs} -> cr + CRAgentSubsDetails {agentSubs = _as} -> cr + CRConnectionDisabled {connectionEntity = _ce} -> cr + CRAgentRcvQueueDeleted {agentConnId = _acId, server = _srv, agentQueueId = _aqId, agentError_ = _ae} -> cr + CRAgentConnDeleted {agentConnId = _acId} -> cr + CRAgentUserDeleted {agentUserId = _auId} -> cr + CRMessageError {user, severity, errorMessage} -> CRMessageError {user = enrichUser user, severity, errorMessage} + CRChatCmdError {user_, chatError} -> CRChatCmdError {user_ = enrichUser <$> user_, chatError} + CRChatError {user_, chatError} -> CRChatError {user_ = enrichUser <$> user_, chatError} + CRArchiveImported {archiveErrors = _ae} -> cr + CRTimedAction {action = _a, durationMilliseconds = _dm} -> cr + where + enrichUser :: User -> User + enrichUser u = u {remoteHostId = Just rhId} + enrichUserInfo :: UserInfo -> UserInfo + enrichUserInfo uInfo@UserInfo {user} = uInfo {user = enrichUser user} + enrichAChat :: AChat -> AChat + enrichAChat (AChat cType chat) = AChat cType chat {remoteHostId = Just rhId} + enrichServerCfg :: ServerCfg p -> ServerCfg p + enrichServerCfg cfg = cfg {remoteHostId = Just rhId} + enrichAUserProtoServers :: AUserProtoServers -> AUserProtoServers + enrichAUserProtoServers (AUPS ups@UserProtoServers {protoServers}) = AUPS ups {protoServers = fmap enrichServerCfg protoServers} + enrichRcvCallInvitation :: RcvCallInvitation -> RcvCallInvitation + enrichRcvCallInvitation rci = rci {remoteHostId = Just rhId} + + remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath remoteStoreFile c localPath fileName = do (fileSize, fileDigest) <- getFileInfo localPath @@ -140,7 +358,7 @@ sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do - encFile_ <- mapM (prepareEncryptedFile encryption) file_ + encFile_ <- mapM (prepareEncryptedFile encryption) file_ req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd) HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing (header, getNext) <- parseDecryptHTTP2Body encryption response respBody diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 783a083e55..12b5862813 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -19,7 +19,7 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Remote.AppVersion -import Simplex.Chat.Types (verificationCode) +import Simplex.Chat.Types (verificationCode, RemoteHostId) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) @@ -118,8 +118,6 @@ data RemoteProtocolError | RPEException {someException :: Text} deriving (Show, Exception) -type RemoteHostId = Int64 - data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} deriving (Eq, Ord, Show) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b6d455fe86..859ce638e0 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -572,7 +572,7 @@ getDirectChatPreviews_ db user@User {userId} = do let contact = toContact user $ contactRow :. connRow ci_ = toDirectChatItemList currentTs ciRow_ stats = toChatStats statsRow - in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats + in AChat SCTDirect $ Chat Nothing (DirectChat contact) ci_ stats getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do @@ -645,7 +645,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do let groupInfo = toGroupInfo userContactId groupInfoRow ci_ = toGroupChatItemList currentTs userContactId ciRow_ stats = toChatStats statsRow - in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats + in AChat SCTGroup $ Chat Nothing (GroupChat groupInfo) ci_ stats getContactRequestChatPreviews_ :: DB.Connection -> User -> IO [AChat] getContactRequestChatPreviews_ db User {userId} = @@ -669,7 +669,7 @@ getContactRequestChatPreviews_ db User {userId} = toContactRequestChatPreview cReqRow = let cReq = toContactRequest cReqRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats + in AChat SCTContactRequest $ Chat Nothing (ContactRequest cReq) [] stats getContactConnectionChatPreviews_ :: DB.Connection -> User -> Bool -> IO [AChat] getContactConnectionChatPreviews_ _ _ False = pure [] @@ -688,7 +688,7 @@ getContactConnectionChatPreviews_ db User {userId} _ = toContactConnectionChatPreview connRow = let conn = toPendingContactConnection connRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + in AChat SCTContactConnection $ Chat Nothing (ContactConnection conn) [] stats getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChat db user contactId pagination search_ = do @@ -703,7 +703,7 @@ getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> Excep getDirectChatLast_ db user ct@Contact {contactId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- getDirectChatItemsLast db user contactId count search - pure $ Chat (DirectChat ct) (reverse chatItems) stats + pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect] @@ -733,7 +733,7 @@ getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsAfter_ - pure $ Chat (DirectChat ct) chatItems stats + pure $ Chat Nothing (DirectChat ct) chatItems stats where getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do @@ -763,7 +763,7 @@ getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsBefore_ - pure $ Chat (DirectChat ct) (reverse chatItems) stats + pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats where getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do @@ -803,7 +803,7 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat (GroupChat g) (reverse chatItems) stats + pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] getGroupChatItemIdsLast_ = @@ -841,7 +841,7 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat (GroupChat g) chatItems stats + pure $ Chat Nothing (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsAfter_ afterChatItemTs = @@ -864,7 +864,7 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat (GroupChat g) (reverse chatItems) stats + pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsBefore_ beforeChatItemTs = diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 611faf90c6..010bcf2bc3 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -504,7 +504,7 @@ getProtocolServers db User {userId} = toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in ServerCfg {server, preset, tested, enabled} + in ServerCfg {remoteHostId = Nothing, server, preset, tested, enabled} overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () overwriteProtocolServers db User {userId} servers = @@ -555,7 +555,7 @@ getCalls db = |] where toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call - toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs} + toCall (contactId, callId, chatItemId, callState, callTs) = Call {remoteHostId = Nothing, contactId, callId, chatItemId, callState, callTs} createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId createCommand db User {userId} connId commandFunction = do diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index ec84860379..4127ebce4b 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -18,6 +18,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.RemoteControl.Types import UnliftIO +import Simplex.Chat.Types (RemoteHostId) insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index af8220d8ea..78807fc038 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -316,7 +316,7 @@ userQuery = toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString) -> User toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_)) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} + User {remoteHostId = Nothing, userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} where profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""} fullPreferences = mergePreferences Nothing userPreferences diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 98d4285a2e..629054b474 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -26,7 +26,6 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) -import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) import Simplex.Chat.Types diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d96bddb8b7..536384f796 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -100,11 +100,14 @@ instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f instance ToField AgentUserId where toField (AgentUserId uId) = toField uId +type RemoteHostId = Int64 + aUserId :: User -> UserId aUserId User {agentUserId = AgentUserId uId} = uId data User = User - { userId :: UserId, + { remoteHostId :: Maybe RemoteHostId, + userId :: UserId, agentUserId :: AgentUserId, userContactId :: ContactId, localDisplayName :: ContactName, @@ -1521,7 +1524,8 @@ data XGrpMemIntroCont = XGrpMemIntroCont deriving (Show) data ServerCfg p = ServerCfg - { server :: ProtoServerWithAuth p, + { remoteHostId :: Maybe RemoteHostId, + server :: ProtoServerWithAuth p, preset :: Bool, tested :: Maybe Bool, enabled :: Bool diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 119d19e620..80ba9e5349 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -387,10 +387,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe testViewChats chats = [sShow $ map toChatView chats] where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) - toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) - toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) - toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) - toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) + toChatView (AChat _ (Chat _ (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) + toChatView (AChat _ (Chat _ (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) + toChatView (AChat _ (Chat _ (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) + toChatView (AChat _ (Chat _ (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) toCIPreview :: [CChatItem c] -> Maybe GroupMember -> Text toCIPreview (ci : _) membership_ = testViewItem ci membership_ toCIPreview _ _ = "" @@ -496,7 +496,7 @@ viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack viewChats :: CurrentTime -> TimeZone -> [AChat] -> [StyledString] viewChats ts tz = concatMap chatPreview . reverse where - chatPreview (AChat _ (Chat chat items _)) = case items of + chatPreview (AChat _ (Chat _ chat items _)) = case items of CChatItem _ ci : _ -> case viewChatItem chat ci True ts tz of s : _ -> [let s' = sTake 120 s in if sLength s' < sLength s then s' <> "..." else s'] _ -> chatName From 11478da6ef54bf75424c15ff3d97c101007bec6e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 19:56:31 +0400 Subject: [PATCH 106/174] Revert "remoteHostId in backend" This reverts commit 72654caca65d8c1e5c6e886b5fd5f17a7e26e13f. --- src/Simplex/Chat.hs | 12 +- src/Simplex/Chat/Call.hs | 8 +- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Remote.hs | 6 +- src/Simplex/Chat/Remote/Protocol.hs | 232 +--------------------------- src/Simplex/Chat/Remote/Types.hs | 4 +- src/Simplex/Chat/Store/Messages.hs | 20 +-- src/Simplex/Chat/Store/Profiles.hs | 4 +- src/Simplex/Chat/Store/Remote.hs | 1 - src/Simplex/Chat/Store/Shared.hs | 2 +- src/Simplex/Chat/Terminal/Output.hs | 1 + src/Simplex/Chat/Types.hs | 8 +- src/Simplex/Chat/View.hs | 10 +- 13 files changed, 44 insertions(+), 267 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 329285a6ec..5ec22602fd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1069,7 +1069,7 @@ processChatCommand = \case callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) - let call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} + let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -1141,7 +1141,7 @@ processChatCommand = \case rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do user <- getUserByContactId db contactId contact <- getContact db user contactId - pure RcvCallInvitation {remoteHostId = Nothing, user, contact, callType = peerCallType, sharedKey, callTs} + pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} APIGetNetworkStatuses -> withUser $ \_ -> CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> @@ -1186,7 +1186,7 @@ processChatCommand = \case servers' = fromMaybe (L.map toServerCfg defServers) $ nonEmpty servers pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p servers' defServers where - toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = True, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> processChatCommand $ APIGetUserProtoServers userId aProtocol APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ @@ -4766,7 +4766,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} - call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} + call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} calls <- asks currentCalls -- theoretically, the new call invitation for the current contact can mark the in-progress call as ended -- (and replace it in ChatController) @@ -4774,7 +4774,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRCallInvitation RcvCallInvitation {remoteHostId = Nothing, user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} + toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} toView $ CRNewChatItem user $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci else featureRejected CFCalls where @@ -6314,7 +6314,7 @@ chatCommandP = (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) (pure Nothing) srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) - toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = False, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char adminContactReq :: ConnReqContact diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 39481db4af..313442838e 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -20,15 +20,14 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import Simplex.Chat.Types (Contact, ContactId, User, RemoteHostId) +import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call - { remoteHostId :: Maybe RemoteHostId, - contactId :: ContactId, + { contactId :: ContactId, callId :: CallId, chatItemId :: Int64, callState :: CallState, @@ -108,8 +107,7 @@ instance FromField CallId where fromField f = CallId <$> fromField f instance ToField CallId where toField (CallId m) = toField m data RcvCallInvitation = RcvCallInvitation - { remoteHostId :: Maybe RemoteHostId, - user :: User, + { user :: User, contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 88ad946058..9e4c309910 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -266,8 +266,7 @@ data NewChatItem d = NewChatItem -- | type to show one chat with messages data Chat c = Chat - { remoteHostId :: Maybe RemoteHostId, - chatInfo :: ChatInfo c, + { chatInfo :: ChatInfo c, chatItems :: [CChatItem c], chatStats :: ChatStats } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index da1b0f9d78..d9ef5bd648 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -225,7 +225,7 @@ startRemoteHost rh_ = do pollEvents rhId rhClient = do oq <- asks outputQ forever $ do - r_ <- liftRH rhId $ remoteRecv rhId rhClient 10000000 + r_ <- liftRH rhId $ remoteRecv rhClient 10000000 forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) httpError :: RemoteHostId -> HTTP2ClientError -> ChatError httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow @@ -359,13 +359,13 @@ processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostClient -> ChatC processRemoteCommand remoteHostId c cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f SendImage chatName f -> sendFile "/img" chatName f - _ -> liftRH remoteHostId $ remoteSend remoteHostId c s + _ -> liftRH remoteHostId $ remoteSend c s where sendFile cmdName chatName (CryptoFile path cfArgs) = do -- don't encrypt in host if already encrypted locally CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption - liftRH remoteHostId $ remoteSend remoteHostId c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] cryptoFileStr CryptoFile {filePath, cryptoArgs} = maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs <> encodeUtf8 (T.pack filePath) diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index e70041e9c7..c1acee1e0f 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -8,7 +8,6 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TupleSections #-} -{-# OPTIONS_GHC -Wno-ambiguous-fields #-} module Simplex.Chat.Remote.Protocol where @@ -35,12 +34,9 @@ import Data.Word (Word32) import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H import Network.Transport.Internal (decodeWord32, encodeWord32) -import Simplex.Chat.Call (RcvCallInvitation (..)) import Simplex.Chat.Controller -import Simplex.Chat.Messages (AChat (..), Chat (..)) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types -import Simplex.Chat.Types (RemoteHostId, User (..), UserInfo (..), ServerCfg (..)) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Messaging.Agent.Client (agentDRG) import qualified Simplex.Messaging.Crypto as C @@ -109,232 +105,18 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client -- ** Commands -remoteSend :: RemoteHostId -> RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse -remoteSend rhId c cmd = +remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend c cmd = sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case - RRChatResponse cr -> pure $ enrichCRRemoteHostId cr rhId + RRChatResponse cr -> pure cr r -> badResponse r -remoteRecv :: RemoteHostId -> RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) -remoteRecv rhId c ms = +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv c ms = sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case - RRChatEvent cr_ -> pure $ (`enrichCRRemoteHostId` rhId) <$> cr_ + RRChatEvent cr_ -> pure cr_ r -> badResponse r -enrichCRRemoteHostId :: ChatResponse -> RemoteHostId -> ChatResponse -enrichCRRemoteHostId cr rhId = case cr of - CRActiveUser {user} -> CRActiveUser {user = enrichUser user} - CRUsersList {users} -> CRUsersList {users = map enrichUserInfo users} - CRChatStarted -> cr - CRChatRunning -> cr - CRChatStopped -> cr - CRChatSuspended -> cr - CRApiChats {user, chats} -> CRApiChats {user = enrichUser user, chats = map enrichAChat chats} - CRChats {chats} -> CRChats {chats = map enrichAChat chats} - CRApiChat {user, chat} -> CRApiChat {user = enrichUser user, chat = enrichAChat chat} - CRChatItems {user, chatName_, chatItems} -> CRChatItems {user = enrichUser user, chatName_, chatItems} - CRChatItemInfo {user, chatItem, chatItemInfo} -> CRChatItemInfo {user = enrichUser user, chatItem, chatItemInfo} - CRChatItemId user chatItemId_ -> CRChatItemId (enrichUser user) chatItemId_ - CRApiParsedMarkdown {formattedText = _ft} -> cr - CRUserProtoServers {user, servers} -> CRUserProtoServers {user = enrichUser user, servers = enrichAUserProtoServers servers} - CRServerTestResult {user, testServer, testFailure} -> CRServerTestResult {user = enrichUser user, testServer, testFailure} - CRChatItemTTL {user, chatItemTTL} -> CRChatItemTTL {user = enrichUser user, chatItemTTL} - CRNetworkConfig {networkConfig = _nc} -> cr - CRContactInfo {user, contact, connectionStats_, customUserProfile} -> CRContactInfo {user = enrichUser user, contact, connectionStats_, customUserProfile} - CRGroupInfo {user, groupInfo, groupSummary} -> CRGroupInfo {user = enrichUser user, groupInfo, groupSummary} - CRGroupMemberInfo {user, groupInfo, member, connectionStats_} -> CRGroupMemberInfo {user = enrichUser user, groupInfo, member, connectionStats_} - CRContactSwitchStarted {user, contact, connectionStats} -> CRContactSwitchStarted {user = enrichUser user, contact, connectionStats} - CRGroupMemberSwitchStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchStarted {user = enrichUser user, groupInfo, member, connectionStats} - CRContactSwitchAborted {user, contact, connectionStats} -> CRContactSwitchAborted {user = enrichUser user, contact, connectionStats} - CRGroupMemberSwitchAborted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchAborted {user = enrichUser user, groupInfo, member, connectionStats} - CRContactSwitch {user, contact, switchProgress} -> CRContactSwitch {user = enrichUser user, contact, switchProgress} - CRGroupMemberSwitch {user, groupInfo, member, switchProgress} -> CRGroupMemberSwitch {user = enrichUser user, groupInfo, member, switchProgress} - CRContactRatchetSyncStarted {user, contact, connectionStats} -> CRContactRatchetSyncStarted {user = enrichUser user, contact, connectionStats} - CRGroupMemberRatchetSyncStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberRatchetSyncStarted {user = enrichUser user, groupInfo, member, connectionStats} - CRContactRatchetSync {user, contact, ratchetSyncProgress} -> CRContactRatchetSync {user = enrichUser user, contact, ratchetSyncProgress} - CRGroupMemberRatchetSync {user, groupInfo, member, ratchetSyncProgress} -> CRGroupMemberRatchetSync {user = enrichUser user, groupInfo, member, ratchetSyncProgress} - CRContactVerificationReset {user, contact} -> CRContactVerificationReset {user = enrichUser user, contact} - CRGroupMemberVerificationReset {user, groupInfo, member} -> CRGroupMemberVerificationReset {user = enrichUser user, groupInfo, member} - CRContactCode {user, contact, connectionCode} -> CRContactCode {user = enrichUser user, contact, connectionCode} - CRGroupMemberCode {user, groupInfo, member, connectionCode} -> CRGroupMemberCode {user = enrichUser user, groupInfo, member, connectionCode} - CRConnectionVerified {user, verified, expectedCode} -> CRConnectionVerified {user = enrichUser user, verified, expectedCode} - CRNewChatItem {user, chatItem} -> CRNewChatItem {user = enrichUser user, chatItem} - CRChatItemStatusUpdated {user, chatItem} -> CRChatItemStatusUpdated {user = enrichUser user, chatItem} - CRChatItemUpdated {user, chatItem} -> CRChatItemUpdated {user = enrichUser user, chatItem} - CRChatItemNotChanged {user, chatItem} -> CRChatItemNotChanged {user = enrichUser user, chatItem} - CRChatItemReaction {user, added, reaction} -> CRChatItemReaction {user = enrichUser user, added, reaction} - CRChatItemDeleted {user, deletedChatItem, toChatItem, byUser, timed} -> CRChatItemDeleted {user = enrichUser user, deletedChatItem, toChatItem, byUser, timed} - CRChatItemDeletedNotFound {user, contact, sharedMsgId} -> CRChatItemDeletedNotFound {user = enrichUser user, contact, sharedMsgId} - CRBroadcastSent {user, msgContent, successes, failures, timestamp} -> CRBroadcastSent {user = enrichUser user, msgContent, successes, failures, timestamp} - CRMsgIntegrityError {user, msgError} -> CRMsgIntegrityError {user = enrichUser user, msgError} - CRCmdAccepted {corr = _corr} -> cr - CRCmdOk {user_} -> CRCmdOk {user_ = enrichUser <$> user_} - CRChatHelp {helpSection = _hs} -> cr - CRWelcome {user} -> CRWelcome {user = enrichUser user} - CRGroupCreated {user, groupInfo} -> CRGroupCreated {user = enrichUser user, groupInfo} - CRGroupMembers {user, group} -> CRGroupMembers {user = enrichUser user, group} - CRContactsList {user, contacts} -> CRContactsList {user = enrichUser user, contacts} - CRUserContactLink {user, contactLink} -> CRUserContactLink {user = enrichUser user, contactLink} - CRUserContactLinkUpdated {user, contactLink} -> CRUserContactLinkUpdated {user = enrichUser user, contactLink} - CRContactRequestRejected {user, contactRequest} -> CRContactRequestRejected {user = enrichUser user, contactRequest} - CRUserAcceptedGroupSent {user, groupInfo, hostContact} -> CRUserAcceptedGroupSent {user = enrichUser user, groupInfo, hostContact} - CRGroupLinkConnecting {user, groupInfo, hostMember} -> CRGroupLinkConnecting {user = enrichUser user, groupInfo, hostMember} - CRUserDeletedMember {user, groupInfo, member} -> CRUserDeletedMember {user = enrichUser user, groupInfo, member} - CRGroupsList {user, groups} -> CRGroupsList {user = enrichUser user, groups} - CRSentGroupInvitation {user, groupInfo, contact, member} -> CRSentGroupInvitation {user = enrichUser user, groupInfo, contact, member} - CRFileTransferStatus user ftStatus -> CRFileTransferStatus (enrichUser user) ftStatus - CRFileTransferStatusXFTP user chatItem -> CRFileTransferStatusXFTP (enrichUser user) chatItem - CRUserProfile {user, profile} -> CRUserProfile {user = enrichUser user, profile} - CRUserProfileNoChange {user} -> CRUserProfileNoChange {user = enrichUser user} - CRUserPrivacy {user, updatedUser} -> CRUserPrivacy {user = enrichUser user, updatedUser = enrichUser updatedUser} - CRVersionInfo {versionInfo = _v, chatMigrations = _cm, agentMigrations = _am} -> cr - CRInvitation {user, connReqInvitation, connection} -> CRInvitation {user = enrichUser user, connReqInvitation, connection} - CRConnectionIncognitoUpdated {user, toConnection} -> CRConnectionIncognitoUpdated {user = enrichUser user, toConnection} - CRConnectionPlan {user, connectionPlan} -> CRConnectionPlan {user = enrichUser user, connectionPlan} - CRSentConfirmation {user} -> CRSentConfirmation {user = enrichUser user} - CRSentInvitation {user, customUserProfile} -> CRSentInvitation {user = enrichUser user, customUserProfile} - CRSentInvitationToContact {user, contact, customUserProfile} -> CRSentInvitationToContact {user = enrichUser user, contact, customUserProfile} - CRContactUpdated {user, fromContact, toContact} -> CRContactUpdated {user = enrichUser user, fromContact, toContact} - CRGroupMemberUpdated {user, groupInfo, fromMember, toMember} -> CRGroupMemberUpdated {user = enrichUser user, groupInfo, fromMember, toMember} - CRContactsMerged {user, intoContact, mergedContact, updatedContact} -> CRContactsMerged {user = enrichUser user, intoContact, mergedContact, updatedContact} - CRContactDeleted {user, contact} -> CRContactDeleted {user = enrichUser user, contact} - CRContactDeletedByContact {user, contact} -> CRContactDeletedByContact {user = enrichUser user, contact} - CRChatCleared {user, chatInfo} -> CRChatCleared {user = enrichUser user, chatInfo} - CRUserContactLinkCreated {user, connReqContact} -> CRUserContactLinkCreated {user = enrichUser user, connReqContact} - CRUserContactLinkDeleted {user} -> CRUserContactLinkDeleted {user = enrichUser user} - CRReceivedContactRequest {user, contactRequest} -> CRReceivedContactRequest {user = enrichUser user, contactRequest} - CRAcceptingContactRequest {user, contact} -> CRAcceptingContactRequest {user = enrichUser user, contact} - CRContactAlreadyExists {user, contact} -> CRContactAlreadyExists {user = enrichUser user, contact} - CRContactRequestAlreadyAccepted {user, contact} -> CRContactRequestAlreadyAccepted {user = enrichUser user, contact} - CRLeftMemberUser {user, groupInfo} -> CRLeftMemberUser {user = enrichUser user, groupInfo} - CRGroupDeletedUser {user, groupInfo} -> CRGroupDeletedUser {user = enrichUser user, groupInfo} - CRRcvFileDescrReady {user, chatItem} -> CRRcvFileDescrReady {user = enrichUser user, chatItem} - CRRcvFileAccepted {user, chatItem} -> CRRcvFileAccepted {user = enrichUser user, chatItem} - CRRcvFileAcceptedSndCancelled {user, rcvFileTransfer} -> CRRcvFileAcceptedSndCancelled {user = enrichUser user, rcvFileTransfer} - CRRcvFileDescrNotReady {user, chatItem} -> CRRcvFileDescrNotReady {user = enrichUser user, chatItem} - CRRcvFileStart {user, chatItem} -> CRRcvFileStart {user = enrichUser user, chatItem} - CRRcvFileProgressXFTP {user, chatItem, receivedSize, totalSize} -> CRRcvFileProgressXFTP {user = enrichUser user, chatItem, receivedSize, totalSize} - CRRcvFileComplete {user, chatItem} -> CRRcvFileComplete {user = enrichUser user, chatItem} - CRRcvFileCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileCancelled {user = enrichUser user, chatItem, rcvFileTransfer} - CRRcvFileSndCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileSndCancelled {user = enrichUser user, chatItem, rcvFileTransfer} - CRRcvFileError {user, chatItem, agentError} -> CRRcvFileError {user = enrichUser user, chatItem, agentError} - CRSndFileStart {user, chatItem, sndFileTransfer} -> CRSndFileStart {user = enrichUser user, chatItem, sndFileTransfer} - CRSndFileComplete {user, chatItem, sndFileTransfer} -> CRSndFileComplete {user = enrichUser user, chatItem, sndFileTransfer} - CRSndFileRcvCancelled {user, chatItem, sndFileTransfer} -> CRSndFileRcvCancelled {user = enrichUser user, chatItem, sndFileTransfer} - CRSndFileCancelled {user, chatItem, fileTransferMeta, sndFileTransfers} -> CRSndFileCancelled {user = enrichUser user, chatItem, fileTransferMeta, sndFileTransfers} - CRSndFileStartXFTP {user, chatItem, fileTransferMeta} -> CRSndFileStartXFTP {user = enrichUser user, chatItem, fileTransferMeta} - CRSndFileProgressXFTP {user, chatItem, fileTransferMeta, sentSize, totalSize} -> CRSndFileProgressXFTP {user = enrichUser user, chatItem, fileTransferMeta, sentSize, totalSize} - CRSndFileCompleteXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCompleteXFTP {user = enrichUser user, chatItem, fileTransferMeta} - CRSndFileCancelledXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCancelledXFTP {user = enrichUser user, chatItem, fileTransferMeta} - CRSndFileError {user, chatItem} -> CRSndFileError {user = enrichUser user, chatItem} - CRUserProfileUpdated {user, fromProfile, toProfile, updateSummary} -> CRUserProfileUpdated {user = enrichUser user, fromProfile, toProfile, updateSummary} - CRUserProfileImage {user, profile} -> CRUserProfileImage {user = enrichUser user, profile} - CRContactAliasUpdated {user, toContact} -> CRContactAliasUpdated {user = enrichUser user, toContact} - CRConnectionAliasUpdated {user, toConnection} -> CRConnectionAliasUpdated {user = enrichUser user, toConnection} - CRContactPrefsUpdated {user, fromContact, toContact} -> CRContactPrefsUpdated {user = enrichUser user, fromContact, toContact} - CRContactConnecting {user, contact} -> CRContactConnecting {user = enrichUser user, contact} - CRContactConnected {user, contact, userCustomProfile} -> CRContactConnected {user = enrichUser user, contact, userCustomProfile} - CRContactAnotherClient {user, contact} -> CRContactAnotherClient {user = enrichUser user, contact} - CRSubscriptionEnd {user, connectionEntity} -> CRSubscriptionEnd {user = enrichUser user, connectionEntity} - CRContactsDisconnected {server = _srv, contactRefs = _cr} -> cr - CRContactsSubscribed {server = _srv, contactRefs = _cr} -> cr - CRContactSubError {user, contact, chatError} -> CRContactSubError {user = enrichUser user, contact, chatError} - CRContactSubSummary {user, contactSubscriptions} -> CRContactSubSummary {user = enrichUser user, contactSubscriptions} - CRUserContactSubSummary {user, userContactSubscriptions} -> CRUserContactSubSummary {user = enrichUser user, userContactSubscriptions} - CRNetworkStatus {networkStatus = _ns, connections = _cns} -> cr - CRNetworkStatuses {user_, networkStatuses} -> CRNetworkStatuses {user_ = enrichUser <$> user_, networkStatuses} - CRHostConnected {protocol = _p, transportHost = _th} -> cr - CRHostDisconnected {protocol = _p, transportHost = _th} -> cr - CRGroupInvitation {user, groupInfo} -> CRGroupInvitation {user = enrichUser user, groupInfo} - CRReceivedGroupInvitation {user, groupInfo, contact, fromMemberRole, memberRole} -> CRReceivedGroupInvitation {user = enrichUser user, groupInfo, contact, fromMemberRole, memberRole} - CRUserJoinedGroup {user, groupInfo, hostMember} -> CRUserJoinedGroup {user = enrichUser user, groupInfo, hostMember} - CRJoinedGroupMember {user, groupInfo, member} -> CRJoinedGroupMember {user = enrichUser user, groupInfo, member} - CRJoinedGroupMemberConnecting {user, groupInfo, hostMember, member} -> CRJoinedGroupMemberConnecting {user = enrichUser user, groupInfo, hostMember, member} - CRMemberRole {user, groupInfo, byMember, member, fromRole, toRole} -> CRMemberRole {user = enrichUser user, groupInfo, byMember, member, fromRole, toRole} - CRMemberRoleUser {user, groupInfo, member, fromRole, toRole} -> CRMemberRoleUser {user = enrichUser user, groupInfo, member, fromRole, toRole} - CRConnectedToGroupMember {user, groupInfo, member, memberContact} -> CRConnectedToGroupMember {user = enrichUser user, groupInfo, member, memberContact} - CRDeletedMember {user, groupInfo, byMember, deletedMember} -> CRDeletedMember {user = enrichUser user, groupInfo, byMember, deletedMember} - CRDeletedMemberUser {user, groupInfo, member} -> CRDeletedMemberUser {user = enrichUser user, groupInfo, member} - CRLeftMember {user, groupInfo, member} -> CRLeftMember {user = enrichUser user, groupInfo, member} - CRGroupEmpty {user, groupInfo} -> CRGroupEmpty {user = enrichUser user, groupInfo} - CRGroupRemoved {user, groupInfo} -> CRGroupRemoved {user = enrichUser user, groupInfo} - CRGroupDeleted {user, groupInfo, member} -> CRGroupDeleted {user = enrichUser user, groupInfo, member} - CRGroupUpdated {user, fromGroup, toGroup, member_} -> CRGroupUpdated {user = enrichUser user, fromGroup, toGroup, member_} - CRGroupProfile {user, groupInfo} -> CRGroupProfile {user = enrichUser user, groupInfo} - CRGroupDescription {user, groupInfo} -> CRGroupDescription {user = enrichUser user, groupInfo} - CRGroupLinkCreated {user, groupInfo, connReqContact, memberRole} -> CRGroupLinkCreated {user = enrichUser user, groupInfo, connReqContact, memberRole} - CRGroupLink {user, groupInfo, connReqContact, memberRole} -> CRGroupLink {user = enrichUser user, groupInfo, connReqContact, memberRole} - CRGroupLinkDeleted {user, groupInfo} -> CRGroupLinkDeleted {user = enrichUser user, groupInfo} - CRAcceptingGroupJoinRequest {user, groupInfo, contact} -> CRAcceptingGroupJoinRequest {user = enrichUser user, groupInfo, contact} - CRAcceptingGroupJoinRequestMember {user, groupInfo, member} -> CRAcceptingGroupJoinRequestMember {user = enrichUser user, groupInfo, member} - CRNoMemberContactCreating {user, groupInfo, member} -> CRNoMemberContactCreating {user = enrichUser user, groupInfo, member} - CRNewMemberContact {user, contact, groupInfo, member} -> CRNewMemberContact {user = enrichUser user, contact, groupInfo, member} - CRNewMemberContactSentInv {user, contact, groupInfo, member} -> CRNewMemberContactSentInv {user = enrichUser user, contact, groupInfo, member} - CRNewMemberContactReceivedInv {user, contact, groupInfo, member} -> CRNewMemberContactReceivedInv {user = enrichUser user, contact, groupInfo, member} - CRContactAndMemberAssociated {user, contact, groupInfo, member, updatedContact} -> CRContactAndMemberAssociated {user = enrichUser user, contact, groupInfo, member, updatedContact} - CRMemberSubError {user, groupInfo, member, chatError} -> CRMemberSubError {user = enrichUser user, groupInfo, member, chatError} - CRMemberSubSummary {user, memberSubscriptions} -> CRMemberSubSummary {user = enrichUser user, memberSubscriptions} - CRGroupSubscribed {user, groupInfo} -> CRGroupSubscribed {user = enrichUser user, groupInfo} - CRPendingSubSummary {user, pendingSubscriptions} -> CRPendingSubSummary {user = enrichUser user, pendingSubscriptions} - CRSndFileSubError {user, sndFileTransfer, chatError} -> CRSndFileSubError {user = enrichUser user, sndFileTransfer, chatError} - CRRcvFileSubError {user, rcvFileTransfer, chatError} -> CRRcvFileSubError {user = enrichUser user, rcvFileTransfer, chatError} - CRCallInvitation {callInvitation} -> CRCallInvitation {callInvitation = enrichRcvCallInvitation callInvitation} - CRCallOffer {user, contact, callType, offer, sharedKey, askConfirmation} -> CRCallOffer {user = enrichUser user, contact, callType, offer, sharedKey, askConfirmation} - CRCallAnswer {user, contact, answer} -> CRCallAnswer {user = enrichUser user, contact, answer} - CRCallExtraInfo {user, contact, extraInfo} -> CRCallExtraInfo {user = enrichUser user, contact, extraInfo} - CRCallEnded {user, contact} -> CRCallEnded {user = enrichUser user, contact} - CRCallInvitations {callInvitations} -> CRCallInvitations {callInvitations = map enrichRcvCallInvitation callInvitations} - CRUserContactLinkSubscribed -> cr - CRUserContactLinkSubError {chatError = _ce} -> cr - CRNtfTokenStatus {status = _s} -> cr - CRNtfToken {token = _t, status = _s, ntfMode = _nm} -> cr - CRNtfMessages {user_, connEntity, msgTs, ntfMessages} -> CRNtfMessages {user_ = enrichUser <$> user_, connEntity, msgTs, ntfMessages} - CRNewContactConnection {user, connection} -> CRNewContactConnection {user = enrichUser user, connection} - CRContactConnectionDeleted {user, connection} -> CRContactConnectionDeleted {user = enrichUser user, connection} - CRRemoteHostList {remoteHosts = _rh} -> cr - CRCurrentRemoteHost {remoteHost_ = _rh} -> cr - CRRemoteHostStarted {remoteHost_ = _rh, invitation = _i} -> cr - CRRemoteHostSessionCode {remoteHost_ = _rh, sessionCode = _sc} -> cr - CRNewRemoteHost {remoteHost = _rh} -> cr - CRRemoteHostConnected {remoteHost = _rh} -> cr - CRRemoteHostStopped {remoteHostId_ = _rh} -> cr - CRRemoteFileStored {remoteHostId = _rhId, remoteFileSource = _rfs} -> cr - CRRemoteCtrlList {remoteCtrls = _rc} -> cr - CRRemoteCtrlFound {remoteCtrl = _rc} -> cr - CRRemoteCtrlConnecting {remoteCtrl_ = _rc, ctrlAppInfo = _cai, appVersion = _av} -> cr - CRRemoteCtrlSessionCode {remoteCtrl_ = _rc, sessionCode = _sc} -> cr - CRRemoteCtrlConnected {remoteCtrl = _rc} -> cr - CRRemoteCtrlStopped -> cr - CRSQLResult {rows = _r} -> cr - CRSlowSQLQueries {chatQueries = _cq, agentQueries = _aq} -> cr - CRDebugLocks {chatLockName = _cl, agentLocks = _al} -> cr - CRAgentStats {agentStats = _as} -> cr - CRAgentSubs {activeSubs = _as, pendingSubs = _ps, removedSubs = _rs} -> cr - CRAgentSubsDetails {agentSubs = _as} -> cr - CRConnectionDisabled {connectionEntity = _ce} -> cr - CRAgentRcvQueueDeleted {agentConnId = _acId, server = _srv, agentQueueId = _aqId, agentError_ = _ae} -> cr - CRAgentConnDeleted {agentConnId = _acId} -> cr - CRAgentUserDeleted {agentUserId = _auId} -> cr - CRMessageError {user, severity, errorMessage} -> CRMessageError {user = enrichUser user, severity, errorMessage} - CRChatCmdError {user_, chatError} -> CRChatCmdError {user_ = enrichUser <$> user_, chatError} - CRChatError {user_, chatError} -> CRChatError {user_ = enrichUser <$> user_, chatError} - CRArchiveImported {archiveErrors = _ae} -> cr - CRTimedAction {action = _a, durationMilliseconds = _dm} -> cr - where - enrichUser :: User -> User - enrichUser u = u {remoteHostId = Just rhId} - enrichUserInfo :: UserInfo -> UserInfo - enrichUserInfo uInfo@UserInfo {user} = uInfo {user = enrichUser user} - enrichAChat :: AChat -> AChat - enrichAChat (AChat cType chat) = AChat cType chat {remoteHostId = Just rhId} - enrichServerCfg :: ServerCfg p -> ServerCfg p - enrichServerCfg cfg = cfg {remoteHostId = Just rhId} - enrichAUserProtoServers :: AUserProtoServers -> AUserProtoServers - enrichAUserProtoServers (AUPS ups@UserProtoServers {protoServers}) = AUPS ups {protoServers = fmap enrichServerCfg protoServers} - enrichRcvCallInvitation :: RcvCallInvitation -> RcvCallInvitation - enrichRcvCallInvitation rci = rci {remoteHostId = Just rhId} - - remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath remoteStoreFile c localPath fileName = do (fileSize, fileDigest) <- getFileInfo localPath @@ -358,7 +140,7 @@ sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do - encFile_ <- mapM (prepareEncryptedFile encryption) file_ + encFile_ <- mapM (prepareEncryptedFile encryption) file_ req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd) HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing (header, getNext) <- parseDecryptHTTP2Body encryption response respBody diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 12b5862813..783a083e55 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -19,7 +19,7 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Remote.AppVersion -import Simplex.Chat.Types (verificationCode, RemoteHostId) +import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) @@ -118,6 +118,8 @@ data RemoteProtocolError | RPEException {someException :: Text} deriving (Show, Exception) +type RemoteHostId = Int64 + data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} deriving (Eq, Ord, Show) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 859ce638e0..b6d455fe86 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -572,7 +572,7 @@ getDirectChatPreviews_ db user@User {userId} = do let contact = toContact user $ contactRow :. connRow ci_ = toDirectChatItemList currentTs ciRow_ stats = toChatStats statsRow - in AChat SCTDirect $ Chat Nothing (DirectChat contact) ci_ stats + in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do @@ -645,7 +645,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do let groupInfo = toGroupInfo userContactId groupInfoRow ci_ = toGroupChatItemList currentTs userContactId ciRow_ stats = toChatStats statsRow - in AChat SCTGroup $ Chat Nothing (GroupChat groupInfo) ci_ stats + in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats getContactRequestChatPreviews_ :: DB.Connection -> User -> IO [AChat] getContactRequestChatPreviews_ db User {userId} = @@ -669,7 +669,7 @@ getContactRequestChatPreviews_ db User {userId} = toContactRequestChatPreview cReqRow = let cReq = toContactRequest cReqRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactRequest $ Chat Nothing (ContactRequest cReq) [] stats + in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats getContactConnectionChatPreviews_ :: DB.Connection -> User -> Bool -> IO [AChat] getContactConnectionChatPreviews_ _ _ False = pure [] @@ -688,7 +688,7 @@ getContactConnectionChatPreviews_ db User {userId} _ = toContactConnectionChatPreview connRow = let conn = toPendingContactConnection connRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactConnection $ Chat Nothing (ContactConnection conn) [] stats + in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChat db user contactId pagination search_ = do @@ -703,7 +703,7 @@ getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> Excep getDirectChatLast_ db user ct@Contact {contactId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- getDirectChatItemsLast db user contactId count search - pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats + pure $ Chat (DirectChat ct) (reverse chatItems) stats -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect] @@ -733,7 +733,7 @@ getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsAfter_ - pure $ Chat Nothing (DirectChat ct) chatItems stats + pure $ Chat (DirectChat ct) chatItems stats where getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do @@ -763,7 +763,7 @@ getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsBefore_ - pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats + pure $ Chat (DirectChat ct) (reverse chatItems) stats where getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do @@ -803,7 +803,7 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats + pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] getGroupChatItemIdsLast_ = @@ -841,7 +841,7 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat Nothing (GroupChat g) chatItems stats + pure $ Chat (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsAfter_ afterChatItemTs = @@ -864,7 +864,7 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats + pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsBefore_ beforeChatItemTs = diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 010bcf2bc3..611faf90c6 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -504,7 +504,7 @@ getProtocolServers db User {userId} = toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in ServerCfg {remoteHostId = Nothing, server, preset, tested, enabled} + in ServerCfg {server, preset, tested, enabled} overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () overwriteProtocolServers db User {userId} servers = @@ -555,7 +555,7 @@ getCalls db = |] where toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call - toCall (contactId, callId, chatItemId, callState, callTs) = Call {remoteHostId = Nothing, contactId, callId, chatItemId, callState, callTs} + toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs} createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId createCommand db User {userId} connId commandFunction = do diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 4127ebce4b..ec84860379 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -18,7 +18,6 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.RemoteControl.Types import UnliftIO -import Simplex.Chat.Types (RemoteHostId) insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 78807fc038..af8220d8ea 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -316,7 +316,7 @@ userQuery = toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString) -> User toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_)) = - User {remoteHostId = Nothing, userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} where profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""} fullPreferences = mergePreferences Nothing userPreferences diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 629054b474..98d4285a2e 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -26,6 +26,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) +import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) import Simplex.Chat.Types diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 536384f796..d96bddb8b7 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -100,14 +100,11 @@ instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f instance ToField AgentUserId where toField (AgentUserId uId) = toField uId -type RemoteHostId = Int64 - aUserId :: User -> UserId aUserId User {agentUserId = AgentUserId uId} = uId data User = User - { remoteHostId :: Maybe RemoteHostId, - userId :: UserId, + { userId :: UserId, agentUserId :: AgentUserId, userContactId :: ContactId, localDisplayName :: ContactName, @@ -1524,8 +1521,7 @@ data XGrpMemIntroCont = XGrpMemIntroCont deriving (Show) data ServerCfg p = ServerCfg - { remoteHostId :: Maybe RemoteHostId, - server :: ProtoServerWithAuth p, + { server :: ProtoServerWithAuth p, preset :: Bool, tested :: Maybe Bool, enabled :: Bool diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 80ba9e5349..119d19e620 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -387,10 +387,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe testViewChats chats = [sShow $ map toChatView chats] where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) - toChatView (AChat _ (Chat _ (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) - toChatView (AChat _ (Chat _ (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) - toChatView (AChat _ (Chat _ (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) - toChatView (AChat _ (Chat _ (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) + toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) + toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) + toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) + toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) toCIPreview :: [CChatItem c] -> Maybe GroupMember -> Text toCIPreview (ci : _) membership_ = testViewItem ci membership_ toCIPreview _ _ = "" @@ -496,7 +496,7 @@ viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack viewChats :: CurrentTime -> TimeZone -> [AChat] -> [StyledString] viewChats ts tz = concatMap chatPreview . reverse where - chatPreview (AChat _ (Chat _ chat items _)) = case items of + chatPreview (AChat _ (Chat chat items _)) = case items of CChatItem _ ci : _ -> case viewChatItem chat ci True ts tz of s : _ -> [let s' = sTake 120 s in if sLength s' < sLength s then s' <> "..." else s'] _ -> chatName From f6b786a187f52a44b70cb70e8ad6a5c26d38277e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 20:31:35 +0400 Subject: [PATCH 107/174] get user index by remote host id --- .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 179a4e846a..660e7de151 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 @@ -122,7 +122,7 @@ object ChatModel { } private fun getUserIndex(user: User): Int = - users.indexOfFirst { it.user.userId == user.userId } + users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == user.remoteHostId } fun updateUser(user: User) { val i = getUserIndex(user) From 7f5efd8927c4ca1d4d5b8bd63aa6a5a347486b01 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 20:56:05 +0000 Subject: [PATCH 108/174] desktop: use correct remote host when creating/connecting via links (#3409) --- .../views/newchat/ConnectViaLinkView.android.kt | 7 ++++--- .../views/newchat/ScanToConnectView.android.kt | 5 +++-- .../simplex/common/views/chatlist/ChatListView.kt | 2 +- .../simplex/common/views/newchat/AddContactView.kt | 12 ++++++------ .../simplex/common/views/newchat/AddGroupView.kt | 3 ++- .../common/views/newchat/ConnectViaLinkView.kt | 3 ++- .../simplex/common/views/newchat/CreateLinkView.kt | 11 +++++------ .../simplex/common/views/newchat/NewChatSheet.kt | 8 ++++---- .../simplex/common/views/newchat/PasteToConnect.kt | 13 ++++++------- .../common/views/newchat/ScanToConnectView.kt | 8 ++++---- .../views/newchat/ConnectViaLinkView.desktop.kt | 5 +++-- .../views/newchat/ScanToConnectView.desktop.kt | 5 +++-- 12 files changed, 43 insertions(+), 39 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt index 1faf115b37..e5a7ae40a5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt @@ -9,10 +9,11 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo import chat.simplex.res.MR @Composable -actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { // TODO this should close if remote host changes in model val selection = remember { mutableStateOf( @@ -32,10 +33,10 @@ actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { Column(Modifier.weight(1f)) { when (selection.value) { ConnectViaLinkTab.SCAN -> { - ScanToConnectView(m, rhId, close) + ScanToConnectView(m, rh, close) } ConnectViaLinkTab.PASTE -> { - PasteToConnectView(m, rhId, close) + PasteToConnectView(m, rh, close) } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt index 89477e45a1..f046f44bee 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt @@ -4,17 +4,18 @@ import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } ConnectContactLayout( chatModel = chatModel, - rhId = rhId, + rh = rh, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) 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 1644d286f5..f9502bf908 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 @@ -118,7 +118,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf if (searchInList.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) // TODO disable this button and sheet for the duration of the switch - NewChatSheet(chatModel, chatModel.remoteHostId, newChatSheetState, stopped, hideNewChatSheet) + NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index 360667fcfb..a539bf4984 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -25,13 +25,13 @@ import chat.simplex.res.MR @Composable fun AddContactView( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, connReqInvitation: String, contactConnection: MutableState ) { val clipboard = LocalClipboardManager.current AddContactLayout( - rhId = rhId, + rh = rh, chatModel = chatModel, incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, @@ -54,7 +54,7 @@ fun AddContactView( @Composable fun AddContactLayout( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, @@ -66,9 +66,9 @@ fun AddContactLayout( withApi { val contactConnVal = contactConnection.value if (contactConnVal != null) { - chatModel.controller.apiSetConnectionIncognito(rhId, contactConnVal.pccConnId, incognito.value)?.let { + chatModel.controller.apiSetConnectionIncognito(rh?.remoteHostId, contactConnVal.pccConnId, incognito.value)?.let { contactConnection.value = it - chatModel.updateContactConnection(rhId, it) + chatModel.updateContactConnection(rh?.remoteHostId, it) } } } @@ -175,7 +175,7 @@ fun sharedProfileInfo( fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( - rhId = null, + rh = null, chatModel = ChatModel, incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index d60ee75311..aa5494e5a9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -32,7 +32,8 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun AddGroupView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { + val rhId = rh?.remoteHostId AddGroupLayout( createGroup = { incognito, groupProfile -> withApi { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt index e41c8701e3..0077e2849c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt @@ -2,10 +2,11 @@ package chat.simplex.common.views.newchat import androidx.compose.runtime.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo enum class ConnectViaLinkTab { SCAN, PASTE } @Composable -expect fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) +expect fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index f4252f53b0..0a23ed743d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -9,8 +9,7 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.PendingContactConnection +import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.res.MR @@ -20,7 +19,7 @@ enum class CreateLinkTab { } @Composable -fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { +fun CreateLinkView(m: ChatModel, rh: RemoteHostInfo?, initialSelection: CreateLinkTab) { val selection = remember { mutableStateOf(initialSelection) } val connReqInvitation = rememberSaveable { m.connReqInv } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } @@ -32,7 +31,7 @@ fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { && contactConnection.value == null && !creatingConnReq.value ) { - createInvitation(m, rhId, creatingConnReq, connReqInvitation, contactConnection) + createInvitation(m, rh?.remoteHostId, creatingConnReq, connReqInvitation, contactConnection) } } /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. @@ -65,10 +64,10 @@ fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { Column(Modifier.weight(1f)) { when (selection.value) { CreateLinkTab.ONE_TIME -> { - AddContactView(m, rhId,connReqInvitation.value ?: "", contactConnection) + AddContactView(m, rh,connReqInvitation.value ?: "", contactConnection) } CreateLinkTab.LONG_TERM -> { - UserAddressView(m, rhId, viaCreateLinkView = true, close = {}) + UserAddressView(m, rh?.remoteHostId, viaCreateLinkView = true, close = {}) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 382bc72e4f..86929584c1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, rhId: Long?, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { // TODO close new chat if remote host changes in model if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( @@ -42,17 +42,17 @@ fun NewChatSheet(chatModel: ChatModel, rhId: Long?, newChatSheetState: StateFlow addContact = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showModal { CreateLinkView(chatModel, rhId, CreateLinkTab.ONE_TIME) } + ModalManager.center.showModal { CreateLinkView(chatModel, chatModel.currentRemoteHost.value, CreateLinkTab.ONE_TIME) } }, connectViaLink = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, rhId, close) } + ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, chatModel.currentRemoteHost.value, close) } }, createGroup = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, rhId, close) } + ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) } }, closeNewChatSheet, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index d40fa97624..3e4447d355 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -14,8 +14,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.SharedPreference +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.IncognitoView @@ -24,12 +23,12 @@ import chat.simplex.res.MR import java.net.URI @Composable -fun PasteToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +fun PasteToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { val connectionLink = remember { mutableStateOf("") } val clipboard = LocalClipboardManager.current PasteToConnectLayout( chatModel = chatModel, - rhId = rhId, + rh = rh, incognitoPref = chatModel.controller.appPrefs.incognito, connectionLink = connectionLink, pasteFromClipboard = { @@ -42,14 +41,14 @@ fun PasteToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { @Composable fun PasteToConnectLayout( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, incognitoPref: SharedPreference, connectionLink: MutableState, pasteFromClipboard: () -> Unit, close: () -> Unit ) { val incognito = remember { mutableStateOf(incognitoPref.get()) } - + val rhId = rh?.remoteHostId fun connectViaLink(connReqUri: String) { try { val uri = URI(connReqUri) @@ -126,7 +125,7 @@ fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( chatModel = ChatModel, - rhId = null, + rh = null, incognitoPref = SharedPreference({ false }, {}), connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 523b7e5327..bd111c9c32 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -26,7 +26,7 @@ import chat.simplex.res.MR import java.net.URI @Composable -expect fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) +expect fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -428,7 +428,7 @@ fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, grou @Composable fun ConnectContactLayout( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, incognitoPref: SharedPreference, close: () -> Unit ) { @@ -440,7 +440,7 @@ fun ConnectContactLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rh?.remoteHostId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -492,7 +492,7 @@ fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( chatModel = ChatModel, - rhId = null, + rh = null, incognitoPref = SharedPreference({ false }, {}), close = {}, ) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt index 6d56a7b515..72d9678154 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt @@ -2,9 +2,10 @@ package chat.simplex.common.views.newchat import androidx.compose.runtime.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo @Composable -actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { // TODO this should close if remote host changes in model - PasteToConnectView(m, rhId, close) + PasteToConnectView(m, rh, close) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt index 540de40a95..7579f09fa5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt @@ -2,12 +2,13 @@ package chat.simplex.common.views.newchat import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo @Composable -actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { ConnectContactLayout( chatModel = chatModel, - rhId = rhId, + rh = rh, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) From 121985138aacd4e09d6e1ae898ce95940d8619d6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 22:24:19 +0000 Subject: [PATCH 109/174] ios: export localizations --- .../bg.xcloc/Localized Contents/bg.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../cs.xcloc/Localized Contents/cs.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../de.xcloc/Localized Contents/de.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../en.xcloc/Localized Contents/en.xliff | 150 ++++++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../es.xcloc/Localized Contents/es.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../fi.xcloc/Localized Contents/fi.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../fr.xcloc/Localized Contents/fr.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../it.xcloc/Localized Contents/it.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../ja.xcloc/Localized Contents/ja.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../nl.xcloc/Localized Contents/nl.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../pl.xcloc/Localized Contents/pl.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../ru.xcloc/Localized Contents/ru.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../th.xcloc/Localized Contents/th.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../uk.xcloc/Localized Contents/uk.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../Localized Contents/zh-Hans.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + 30 files changed, 1860 insertions(+) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 77133907fb..19e311468a 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Назад No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Лошо ID на съобщението @@ -1151,6 +1163,10 @@ Свързване инкогнито No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Свързване със сървъра… @@ -1198,6 +1222,10 @@ This is your own one-time link! Свързване със сървър…(грешка: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Връзка @@ -1218,6 +1246,10 @@ This is your own one-time link! Заявката за връзка е изпратена! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Времето на изчакване за установяване на връзката изтече @@ -1701,6 +1733,18 @@ This cannot be undone! Описание No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Разработване @@ -1791,11 +1835,19 @@ This cannot be undone! Прекъсни връзката server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Открийте и се присъединете към групи No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. НЕ използвайте SimpleX за спешни повиквания. @@ -2003,6 +2055,10 @@ This cannot be undone! Въведи сървъра ръчно No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Въведи съобщение при посрещане… @@ -2773,6 +2829,10 @@ This cannot be undone! Несъвместима версия на базата данни No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Неправилен kод за достъп @@ -2938,6 +2998,10 @@ This is your link for group %@! Присъединяване към групата No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Запазете връзките си @@ -2998,6 +3062,14 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Съобщение на живо! @@ -3565,6 +3637,10 @@ This is your link for group %@! Постави No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Постави изображение @@ -4156,6 +4232,10 @@ This is your link for group %@! Сканирай QR код No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Сканирай код @@ -4376,6 +4456,10 @@ This is your link for group %@! Сървъри No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Задай 1 ден @@ -4872,6 +4956,10 @@ It can happen because of some bug or when the connection is compromised.Това действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Тази група има над %lld членове, потвърждения за доставка не се изпращат. @@ -5061,6 +5149,14 @@ To connect, please ask your contact to create another connection link and check За да се свържете, моля, помолете вашия контакт да създаде друг линк за връзка и проверете дали имате стабилна мрежова връзка. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Отключи @@ -5151,6 +5247,10 @@ To connect, please ask your contact to create another connection link and check Използвай за нови връзки No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Използвай интерфейса за повикване на iOS @@ -5181,11 +5281,23 @@ To connect, please ask your contact to create another connection link and check Използват се сървърите на SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Потвръди сигурността на връзката No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Потвръди кода за сигурност @@ -6356,6 +6468,10 @@ SimpleX сървърите не могат да видят вашия профи актуализиран профил на групата rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6493,6 +6609,10 @@ SimpleX сървърите не могат да видят вашия профи SimpleX използва Face ID за локалнa идентификация Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX се нуждае от достъп до микрофона за аудио и видео разговори и за запис на гласови съобщения. diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index fe78e2da82..0286c8c684 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Zpět No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Špatné ID zprávy @@ -1151,6 +1163,10 @@ Spojit se inkognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Připojování k serveru… @@ -1198,6 +1222,10 @@ This is your own one-time link! Připojování k serveru... (chyba: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Připojení @@ -1218,6 +1246,10 @@ This is your own one-time link! Požadavek na připojení byl odeslán! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Časový limit připojení @@ -1701,6 +1733,18 @@ This cannot be undone! Popis No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Vyvinout @@ -1791,11 +1835,19 @@ This cannot be undone! Odpojit server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Objevte a připojte skupiny No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NEpoužívejte SimpleX pro tísňová volání. @@ -2003,6 +2055,10 @@ This cannot be undone! Zadejte server ručně No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Zadat uvítací zprávu… @@ -2773,6 +2829,10 @@ This cannot be undone! Nekompatibilní verze databáze No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Nesprávné heslo @@ -2938,6 +2998,10 @@ This is your link for group %@! Připojování ke skupině No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Zachovat vaše připojení @@ -2998,6 +3062,14 @@ This is your link for group %@! Omezení No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Živé zprávy! @@ -3565,6 +3637,10 @@ This is your link for group %@! Vložit No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Vložit obrázek @@ -4156,6 +4232,10 @@ This is your link for group %@! Skenovat QR kód No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Skenovat kód @@ -4376,6 +4456,10 @@ This is your link for group %@! Servery No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Nastavit 1 den @@ -4872,6 +4956,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Tuto akci nelze vzít zpět - váš profil, kontakty, zprávy a soubory budou nenávratně ztraceny. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Tato skupina má více než %lld členů, potvrzení o doručení nejsou odesílány. @@ -5061,6 +5149,14 @@ To connect, please ask your contact to create another connection link and check Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu na připojení a zkontrolujte, zda máte stabilní připojení k síti. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Odemknout @@ -5151,6 +5247,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Použít pro nová připojení No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Použít rozhraní volání iOS @@ -5181,11 +5281,23 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Používat servery SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Ověření zabezpečení připojení No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Ověření bezpečnostního kódu @@ -6355,6 +6467,10 @@ Servery SimpleX nevidí váš profil. aktualizoval profil skupiny rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6492,6 +6608,10 @@ Servery SimpleX nevidí váš profil. SimpleX používá Face ID pro místní ověřování Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX potřebuje přístup k mikrofonu pro audio a video hovory a pro nahrávání hlasových zpráv. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 259d7c12f2..a69ae55fd3 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Zurück No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Falsche Nachrichten-ID @@ -1165,6 +1177,10 @@ Inkognito verbinden No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Mit Ihnen selbst verbinden? @@ -1209,6 +1225,14 @@ Das ist Ihr eigener Einmal-Link! Mit %@ verbinden No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Mit dem Server verbinden… @@ -1219,6 +1243,10 @@ Das ist Ihr eigener Einmal-Link! Mit dem Server verbinden… (Fehler: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Verbindung @@ -1239,6 +1267,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungsanfrage wurde gesendet! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Verbindungszeitüberschreitung @@ -1729,6 +1761,18 @@ Das kann nicht rückgängig gemacht werden! Beschreibung No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Entwicklung @@ -1819,11 +1863,19 @@ Das kann nicht rückgängig gemacht werden! Trennen server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Gruppen entdecken und ihnen beitreten No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Nutzen Sie SimpleX nicht für Notrufe. @@ -2032,6 +2084,10 @@ Das kann nicht rückgängig gemacht werden! Geben Sie den Server manuell ein No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Geben Sie eine Begrüßungsmeldung ein … @@ -2807,6 +2863,10 @@ Das kann nicht rückgängig gemacht werden! Inkompatible Datenbank-Version No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Zugangscode ist falsch @@ -2977,6 +3037,10 @@ Das ist Ihr Link für die Gruppe %@! Der Gruppe beitreten No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Ihre Verbindungen beibehalten @@ -3037,6 +3101,14 @@ Das ist Ihr Link für die Gruppe %@! Einschränkungen No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Live Nachricht! @@ -3606,6 +3678,10 @@ Das ist Ihr Link für die Gruppe %@! Einfügen No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Bild einfügen @@ -4201,6 +4277,10 @@ Das ist Ihr Link für die Gruppe %@! QR-Code scannen No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Code scannen @@ -4421,6 +4501,10 @@ Das ist Ihr Link für die Gruppe %@! Server No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Einen Tag festlegen @@ -4918,6 +5002,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Es werden keine Empfangsbestätigungen gesendet, da diese Gruppe über %lld Mitglieder hat. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um sich neu verbinden zu können und stellen Sie sicher, dass Sie eine stabile Netzwerk-Verbindung haben. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Entsperren @@ -5202,6 +5298,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Für neue Verbindungen nutzen No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface iOS Anrufschnittstelle nutzen @@ -5232,11 +5332,23 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verwendung von SimpleX-Chat-Servern. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Sicherheit der Verbindung überprüfen No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Sicherheitscode überprüfen @@ -6424,6 +6536,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Aktualisiertes Gruppenprofil rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Face ID wird von SimpleX für die lokale Authentifizierung genutzt Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX benötigt Zugriff auf das Mikrofon, um Audio- und Videoanrufe und die Aufnahme von Sprachnachrichten zu ermöglichen. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index aa128d59d9..dd302cd1e1 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -297,6 +297,16 @@ ( No comment provided by engineer. + + (new) + (new) + No comment provided by engineer. + + + (this device v%@) + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +879,11 @@ Back No comment provided by engineer. + + Bad desktop address + Bad desktop address + No comment provided by engineer. + Bad message ID Bad message ID @@ -1165,6 +1180,11 @@ Connect incognito No comment provided by engineer. + + Connect to desktop + Connect to desktop + No comment provided by engineer. + Connect to yourself? Connect to yourself? @@ -1209,6 +1229,16 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + Connected desktop + No comment provided by engineer. + + + Connected to desktop + Connected to desktop + No comment provided by engineer. + Connecting to server… Connecting to server… @@ -1219,6 +1249,11 @@ This is your own one-time link! Connecting to server… (error: %@) No comment provided by engineer. + + Connecting to desktop + Connecting to desktop + No comment provided by engineer. + Connection Connection @@ -1239,6 +1274,11 @@ This is your own one-time link! Connection request sent! No comment provided by engineer. + + Connection terminated + Connection terminated + No comment provided by engineer. + Connection timeout Connection timeout @@ -1729,6 +1769,21 @@ This cannot be undone! Description No comment provided by engineer. + + Desktop address + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + Desktop devices + No comment provided by engineer. + Develop Develop @@ -1819,11 +1874,21 @@ This cannot be undone! Disconnect server test step + + Disconnect desktop? + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Discover and join groups No comment provided by engineer. + + Discover on network + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Do NOT use SimpleX for emergency calls. @@ -2034,6 +2099,11 @@ This cannot be undone! Enter server manually No comment provided by engineer. + + Enter this device name… + Enter this device name… + No comment provided by engineer. + Enter welcome message… Enter welcome message… @@ -2809,6 +2879,11 @@ This cannot be undone! Incompatible database version No comment provided by engineer. + + Incompatible version + Incompatible version + No comment provided by engineer. + Incorrect passcode Incorrect passcode @@ -2979,6 +3054,11 @@ This is your link for group %@! Joining group No comment provided by engineer. + + Keep the app open to use it from desktop + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Keep your connections @@ -3039,6 +3119,16 @@ This is your link for group %@! Limitations No comment provided by engineer. + + Linked desktop options + Linked desktop options + No comment provided by engineer. + + + Linked desktops + Linked desktops + No comment provided by engineer. + Live message! Live message! @@ -3608,6 +3698,11 @@ This is your link for group %@! Paste No comment provided by engineer. + + Paste desktop address + Paste desktop address + No comment provided by engineer. + Paste image Paste image @@ -4203,6 +4298,11 @@ This is your link for group %@! Scan QR code No comment provided by engineer. + + Scan QR code from desktop + Scan QR code from desktop + No comment provided by engineer. + Scan code Scan code @@ -4423,6 +4523,11 @@ This is your link for group %@! Servers No comment provided by engineer. + + Session code + Session code + No comment provided by engineer. + Set 1 day Set 1 day @@ -4920,6 +5025,11 @@ It can happen because of some bug or when the connection is compromised.This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. No comment provided by engineer. + + This device name + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. This group has over %lld members, delivery receipts are not sent. @@ -5114,6 +5224,16 @@ To connect, please ask your contact to create another connection link and check To connect, please ask your contact to create another connection link and check that you have a stable network connection. No comment provided by engineer. + + Unlink + Unlink + No comment provided by engineer. + + + Unlink desktop? + Unlink desktop? + No comment provided by engineer. + Unlock Unlock @@ -5204,6 +5324,11 @@ To connect, please ask your contact to create another connection link and check Use for new connections No comment provided by engineer. + + Use from desktop + Use from desktop + No comment provided by engineer. + Use iOS call interface Use iOS call interface @@ -5234,11 +5359,26 @@ To connect, please ask your contact to create another connection link and check Using SimpleX Chat servers. No comment provided by engineer. + + Verify code with desktop + Verify code with desktop + No comment provided by engineer. + + + Verify connection + Verify connection + No comment provided by engineer. + Verify connection security Verify connection security No comment provided by engineer. + + Verify connections + Verify connections + No comment provided by engineer. + Verify security code Verify security code @@ -6426,6 +6566,11 @@ SimpleX servers cannot see your profile. updated group profile rcv group event chat item + + v%@ + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6563,6 +6708,11 @@ SimpleX servers cannot see your profile. SimpleX uses Face ID for local authentication Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX needs microphone access for audio and video calls, and to record voice messages. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 4a76e3ddb4..e913d78d2d 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Volver No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID ID de mensaje incorrecto @@ -1151,6 +1163,10 @@ Conectar incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Conectando con el servidor… @@ -1198,6 +1222,10 @@ This is your own one-time link! Conectando con el servidor... (error: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Conexión @@ -1218,6 +1246,10 @@ This is your own one-time link! ¡Solicitud de conexión enviada! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Tiempo de conexión expirado @@ -1701,6 +1733,18 @@ This cannot be undone! Descripción No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Desarrollo @@ -1791,11 +1835,19 @@ This cannot be undone! Desconectar server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Descubre y únete a grupos No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NO uses SimpleX para llamadas de emergencia. @@ -2003,6 +2055,10 @@ This cannot be undone! Introduce el servidor manualmente No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Introduce mensaje de bienvenida… @@ -2773,6 +2829,10 @@ This cannot be undone! Versión de base de datos incompatible No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Código de acceso incorrecto @@ -2938,6 +2998,10 @@ This is your link for group %@! Entrando al grupo No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Conserva tus conexiones @@ -2998,6 +3062,14 @@ This is your link for group %@! Limitaciones No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! ¡Mensaje en vivo! @@ -3565,6 +3637,10 @@ This is your link for group %@! Pegar No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Pegar imagen @@ -4156,6 +4232,10 @@ This is your link for group %@! Escanear código QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Escanear código @@ -4376,6 +4456,10 @@ This is your link for group %@! Servidores No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Establecer 1 día @@ -4872,6 +4956,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Esta acción no se puede deshacer. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Este grupo tiene más de %lld miembros, no se enviarán confirmaciones de entrega. @@ -5062,6 +5150,14 @@ que este enlace ya se haya usado, podría ser un error. Por favor, notifícalo. Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueba que tienes buena conexión de red. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Desbloquear @@ -5152,6 +5248,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Usar para conexiones nuevas No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Usar interfaz de llamada de iOS @@ -5182,11 +5282,23 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Usar servidores SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Comprobar la seguridad de la conexión No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Comprobar código de seguridad @@ -6357,6 +6469,10 @@ Los servidores de SimpleX no pueden ver tu perfil. ha actualizado el perfil del grupo rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6494,6 +6610,10 @@ Los servidores de SimpleX no pueden ver tu perfil. SimpleX usa reconocimiento facial para la autenticación local Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX necesita acceso al micrófono para las llamadas de audio, vídeo y para grabar mensajes de voz. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 2661dc10a1..694b81bcd7 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -854,6 +862,10 @@ Takaisin No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Virheellinen viestin tunniste @@ -1146,6 +1158,10 @@ Yhdistä Incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1183,6 +1199,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Yhteyden muodostaminen palvelimeen… @@ -1193,6 +1217,10 @@ This is your own one-time link! Yhteyden muodostaminen palvelimeen... (virhe: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Yhteys @@ -1213,6 +1241,10 @@ This is your own one-time link! Yhteyspyyntö lähetetty! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Yhteyden aikakatkaisu @@ -1696,6 +1728,18 @@ This cannot be undone! Kuvaus No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Kehitä @@ -1786,11 +1830,19 @@ This cannot be undone! Katkaise server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Löydä ryhmiä ja liity niihin No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Älä käytä SimpleX-sovellusta hätäpuheluihin. @@ -1997,6 +2049,10 @@ This cannot be undone! Syötä palvelin manuaalisesti No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Kirjoita tervetuloviesti… @@ -2765,6 +2821,10 @@ This cannot be undone! Yhteensopimaton tietokantaversio No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Väärä pääsykoodi @@ -2930,6 +2990,10 @@ This is your link for group %@! Liittyy ryhmään No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Pidä kontaktisi @@ -2990,6 +3054,14 @@ This is your link for group %@! Rajoitukset No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Live-viesti! @@ -3555,6 +3627,10 @@ This is your link for group %@! Liitä No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Liitä kuva @@ -4146,6 +4222,10 @@ This is your link for group %@! Skannaa QR-koodi No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Skannaa koodi @@ -4365,6 +4445,10 @@ This is your link for group %@! Palvelimet No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Aseta 1 päivä @@ -4860,6 +4944,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.
Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Tässä ryhmässä on yli %lld jäsentä, lähetyskuittauksia ei lähetetä. @@ -5048,6 +5136,14 @@ To connect, please ask your contact to create another connection link and check Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja tarkista, että verkkoyhteytesi on vakaa. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Avaa @@ -5138,6 +5234,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Käytä uusiin yhteyksiin No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Käytä iOS:n puhelujen käyttöliittymää @@ -5168,11 +5268,23 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Käyttää SimpleX Chat -palvelimia. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Tarkista yhteyden suojaus No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Tarkista turvakoodi @@ -6341,6 +6453,10 @@ SimpleX-palvelimet eivät näe profiiliasi. päivitetty ryhmäprofiili rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6478,6 +6594,10 @@ SimpleX-palvelimet eivät näe profiiliasi. SimpleX käyttää Face ID:tä paikalliseen todennukseen Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX tarvitsee mikrofonia ääni- ja videopuheluita ja ääniviestien tallentamista varten. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 6a5f397557..be028fcbb4 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Retour No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Mauvais ID de message @@ -1165,6 +1177,10 @@ Se connecter incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Se connecter à soi-même ? @@ -1209,6 +1225,14 @@ Il s'agit de votre propre lien unique ! Se connecter avec %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Connexion au serveur… @@ -1219,6 +1243,10 @@ Il s'agit de votre propre lien unique ! Connexion au serveur… (erreur : %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Connexion @@ -1239,6 +1267,10 @@ Il s'agit de votre propre lien unique ! Demande de connexion envoyée ! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Délai de connexion @@ -1729,6 +1761,18 @@ Cette opération ne peut être annulée ! Description No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Développer @@ -1819,11 +1863,19 @@ Cette opération ne peut être annulée ! Se déconnecter server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Découvrir et rejoindre des groupes No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. N'utilisez PAS SimpleX pour les appels d'urgence. @@ -2032,6 +2084,10 @@ Cette opération ne peut être annulée ! Entrer un serveur manuellement No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Entrez un message de bienvenue… @@ -2807,6 +2863,10 @@ Cette opération ne peut être annulée ! Version de la base de données incompatible No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Code d'accès erroné @@ -2977,6 +3037,10 @@ Voici votre lien pour le groupe %@ ! Entrain de rejoindre le groupe No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Conserver vos connexions @@ -3037,6 +3101,14 @@ Voici votre lien pour le groupe %@ ! Limitations No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Message dynamique ! @@ -3606,6 +3678,10 @@ Voici votre lien pour le groupe %@ ! Coller No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Coller l'image @@ -4201,6 +4277,10 @@ Voici votre lien pour le groupe %@ ! Scanner un code QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Scanner le code @@ -4421,6 +4501,10 @@ Voici votre lien pour le groupe %@ ! Serveurs No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Définir 1 jour @@ -4918,6 +5002,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Ce groupe compte plus de %lld membres, les accusés de réception ne sont pas envoyés. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Pour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d'une connexion réseau stable. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Déverrouiller @@ -5202,6 +5298,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser pour les nouvelles connexions No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Utiliser l'interface d'appel d'iOS @@ -5232,11 +5332,23 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utilisation des serveurs SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Vérifier la sécurité de la connexion No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Vérifier le code de sécurité @@ -6424,6 +6536,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. mise à jour du profil de groupe rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. SimpleGroup not found!X utilise Face ID pour l'authentification locale Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX a besoin d'un accès au microphone pour les appels audio et vidéo ainsi que pour enregistrer des messages vocaux. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 72cda836ac..9e38f14e6e 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Indietro No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID ID del messaggio errato @@ -1165,6 +1177,10 @@ Connetti in incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Connettersi a te stesso? @@ -1209,6 +1225,14 @@ Questo è il tuo link una tantum! Connettersi con %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Connessione al server… @@ -1219,6 +1243,10 @@ Questo è il tuo link una tantum! Connessione al server… (errore: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Connessione @@ -1239,6 +1267,10 @@ Questo è il tuo link una tantum! Richiesta di connessione inviata! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Connessione scaduta @@ -1729,6 +1761,18 @@ Non è reversibile! Descrizione No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Sviluppa @@ -1819,11 +1863,19 @@ Non è reversibile! Disconnetti server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Scopri ed unisciti ai gruppi No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NON usare SimpleX per chiamate di emergenza. @@ -2032,6 +2084,10 @@ Non è reversibile! Inserisci il server a mano No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Inserisci il messaggio di benvenuto… @@ -2807,6 +2863,10 @@ Non è reversibile! Versione del database incompatibile No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Codice di accesso errato @@ -2977,6 +3037,10 @@ Questo è il tuo link per il gruppo %@! Ingresso nel gruppo No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Mantieni le tue connessioni @@ -3037,6 +3101,14 @@ Questo è il tuo link per il gruppo %@! Limitazioni No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Messaggio in diretta! @@ -3606,6 +3678,10 @@ Questo è il tuo link per il gruppo %@! Incolla No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Incolla immagine @@ -4201,6 +4277,10 @@ Questo è il tuo link per il gruppo %@! Scansiona codice QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Scansiona codice @@ -4421,6 +4501,10 @@ Questo è il tuo link per il gruppo %@! Server No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Imposta 1 giorno @@ -4918,6 +5002,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Questo gruppo ha più di %lld membri, le ricevute di consegna non vengono inviate. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Sblocca @@ -5202,6 +5298,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa per connessioni nuove No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Usa interfaccia di chiamata iOS @@ -5232,11 +5332,23 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Utilizzo dei server SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Verifica la sicurezza della connessione No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Verifica codice di sicurezza @@ -6424,6 +6536,10 @@ I server di SimpleX non possono vedere il tuo profilo. profilo del gruppo aggiornato rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ I server di SimpleX non possono vedere il tuo profilo. SimpleX usa Face ID per l'autenticazione locale Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX ha bisogno dell'accesso al microfono per le chiamate audio e video e per registrare messaggi vocali. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index b574faecb1..138f67fe2b 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -855,6 +863,10 @@ 戻る No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID メッセージ ID が正しくありません @@ -1148,6 +1160,10 @@ シークレットモードで接続 No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1185,6 +1201,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… サーバーに接続中… @@ -1195,6 +1219,10 @@ This is your own one-time link! サーバーに接続中… (エラー: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection 接続 @@ -1215,6 +1243,10 @@ This is your own one-time link! 接続リクエストを送信しました! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout 接続タイムアウト @@ -1698,6 +1730,18 @@ This cannot be undone! 説明 No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop 開発 @@ -1788,11 +1832,19 @@ This cannot be undone! 切断 server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups グループを見つけて参加する No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. 緊急通報にSimpleXを使用しないでください。 @@ -2000,6 +2052,10 @@ This cannot be undone! サーバを手動で入力 No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… ウェルカムメッセージを入力してください… @@ -2768,6 +2824,10 @@ This cannot be undone! データベースのバージョンと互換性がない No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode パスコードが正しくありません @@ -2933,6 +2993,10 @@ This is your link for group %@! グループに参加 No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections 接続を維持 @@ -2993,6 +3057,14 @@ This is your link for group %@! 制限事項 No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! ライブメッセージ! @@ -3559,6 +3631,10 @@ This is your link for group %@! 貼り付け No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image 画像の貼り付け @@ -4149,6 +4225,10 @@ This is your link for group %@! QRコードを読み込む No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code コードを読み込む @@ -4361,6 +4441,10 @@ This is your link for group %@! サーバ No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day 1日に設定 @@ -4857,6 +4941,10 @@ It can happen because of some bug or when the connection is compromised.あなたのプロフィール、連絡先、メッセージ、ファイルが完全削除されます (※元に戻せません※)。 No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. No comment provided by engineer. @@ -5044,6 +5132,14 @@ To connect, please ask your contact to create another connection link and check 接続するには、連絡先に別の接続リンクを作成するよう依頼し、ネットワーク接続が安定していることを確認してください。 No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock ロック解除 @@ -5134,6 +5230,10 @@ To connect, please ask your contact to create another connection link and check 新しい接続に使う No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface iOS通話インターフェースを使用する @@ -5164,11 +5264,23 @@ To connect, please ask your contact to create another connection link and check SimpleX チャット サーバーを使用する。 No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security 接続のセキュリティを確認 No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code セキュリティコードを確認 @@ -6337,6 +6449,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 グループプロフィールを更新しました rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6474,6 +6590,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 SimpleX はローカル認証に Face ID を使用します Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX では、音声通話やビデオ通話、および音声メッセージの録音のためにマイクへのアクセスが必要です。 diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 92cb85456f..696c7e99fc 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Terug No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Onjuiste bericht-ID @@ -1165,6 +1177,10 @@ Verbind incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Verbinding maken met jezelf? @@ -1209,6 +1225,14 @@ Dit is uw eigen eenmalige link! Verbonden met %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Verbinden met de server… @@ -1219,6 +1243,10 @@ Dit is uw eigen eenmalige link! Verbinden met server... (fout: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Verbinding @@ -1239,6 +1267,10 @@ Dit is uw eigen eenmalige link! Verbindingsverzoek verzonden! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Timeout verbinding @@ -1729,6 +1761,18 @@ Dit kan niet ongedaan gemaakt worden! Beschrijving No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Ontwikkelen @@ -1819,11 +1863,19 @@ Dit kan niet ongedaan gemaakt worden! verbinding verbreken server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Ontdek en sluit je aan bij groepen No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Gebruik SimpleX NIET voor noodoproepen. @@ -2032,6 +2084,10 @@ Dit kan niet ongedaan gemaakt worden! Voer de server handmatig in No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Welkomst bericht invoeren… @@ -2807,6 +2863,10 @@ Dit kan niet ongedaan gemaakt worden! Incompatibele database versie No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Onjuiste toegangscode @@ -2977,6 +3037,10 @@ Dit is jouw link voor groep %@! Deel nemen aan groep No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Behoud uw verbindingen @@ -3037,6 +3101,14 @@ Dit is jouw link voor groep %@! Beperkingen No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Live bericht! @@ -3606,6 +3678,10 @@ Dit is jouw link voor groep %@! Plakken No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Afbeelding plakken @@ -4201,6 +4277,10 @@ Dit is jouw link voor groep %@! Scan QR-code No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Code scannen @@ -4421,6 +4501,10 @@ Dit is jouw link voor groep %@! Servers No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Stel 1 dag in @@ -4918,6 +5002,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze actie kan niet ongedaan worden gemaakt. Uw profiel, contacten, berichten en bestanden gaan onomkeerbaar verloren. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Deze groep heeft meer dan %lld -leden, ontvangstbevestigingen worden niet verzonden. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Om verbinding te maken, vraagt u uw contact om een andere verbinding link te maken en te controleren of u een stabiele netwerkverbinding heeft. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Ontgrendelen @@ -5202,6 +5298,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik voor nieuwe verbindingen No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface De iOS-oproepinterface gebruiken @@ -5232,11 +5332,23 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak SimpleX Chat servers gebruiken. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Controleer de verbindingsbeveiliging No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Controleer de beveiligingscode @@ -6424,6 +6536,10 @@ SimpleX servers kunnen uw profiel niet zien. bijgewerkt groep profiel rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ SimpleX servers kunnen uw profiel niet zien. SimpleX gebruikt Face-ID voor lokale authenticatie Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX heeft microfoon toegang nodig voor audio en video oproepen en om spraak berichten op te nemen. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 5ec6d3ee36..58c3c85b0e 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Wstecz No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Zły identyfikator wiadomości @@ -1165,6 +1177,10 @@ Połącz incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Połączyć się ze sobą? @@ -1209,6 +1225,14 @@ To jest twój jednorazowy link! Połącz z %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Łączenie z serwerem… @@ -1219,6 +1243,10 @@ To jest twój jednorazowy link! Łączenie z serwerem... (błąd: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Połączenie @@ -1239,6 +1267,10 @@ To jest twój jednorazowy link! Prośba o połączenie wysłana! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Czas połączenia minął @@ -1729,6 +1761,18 @@ To nie może być cofnięte! Opis No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Deweloperskie @@ -1819,11 +1863,19 @@ To nie może być cofnięte! Rozłącz server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Odkrywaj i dołączaj do grup No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NIE używaj SimpleX do połączeń alarmowych. @@ -2032,6 +2084,10 @@ To nie może być cofnięte! Wprowadź serwer ręcznie No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Wpisz wiadomość powitalną… @@ -2807,6 +2863,10 @@ To nie może być cofnięte! Niekompatybilna wersja bazy danych No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Nieprawidłowy pin @@ -2977,6 +3037,10 @@ To jest twój link do grupy %@! Dołączanie do grupy No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Zachowaj swoje połączenia @@ -3037,6 +3101,14 @@ To jest twój link do grupy %@! Ograniczenia No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Wiadomość na żywo! @@ -3606,6 +3678,10 @@ To jest twój link do grupy %@! Wklej No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Wklej obraz @@ -4201,6 +4277,10 @@ To jest twój link do grupy %@! Zeskanuj kod QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Zeskanuj kod @@ -4421,6 +4501,10 @@ To jest twój link do grupy %@! Serwery No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Ustaw 1 dzień @@ -4918,6 +5002,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Ta grupa ma ponad %lld członków, potwierdzenia dostawy nie są wysyłane. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połączenia i sprawdź, czy masz stabilne połączenie z siecią. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Odblokuj @@ -5202,6 +5298,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Użyj dla nowych połączeń No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Użyj interfejsu połączeń iOS @@ -5232,11 +5332,23 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Używanie serwerów SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Weryfikuj bezpieczeństwo połączenia No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Weryfikuj kod bezpieczeństwa @@ -6424,6 +6536,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. zaktualizowano profil grupy rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. SimpleX używa Face ID do lokalnego uwierzytelniania Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX potrzebuje dostępu do mikrofonu, w celu połączeń audio i wideo oraz nagrywania wiadomości głosowych. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 402fdc9658..d544471c67 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Назад No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Ошибка ID сообщения @@ -1151,6 +1163,10 @@ Соединиться Инкогнито No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Устанавливается соединение с сервером… @@ -1198,6 +1222,10 @@ This is your own one-time link! Устанавливается соединение с сервером… (ошибка: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Соединение @@ -1218,6 +1246,10 @@ This is your own one-time link! Запрос на соединение отправлен! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Превышено время соединения @@ -1701,6 +1733,18 @@ This cannot be undone! Описание No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Для разработчиков @@ -1791,11 +1835,19 @@ This cannot be undone! Разрыв соединения server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Найдите и вступите в группы No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Не используйте SimpleX для экстренных звонков. @@ -2003,6 +2055,10 @@ This cannot be undone! Ввести сервер вручную No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Введите приветственное сообщение… @@ -2771,6 +2827,10 @@ This cannot be undone! Несовместимая версия базы данных No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Неправильный код @@ -2936,6 +2996,10 @@ This is your link for group %@! Вступление в группу No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Сохраните Ваши соединения @@ -2996,6 +3060,14 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Живое сообщение! @@ -3562,6 +3634,10 @@ This is your link for group %@! Вставить No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Вставить изображение @@ -4153,6 +4229,10 @@ This is your link for group %@! Сканировать QR код No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Сканировать код @@ -4372,6 +4452,10 @@ This is your link for group %@! Серверы No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Установить 1 день @@ -4868,6 +4952,10 @@ It can happen because of some bug or when the connection is compromised.Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. В группе более %lld членов, отчёты о доставке выключены. @@ -5057,6 +5145,14 @@ To connect, please ask your contact to create another connection link and check Чтобы установить соединение, попросите Ваш контакт создать еще одну ссылку и проверьте Ваше соединение с сетью. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Разблокировать @@ -5147,6 +5243,10 @@ To connect, please ask your contact to create another connection link and check Использовать для новых соединений No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Использовать интерфейс iOS для звонков @@ -5177,11 +5277,23 @@ To connect, please ask your contact to create another connection link and check Используются серверы, предоставленные SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Проверить безопасность соединения No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Подтвердить код безопасности @@ -6350,6 +6462,10 @@ SimpleX серверы не могут получить доступ к Ваше обновил(а) профиль группы rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6487,6 +6603,10 @@ SimpleX серверы не могут получить доступ к Ваше SimpleX использует Face ID для аутентификации Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX использует микрофон для аудио и видео звонков, и для записи голосовых сообщений. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index e1451378a5..f7e2ad5955 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -284,6 +284,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -846,6 +854,10 @@ กลับ No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID ID ข้อความที่ไม่ดี @@ -1137,6 +1149,10 @@ Connect incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1173,6 +1189,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… กำลังเชื่อมต่อกับเซิร์ฟเวอร์… @@ -1183,6 +1207,10 @@ This is your own one-time link! กำลังเชื่อมต่อกับเซิร์ฟเวอร์... (ข้อผิดพลาด: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection การเชื่อมต่อ @@ -1203,6 +1231,10 @@ This is your own one-time link! ส่งคําขอเชื่อมต่อแล้ว! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout หมดเวลาการเชื่อมต่อ @@ -1684,6 +1716,18 @@ This cannot be undone! คำอธิบาย No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop พัฒนา @@ -1774,10 +1818,18 @@ This cannot be undone! ตัดการเชื่อมต่อ server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. อย่าใช้ SimpleX สําหรับการโทรฉุกเฉิน @@ -1983,6 +2035,10 @@ This cannot be undone! ใส่เซิร์ฟเวอร์ด้วยตนเอง No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… ใส่ข้อความต้อนรับ… @@ -2749,6 +2805,10 @@ This cannot be undone! เวอร์ชันฐานข้อมูลที่เข้ากันไม่ได้ No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode รหัสผ่านไม่ถูกต้อง @@ -2913,6 +2973,10 @@ This is your link for group %@! กำลังจะเข้าร่วมกลุ่ม No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections รักษาการเชื่อมต่อของคุณ @@ -2973,6 +3037,14 @@ This is your link for group %@! ข้อจำกัด No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! ข้อความสด! @@ -3536,6 +3608,10 @@ This is your link for group %@! แปะ No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image แปะภาพ @@ -4124,6 +4200,10 @@ This is your link for group %@! สแกนคิวอาร์โค้ด No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code สแกนรหัส @@ -4341,6 +4421,10 @@ This is your link for group %@! เซิร์ฟเวอร์ No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day ตั้ง 1 วัน @@ -4834,6 +4918,10 @@ It can happen because of some bug or when the connection is compromised.การดำเนินการนี้ไม่สามารถยกเลิกได้ - โปรไฟล์ ผู้ติดต่อ ข้อความ และไฟล์ของคุณจะสูญหายไปอย่างถาวร No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. No comment provided by engineer. @@ -5021,6 +5109,14 @@ To connect, please ask your contact to create another connection link and check ในการเชื่อมต่อ โปรดขอให้ผู้ติดต่อของคุณสร้างลิงก์การเชื่อมต่ออื่น และตรวจสอบว่าคุณมีการเชื่อมต่อเครือข่ายที่เสถียร No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock ปลดล็อค @@ -5110,6 +5206,10 @@ To connect, please ask your contact to create another connection link and check ใช้สำหรับการเชื่อมต่อใหม่ No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface ใช้อินเทอร์เฟซการโทร iOS @@ -5139,11 +5239,23 @@ To connect, please ask your contact to create another connection link and check กำลังใช้เซิร์ฟเวอร์ SimpleX Chat อยู่ No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security ตรวจสอบความปลอดภัยในการเชื่อมต่อ No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code ตรวจสอบรหัสความปลอดภัย @@ -6308,6 +6420,10 @@ SimpleX servers cannot see your profile. อัปเดตโปรไฟล์กลุ่มแล้ว rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6445,6 +6561,10 @@ SimpleX servers cannot see your profile. SimpleX ใช้ Face ID สำหรับการรับรองความถูกต้องในเครื่อง Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX ต้องการการเข้าถึงไมโครโฟนสำหรับการโทรด้วยเสียงและวิดีโอ และเพื่อบันทึกข้อความเสียง diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index b3b5e9d39a..16b66a27d6 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -289,6 +289,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -853,6 +861,10 @@ Назад No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Неправильний ідентифікатор повідомлення @@ -1145,6 +1157,10 @@ Підключайтеся інкогніто No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1182,6 +1198,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Підключення до сервера… @@ -1192,6 +1216,10 @@ This is your own one-time link! Підключення до сервера... (помилка: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Підключення @@ -1212,6 +1240,10 @@ This is your own one-time link! Запит на підключення відправлено! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Тайм-аут з'єднання @@ -1694,6 +1726,18 @@ This cannot be undone! Опис No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Розробник @@ -1784,10 +1828,18 @@ This cannot be undone! Від'єднати server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. НЕ використовуйте SimpleX для екстрених викликів. @@ -1993,6 +2045,10 @@ This cannot be undone! Увійдіть на сервер вручну No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Введіть вітальне повідомлення… @@ -2760,6 +2816,10 @@ This cannot be undone! Несумісна версія бази даних No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Неправильний пароль @@ -2925,6 +2985,10 @@ This is your link for group %@! Приєднання до групи No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Зберігайте свої зв'язки @@ -2985,6 +3049,14 @@ This is your link for group %@! Обмеження No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Живе повідомлення! @@ -3550,6 +3622,10 @@ This is your link for group %@! Вставити No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Вставити зображення @@ -4141,6 +4217,10 @@ This is your link for group %@! Відскануйте QR-код No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Сканувати код @@ -4360,6 +4440,10 @@ This is your link for group %@! Сервери No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Встановити 1 день @@ -4855,6 +4939,10 @@ It can happen because of some bug or when the connection is compromised.Цю дію неможливо скасувати - ваш профіль, контакти, повідомлення та файли будуть безповоротно втрачені. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. У цій групі більше %lld учасників, підтвердження доставки не надсилаються. @@ -5043,6 +5131,14 @@ To connect, please ask your contact to create another connection link and check Щоб підключитися, попросіть вашого контакта створити інше посилання і перевірте, чи маєте ви стабільне з'єднання з мережею. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Розблокувати @@ -5133,6 +5229,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте для нових з'єднань No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Використовуйте інтерфейс виклику iOS @@ -5163,11 +5263,23 @@ To connect, please ask your contact to create another connection link and check Використання серверів SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Перевірте безпеку з'єднання No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Підтвердіть код безпеки @@ -6336,6 +6448,10 @@ SimpleX servers cannot see your profile. оновлений профіль групи rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6473,6 +6589,10 @@ SimpleX servers cannot see your profile. SimpleX використовує Face ID для локальної автентифікації Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX потребує доступу до мікрофона для аудіо та відео дзвінків, а також для запису голосових повідомлень. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 929b54a631..d579e98bb9 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ 返回 No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID 错误消息 ID @@ -1151,6 +1163,10 @@ 在隐身状态下连接 No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… 连接服务器中…… @@ -1198,6 +1222,10 @@ This is your own one-time link! 连接服务器中……(错误:%@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection 连接 @@ -1218,6 +1246,10 @@ This is your own one-time link! 已发送连接请求! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout 连接超时 @@ -1701,6 +1733,18 @@ This cannot be undone! 描述 No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop 开发 @@ -1791,11 +1835,19 @@ This cannot be undone! 断开连接 server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups 发现和加入群组 No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. 请勿使用 SimpleX 进行紧急通话。 @@ -2003,6 +2055,10 @@ This cannot be undone! 手动输入服务器 No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… 输入欢迎消息…… @@ -2773,6 +2829,10 @@ This cannot be undone! 数据库版本不兼容 No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode 密码错误 @@ -2938,6 +2998,10 @@ This is your link for group %@! 加入群组中 No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections 保持连接 @@ -2998,6 +3062,14 @@ This is your link for group %@! 限制 No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! 实时消息! @@ -3565,6 +3637,10 @@ This is your link for group %@! 粘贴 No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image 粘贴图片 @@ -4156,6 +4232,10 @@ This is your link for group %@! 扫描二维码 No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code 扫码 @@ -4376,6 +4456,10 @@ This is your link for group %@! 服务器 No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day 设定1天 @@ -4872,6 +4956,10 @@ It can happen because of some bug or when the connection is compromised.此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。 No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. 该组有超过 %lld 个成员,不发送送货单。 @@ -5061,6 +5149,14 @@ To connect, please ask your contact to create another connection link and check 如果要连接,请让您的联系人创建另一个连接链接,并检查您的网络连接是否稳定。 No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock 解锁 @@ -5151,6 +5247,10 @@ To connect, please ask your contact to create another connection link and check 用于新连接 No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface 使用 iOS 通话界面 @@ -5181,11 +5281,23 @@ To connect, please ask your contact to create another connection link and check 使用 SimpleX Chat 服务器。 No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security 验证连接安全 No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code 验证安全码 @@ -6356,6 +6468,10 @@ SimpleX 服务器无法看到您的资料。 已更新的群组资料 rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6493,6 +6609,10 @@ SimpleX 服务器无法看到您的资料。 SimpleX 使用Face ID进行本地身份验证 Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX 需要麦克风访问权限才能进行音频和视频通话,以及录制语音消息。 diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ From 624a3abba2d16efa2509c866301faf6a2db392a7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 22:29:18 +0000 Subject: [PATCH 110/174] website: translations (#3412) * Translated using Weblate (German) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ * Translated using Weblate (Arabic) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ --------- Co-authored-by: mlanp Co-authored-by: jonnysemon --- website/langs/ar.json | 6 +++--- website/langs/de.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/langs/ar.json b/website/langs/ar.json index 09a8a268ac..2aa0750b6a 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -20,8 +20,8 @@ "hero-overlay-1-textlink": "لماذا تعتبر معرفات المستخدم ضارة بالخصوصية؟", "hero-overlay-2-textlink": "كيف يعمل SimpleX؟", "hero-overlay-2-title": "لماذا تعتبر معرفات المستخدم ضارة بالخصوصية؟", - "feature-2-title": "تشفير
الصور وملفات بين الطرفين", - "feature-3-title": "المجموعات السرية اللامركزية —
المستخدمون فقط يعرفون بوجودها", + "feature-2-title": "تشفير
الصور والفيديوهات والملفات بين الطرفين", + "feature-3-title": "مجموعات لامركزية مشفرة — المستخدمون فقط يعرفون بوجودها", "feature-5-title": "محادثات سرية اختفائية", "feature-6-title": "تشفير المكالمات الصوتية والفيديو
بين الطرفين", "simplex-network-overlay-1-title": "مقارنة مع بروتوكولات المراسلة P2P", @@ -43,7 +43,7 @@ "feature-1-title": "تشفير الرسائل بين الطرفين مع دعم ماركداون والتحرير", "feature-4-title": "تشفير الرسائل الصوتية بين الطرفين", "privacy-matters-overlay-card-1-p-1": "تستخدم العديد من الشركات الكبيرة معلومات حول من تتصل به لتقدير دخلك، وبيع المنتجات التي لا تحتاجها حقًا، ولتحديد الأسعار.", - "feature-7-title": "قاعدة بيانات محمولة مشفرة — نقل ملف التعريف الخاص بك إلى جهاز آخر", + "feature-7-title": "تخزين التطبيقات المشفرة المحمولة — نقل ملف التعريف إلى جهاز آخر", "feature-8-title": "وضع التخفي —
فريد من نوعه لـ SimpleX Chat", "simplex-private-1-title": "طبقتان من
التشفير بين الطرفين", "simplex-private-2-title": "طبقة إضافية من
تشفير الخادم", diff --git a/website/langs/de.json b/website/langs/de.json index 5eb432720e..cb03588e1f 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -35,14 +35,14 @@ "hero-2-header": "Aufbau einer privaten Verbindung", "hero-overlay-1-title": "Wie funktioniert SimpleX?", "hero-2-header-desc": "Das Video zeigt Ihnen, wie Sie sich mit einem Kontakt mit dessen Einmal-QR-Code persönlich oder per Videotreff verbinden. Sie können sich auch über einen geteilten Einladungslink miteinander verbinden.", - "feature-2-title": "Ende-zu-Ende verschlüsselte
Bilder und Dateien", + "feature-2-title": "Ende-zu-Ende verschlüsselte
Bilder, Videos und Dateien", "feature-4-title": "Ende-zu-Ende verschlüsselte Sprachnachrichten", "feature-1-title": "Ende-zu-Ende verschlüsselte Nachrichten mit Markdowns und Bearbeitungsmöglichkeiten", - "feature-3-title": "Dezentralisierte geheime Gruppen —
Nur die Nutzer wissen, dass diese existieren", + "feature-3-title": "Ende-zu-Ende verschlüsselte dezentralisierte Gruppen — Nur die Nutzer wissen, dass diese überhaupt existieren", "simplex-private-8-title": "Mischen von Nachrichten,
um Korrelationen zu reduzieren", "feature-5-title": "Verschwindende geheime Unterhaltungen", "feature-6-title": "Ende-zu-Ende verschlüsselte Sprach- und Videoanrufe", - "feature-7-title": "Portable und verschlüsselte Datenbank — Verschieben Sie Ihr komplettes Profil einfach auf ein anderes Gerät", + "feature-7-title": "Portable und verschlüsselte App-Datenspeicherung — verschieben Sie das komplette Profil einfach auf ein anderes Gerät", "feature-8-title": "Inkognito-Modus —
Einzigartig in SimpleX Chat", "simplex-network-overlay-1-title": "Vergleich mit P2P Nachrichten-Protokollen", "simplex-private-1-title": "Zwei Schichten der
Ende-zu-Ende Verschlüsselung", From 47cd7de1ae2d6b0eca0df79389af108c3ce82a41 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 00:00:29 +0000 Subject: [PATCH 111/174] core: 5.4.0.4 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- tests/RemoteTests.hs | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.yaml b/package.yaml index 386df150aa..da9de37131 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.3 +version: 5.4.0.4 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index abcfcc4c45..d33ffe8b54 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.4.0.3 +version: 5.4.0.4 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d9ef5bd648..d8d53aa0c4 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -71,11 +71,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [5, 4, 0, 3] +minRemoteCtrlVersion = AppVersion [5, 4, 0, 4] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [5, 4, 0, 3] +minRemoteHostVersion = AppVersion [5, 4, 0, 4] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index c734c94dbe..b7b34999b0 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -15,7 +15,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import qualified Network.TLS as TLS import Simplex.Chat.Archive (archiveFilesFolder) -import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..), versionNumber) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types @@ -120,12 +120,12 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil desktop <## "Remote session invitation:" inv <- getTermLine desktop mobileBob ##> ("/connect remote ctrl " <> inv) - mobileBob <## "connecting new remote controller: My desktop, v5.4.0.3" + mobileBob <## ("connecting new remote controller: My desktop, v" <> versionNumber) mobileBob <## "remote controller stopped" -- the server remains active after rejecting invalid client mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) desktop <## "remote host 1 connecting" desktop <## "Compare session code with host:" sessId <- getTermLine desktop @@ -429,7 +429,7 @@ startRemote mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "connecting new remote controller: My desktop, v5.4.0.3" + mobile <## ("connecting new remote controller: My desktop, v" <> versionNumber) desktop <## "new remote host connecting" mobile <## "new remote controller connected" verifyRemoteCtrl mobile desktop @@ -444,7 +444,7 @@ startRemoteStored mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) desktop <## "remote host 1 connecting" mobile <## "remote controller 1 connected" verifyRemoteCtrl mobile desktop @@ -463,7 +463,7 @@ startRemoteDiscover mobile desktop = do mobile <## "1. My desktop" mobile ##> "/confirm remote ctrl 1" - mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) desktop <## "remote host 1 connecting" mobile <## "remote controller 1 connected" verifyRemoteCtrl mobile desktop From da8789ef4c112383817653cb811b134710f4c1c8 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:20:04 +0200 Subject: [PATCH 112/174] desktop: fix RCP tag in AgentErrorType (#3416) --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 33743974cb..85f2203e30 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 @@ -4609,7 +4609,7 @@ sealed class AgentErrorType { @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType() // @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() @Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType() - @Serializable @SerialName("XFTP") class RCP(val rcpErr: RCErrorType): AgentErrorType() + @Serializable @SerialName("RCP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() From 5a08a26c9a92d39c5957f04797585ffb25343fb1 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 21 Nov 2023 16:43:52 +0200 Subject: [PATCH 113/174] desktop: add exception handlers to startReceiver loop (#3417) * desktop: add exception handlers to startReceiver loop * simplify * more exceptions --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../chat/simplex/common/model/SimpleXAPI.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 85f2203e30..9298388df0 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 @@ -423,8 +423,15 @@ object ChatController { receiverStarted = false break } - val msg = recvMsg(ctrl) - if (msg != null) processReceivedMsg(msg) + try { + val msg = recvMsg(ctrl) + if (msg != null) processReceivedMsg(msg) + } catch (e: Exception) { + Log.e(TAG, "ChatController recvMsg/processReceivedMsg exception: " + e.stackTraceToString()); + } catch (e: Throwable) { + Log.e(TAG, "ChatController recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } } } } @@ -3558,7 +3565,7 @@ class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = nul fun decodeStr(str: String): APIResponse { return try { json.decodeFromString(str) - } catch(e: Exception) { + } catch(e: Throwable) { try { Log.d(TAG, e.localizedMessage ?: "") val data = json.parseToJsonElement(str).jsonObject @@ -3587,11 +3594,18 @@ class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = nul return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } } catch (e: Exception) { - Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString()) + Log.e(TAG, "Exception while parsing chat(s): " + e.stackTraceToString()) + } catch (e: Throwable) { + Log.e(TAG, "Throwable while parsing chat(s): " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) } APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr) } catch(e: Exception) { APIResponse(CR.Invalid(str), remoteHostId = null) + } catch(e: Throwable) { + Log.e(TAG, "Throwable2 while parsing chat(s): " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + APIResponse(CR.Invalid(str), remoteHostId = null) } } } From a8576c23404679dbd7019cdfffb5b9b3be2eb4ef Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:25:50 +0400 Subject: [PATCH 114/174] core: test forwarded message deduplication, mute terminal error (#3414) --- src/Simplex/Chat/View.hs | 11 +++++++---- tests/ChatTests/Groups.hs | 40 +++++++++++++++++++++++++++++++++++++++ tests/ChatTests/Utils.hs | 9 +++++++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 119d19e620..fc37569fdd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -360,8 +360,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo] CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] - CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel e - CRChatError u e -> ttyUser' u $ viewChatError logLevel e + CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel testView e + CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRTimedAction _ _ -> [] where @@ -1737,8 +1737,8 @@ viewRemoteCtrl :: RemoteCtrlInfo -> StyledString viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName} = plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName -viewChatError :: ChatLogLevel -> ChatError -> [StyledString] -viewChatError logLevel = \case +viewChatError :: ChatLogLevel -> Bool -> ChatError -> [StyledString] +viewChatError logLevel testView = \case ChatError err -> case err of CENoActiveUser -> ["error: active user is required"] CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError] @@ -1848,6 +1848,9 @@ viewChatError logLevel = \case SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> viewGroupName g)] SERemoteCtrlNotFound rcId -> ["no remote controller " <> sShow rcId] SERemoteHostNotFound rhId -> ["no remote host " <> sShow rhId] + SEDuplicateGroupMessage {groupId, sharedMsgId} + | testView -> ["duplicate group message, group id: " <> sShow groupId <> ", message id: " <> sShow sharedMsgId] + | otherwise -> [] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9278d4071c..03ddf7d57b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -9,6 +9,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (when, void) 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.Protocol (supportedChatVRange) @@ -107,6 +108,7 @@ chatGroupTests = do it "sends and updates profile when creating contact" testMemberContactProfileUpdate describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate it "forward message edit (x.msg.update)" testGroupMsgForwardEdit it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion @@ -3947,6 +3949,44 @@ setupGroupForwarding3 gName alice bob cath = do void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" +testGroupMsgForwardDeduplicate :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeduplicate = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath + <### [ Predicate ("#team bob> hi there" `isInfixOf`), + StartsWith "duplicate group message, group id: 1" + ] + + threadDelay 1000000 + + -- cath sends x.grp.mem.con on deduplication, so alice doesn't forward anymore + + cath #> "#team hey team" + alice <# "#team cath> hey team" + bob <# "#team cath> hey team" + + alice ##> "/tail #team 2" + alice <# "#team bob> hi there" + alice <# "#team cath> hey team" + + bob ##> "/tail #team 2" + bob <# "#team hi there" + bob <# "#team cath> hey team" + + cath ##> "/tail #team 2" + cath <#. "#team bob> hi there" + cath <# "#team hey team" + testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 83b0d507b9..40fe0e6dab 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -282,8 +282,12 @@ cc <##.. ls = do unless prefix $ print ("expected to start from one of: " <> show ls, ", got: " <> l) prefix `shouldBe` True -data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String | StartsWith String - deriving (Show) +data ConsoleResponse + = ConsoleString String + | WithTime String + | EndsWith String + | StartsWith String + | Predicate (String -> Bool) instance IsString ConsoleResponse where fromString = ConsoleString @@ -303,6 +307,7 @@ getInAnyOrder f cc ls = do WithTime s -> dropTime_ l == Just s EndsWith s -> s `isSuffixOf` l StartsWith s -> s `isPrefixOf` l + Predicate p -> p l filterFirst :: (a -> Bool) -> [a] -> [a] filterFirst _ [] = [] filterFirst p (x:xs) From 097242e7a8ffe5dc546bcfabd7fb03763c6bdf8f Mon Sep 17 00:00:00 2001 From: Jesse Horne Date: Tue, 21 Nov 2023 14:37:15 -0500 Subject: [PATCH 115/174] desktop: add image pasting from clipboard (#3378) * first pass at desktop image pasting for multiplatform * removed debug println * fixed bug with pasting text * temp files are deleted now following simplex conventions * optimizations --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../platform/PlatformTextField.desktop.kt | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index c677edc066..74df6b8251 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -22,12 +22,21 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import chat.simplex.common.views.chat.* -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay +import java.awt.Image +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.UnsupportedFlavorException +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream import java.io.File import java.net.URI +import java.util.* +import javax.imageio.ImageIO +import kotlin.collections.ArrayList import kotlin.io.path.* import kotlin.math.min import kotlin.text.substring @@ -112,12 +121,45 @@ actual fun PlatformTextField( onUpArrow() true } else if (it.key == Key.V && - it.type == KeyEventType.KeyDown && - ((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac())) && - parseToFiles(clipboard.getText()).isNotEmpty()) { - onFilesPasted(parseToFiles(clipboard.getText())) - true - } + it.type == KeyEventType.KeyDown && + ((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac()))) { + if (parseToFiles(clipboard.getText()).isNotEmpty()) { + onFilesPasted(parseToFiles(clipboard.getText())) + true + } else { + // It's much faster to getData instead of getting transferable first + val image = try { + Toolkit.getDefaultToolkit().systemClipboard.getData(DataFlavor.imageFlavor) as Image + } catch (e: UnsupportedFlavorException) { + null + } + if (image != null) { + try { + // create BufferedImage from Image + val bi = BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB) + val bgr = bi.createGraphics() + bgr.drawImage(image, 0, 0, null) + bgr.dispose() + // create byte array from BufferedImage + val baos = ByteArrayOutputStream() + ImageIO.write(bi, "png", baos) + val bytes = baos.toByteArray() + withBGApi { + val tempFile = File(tmpDir, "${UUID.randomUUID()}.png") + chatModel.filesToDelete.add(tempFile) + + tempFile.writeBytes(bytes) + composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message) + } + } catch (e: Exception) { + Log.e(TAG, "Pasting image exception: ${e.stackTraceToString()}") + } + true + } else { + false + } + } + } else false }, cursorBrush = SolidColor(MaterialTheme.colors.secondary), From febf3e0a454f6e67524ff10b4aa85796f48d26c7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 21 Nov 2023 23:38:05 +0400 Subject: [PATCH 116/174] ui: v5.4 what's new (#3413) * ios: v5.4 what's new * android * export localizations * update --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../Views/Onboarding/WhatsNewView.swift | 31 ++++++++ .../bg.xcloc/Localized Contents/bg.xliff | 38 +++++++++ .../cs.xcloc/Localized Contents/cs.xliff | 38 +++++++++ .../de.xcloc/Localized Contents/de.xliff | 38 +++++++++ .../en.xcloc/Localized Contents/en.xliff | 49 ++++++++++++ .../es.xcloc/Localized Contents/es.xliff | 38 +++++++++ .../fi.xcloc/Localized Contents/fi.xliff | 38 +++++++++ .../fr.xcloc/Localized Contents/fr.xliff | 38 +++++++++ .../it.xcloc/Localized Contents/it.xliff | 38 +++++++++ .../ja.xcloc/Localized Contents/ja.xliff | 38 +++++++++ .../nl.xcloc/Localized Contents/nl.xliff | 38 +++++++++ .../pl.xcloc/Localized Contents/pl.xliff | 38 +++++++++ .../ru.xcloc/Localized Contents/ru.xliff | 38 +++++++++ .../th.xcloc/Localized Contents/th.xliff | 38 +++++++++ .../uk.xcloc/Localized Contents/uk.xliff | 38 +++++++++ .../Localized Contents/zh-Hans.xliff | 38 +++++++++ .../common/views/onboarding/WhatsNewView.kt | 31 ++++++++ .../commonMain/resources/MR/base/strings.xml | 19 +++++ ...desktop-quantum-resistant-better-groups.md | 78 +++++++++++++++++++ 19 files changed, 740 insertions(+) create mode 100644 blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 966284b0c9..59c2b25b6d 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -283,6 +283,37 @@ private let versionDescriptions: [VersionDescription] = [ ), ] ), + VersionDescription( + version: "v5.4", + post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"), + features: [ + FeatureDescription( + icon: "desktopcomputer", + title: "Link mobile and desktop apps! 🔗", + description: "Via secure quantum resistant protocol." + ), + FeatureDescription( + icon: "person.2", + title: "Better groups", + description: "Faster joining and more reliable messages." + ), + FeatureDescription( + icon: "theatermasks", + title: "Incognito groups", + description: "Create a group using a random profile." + ), + FeatureDescription( + icon: "hand.raised", + title: "Block group members", + description: "To hide unwanted messages." + ), + FeatureDescription( + icon: "gift", + title: "A few more things", + description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" + ), + ] + ), ] private let lastVersion = versionDescriptions.last!.version diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 19e311468a..9db0f95f00 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -386,6 +386,12 @@ - и още! No comment provided by engineer.
+ + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Лош хеш на съобщението No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages По-добри съобщения @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Създай SimpleX адрес No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Създайте адрес, за да позволите на хората да се свързват с вас. @@ -2382,6 +2400,10 @@ This cannot be undone! Бързо и без чакане, докато подателят е онлайн! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Любим @@ -2799,6 +2821,10 @@ This cannot be undone! Инкогнито No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Режим инкогнито @@ -3062,6 +3088,10 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ It can happen because of some bug or when the connection is compromised.За да се свърже, вашият контакт може да сканира QR код или да използва линка в приложението. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection За да направите нова връзка @@ -5308,6 +5342,10 @@ To connect, please ask your contact to create another connection link and check Чрез браузър No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Видео разговор diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 0286c8c684..4d679ab2d5 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -386,6 +386,12 @@ - a více! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Špatný hash zprávy No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Lepší zprávy @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Vytvořit SimpleX adresu No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Vytvořit adresu, aby se s vámi lidé mohli spojit. @@ -2382,6 +2400,10 @@ This cannot be undone! Rychle a bez čekání, než bude odesílatel online! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Oblíbené @@ -2799,6 +2821,10 @@ This cannot be undone! Inkognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Režim inkognito @@ -3062,6 +3088,10 @@ This is your link for group %@! Omezení No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Pro připojení může váš kontakt naskenovat QR kód, nebo použít odkaz v aplikaci. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Vytvoření nového připojení @@ -5308,6 +5342,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Prostřednictvím prohlížeče No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videohovor diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index a69ae55fd3..6c0c0d6807 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -393,6 +393,12 @@ - und mehr! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Ungültiger Nachrichten-Hash No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Verbesserungen bei Nachrichten @@ -901,6 +911,10 @@ Blockieren No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Mitglied blockieren @@ -1351,6 +1365,10 @@ Das ist Ihr eigener Einmal-Link! SimpleX-Adresse erstellen No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können. @@ -2413,6 +2431,10 @@ Das kann nicht rückgängig gemacht werden! Schnell und ohne warten auf den Absender, bis er online ist! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favorit @@ -2833,6 +2855,10 @@ Das kann nicht rückgängig gemacht werden! Inkognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Inkognito-Modus @@ -3101,6 +3127,10 @@ Das ist Ihr Link für die Gruppe %@! Einschränkungen No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Um eine Verbindung herzustellen, kann Ihr Kontakt den QR-Code scannen oder den Link in der App verwenden. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Um eine Verbindung mit einem neuen Kontakt zu erstellen @@ -5359,6 +5393,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Über den Browser No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videoanruf diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index dd302cd1e1..7781cec534 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -392,6 +392,15 @@ - and more! - more stable message delivery. - a bit better groups. +- and more! + No comment provided by engineer. + + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + - optionally notify deleted contacts. +- spaces in profile names. - and more! No comment provided by engineer. @@ -894,6 +903,11 @@ Bad message hash No comment provided by engineer. + + Better groups + Better groups + No comment provided by engineer. + Better messages Better messages @@ -904,6 +918,11 @@ Block No comment provided by engineer. + + Block group members + Block group members + No comment provided by engineer. + Block member Block member @@ -1359,6 +1378,11 @@ This is your own one-time link! Create SimpleX address No comment provided by engineer. + + Create a group using a random profile. + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Create an address to let people connect with you. @@ -2429,6 +2453,11 @@ This cannot be undone! Fast and no wait until the sender is online! No comment provided by engineer. + + Faster joining and more reliable messages. + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favorite @@ -2849,6 +2878,11 @@ This cannot be undone! Incognito No comment provided by engineer. + + Incognito groups + Incognito groups + No comment provided by engineer. + Incognito mode Incognito mode @@ -3119,6 +3153,11 @@ This is your link for group %@! Limitations No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options Linked desktop options @@ -5065,6 +5104,11 @@ It can happen because of some bug or when the connection is compromised.To connect, your contact can scan QR code or use the link in the app. No comment provided by engineer. + + To hide unwanted messages. + To hide unwanted messages. + No comment provided by engineer. + To make a new connection To make a new connection @@ -5389,6 +5433,11 @@ To connect, please ask your contact to create another connection link and check Via browser No comment provided by engineer. + + Via secure quantum resistant protocol. + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Video call diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index e913d78d2d..b22eeb2554 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -386,6 +386,12 @@ - ¡y más! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Hash de mensaje incorrecto No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Mensajes mejorados @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Crear tu dirección SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Crea una dirección para que otras personas puedan conectar contigo. @@ -2382,6 +2400,10 @@ This cannot be undone! ¡Rápido y sin necesidad de esperar a que el remitente esté en línea! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favoritos @@ -2799,6 +2821,10 @@ This cannot be undone! Incógnito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Modo incógnito @@ -3062,6 +3088,10 @@ This is your link for group %@! Limitaciones No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Para conectarse, tu contacto puede escanear el código QR o usar el enlace en la aplicación. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Para hacer una conexión nueva @@ -5309,6 +5343,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Mediante navegador No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videollamada diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 694b81bcd7..6999871574 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -383,6 +383,12 @@ - ja paljon muuta! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -876,6 +882,10 @@ Virheellinen viestin tarkiste No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Parempia viestejä @@ -885,6 +895,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1324,6 +1338,10 @@ This is your own one-time link! Luo SimpleX-osoite No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Luo osoite, jolla ihmiset voivat ottaa sinuun yhteyttä. @@ -2374,6 +2392,10 @@ This cannot be undone! Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Suosikki @@ -2791,6 +2813,10 @@ This cannot be undone! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Incognito-tila @@ -3054,6 +3080,10 @@ This is your link for group %@! Rajoitukset No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4981,6 +5011,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Uuden yhteyden luominen @@ -5295,6 +5329,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Selaimella No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videopuhelu diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index be028fcbb4..a0c5b6e991 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -393,6 +393,12 @@ - et bien d'autres choses encore ! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Mauvais hash de message No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Meilleurs messages @@ -901,6 +911,10 @@ Bloquer No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Bloquer ce membre @@ -1351,6 +1365,10 @@ Il s'agit de votre propre lien unique ! Créer une adresse SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Créez une adresse pour permettre aux gens de vous contacter. @@ -2413,6 +2431,10 @@ Cette opération ne peut être annulée ! Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne ! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favoris @@ -2833,6 +2855,10 @@ Cette opération ne peut être annulée ! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Mode Incognito @@ -3101,6 +3127,10 @@ Voici votre lien pour le groupe %@ ! Limitations No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Pour se connecter, votre contact peut scanner le code QR ou utiliser le lien dans l'application. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Pour établir une nouvelle connexion @@ -5359,6 +5393,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Via navigateur No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Appel vidéo diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 9e38f14e6e..ec62aed15a 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -393,6 +393,12 @@ - e altro ancora! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Hash del messaggio errato No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Messaggi migliorati @@ -901,6 +911,10 @@ Blocca No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Blocca membro @@ -1351,6 +1365,10 @@ Questo è il tuo link una tantum! Crea indirizzo SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Crea un indirizzo per consentire alle persone di connettersi con te. @@ -2413,6 +2431,10 @@ Non è reversibile! Veloce e senza aspettare che il mittente sia in linea! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Preferito @@ -2833,6 +2855,10 @@ Non è reversibile! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Modalità incognito @@ -3101,6 +3127,10 @@ Questo è il tuo link per il gruppo %@! Limitazioni No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell'app. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Per creare una nuova connessione @@ -5359,6 +5393,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Via browser No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videochiamata diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 138f67fe2b..37b942f7d1 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -383,6 +383,12 @@ - などなど! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -877,6 +883,10 @@ メッセージのハッシュ値問題 No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages より良いメッセージ @@ -886,6 +896,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1326,6 +1340,10 @@ This is your own one-time link! SimpleXアドレスの作成 No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. 人とつながるためのアドレスを作成する。 @@ -2377,6 +2395,10 @@ This cannot be undone! 送信者がオンラインになるまでの待ち時間がなく、速い! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite お気に入り @@ -2794,6 +2816,10 @@ This cannot be undone! シークレットモード No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode シークレットモード @@ -3057,6 +3083,10 @@ This is your link for group %@! 制限事項 No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4977,6 +5007,10 @@ It can happen because of some bug or when the connection is compromised.接続するにはQRコードを読み込むか、アプリ内のリンクを使用する必要があります。 No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection 新規に接続する場合 @@ -5291,6 +5325,10 @@ To connect, please ask your contact to create another connection link and check ブラウザ経由 No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call ビデオ通話 diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 696c7e99fc..5119dc9a3f 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -393,6 +393,12 @@ - en meer! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Onjuiste bericht hash No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Betere berichten @@ -901,6 +911,10 @@ Blokkeren No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Lid blokkeren @@ -1351,6 +1365,10 @@ Dit is uw eigen eenmalige link! Maak een SimpleX adres aan No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Maak een adres aan zodat mensen contact met je kunnen opnemen. @@ -2413,6 +2431,10 @@ Dit kan niet ongedaan gemaakt worden! Snel en niet wachten tot de afzender online is! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favoriet @@ -2833,6 +2855,10 @@ Dit kan niet ongedaan gemaakt worden! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Incognito modus @@ -3101,6 +3127,10 @@ Dit is jouw link voor groep %@! Beperkingen No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Om verbinding te maken, kan uw contact de QR-code scannen of de link in de app gebruiken. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Om een nieuwe verbinding te maken @@ -5359,6 +5393,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Via browser No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call video oproep diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 58c3c85b0e..518addb4ca 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -393,6 +393,12 @@ - i więcej! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Zły hash wiadomości No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Lepsze wiadomości @@ -901,6 +911,10 @@ Zablokuj No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Zablokuj członka @@ -1351,6 +1365,10 @@ To jest twój jednorazowy link! Utwórz adres SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Utwórz adres, aby ludzie mogli się z Tobą połączyć. @@ -2413,6 +2431,10 @@ To nie może być cofnięte! Szybko i bez czekania aż nadawca będzie online! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Ulubione @@ -2833,6 +2855,10 @@ To nie może być cofnięte! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Tryb incognito @@ -3101,6 +3127,10 @@ To jest twój link do grupy %@! Ograniczenia No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Aby się połączyć, Twój kontakt może zeskanować kod QR lub skorzystać z linku w aplikacji. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Aby nawiązać nowe połączenie @@ -5359,6 +5393,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Przez przeglądarkę No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Połączenie wideo diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index d544471c67..7744fa908a 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -386,6 +386,12 @@ - и прочее! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Ошибка хэш сообщения No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Улучшенные сообщения @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Создать адрес SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Создайте адрес, чтобы можно было соединиться с вами. @@ -2380,6 +2398,10 @@ This cannot be undone! Быстрые и не нужно ждать, когда отправитель онлайн! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Избранный @@ -2797,6 +2819,10 @@ This cannot be undone! Инкогнито No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Режим Инкогнито @@ -3060,6 +3086,10 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4989,6 +5019,10 @@ It can happen because of some bug or when the connection is compromised.Чтобы соединиться с Вами, Ваш контакт может отсканировать QR-код или использовать ссылку в приложении. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Чтобы соединиться @@ -5304,6 +5338,10 @@ To connect, please ask your contact to create another connection link and check В браузере No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Видеозвонок diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index f7e2ad5955..3c37295754 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -377,6 +377,12 @@ - และอื่น ๆ! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -868,6 +874,10 @@ แฮชข้อความไม่ดี No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages ข้อความที่ดีขึ้น @@ -877,6 +887,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1314,6 +1328,10 @@ This is your own one-time link! สร้างที่อยู่ SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. สร้างที่อยู่เพื่อให้ผู้อื่นเชื่อมต่อกับคุณ @@ -2359,6 +2377,10 @@ This cannot be undone! รวดเร็วและไม่ต้องรอจนกว่าผู้ส่งจะออนไลน์! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite ที่ชอบ @@ -2776,6 +2798,10 @@ This cannot be undone! ไม่ระบุตัวตน No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode โหมดไม่ระบุตัวตน @@ -3037,6 +3063,10 @@ This is your link for group %@! ข้อจำกัด No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4954,6 +4984,10 @@ It can happen because of some bug or when the connection is compromised.เพื่อการเชื่อมต่อ ผู้ติดต่อของคุณสามารถสแกนคิวอาร์โค้ดหรือใช้ลิงก์ในแอป No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection เพื่อสร้างการเชื่อมต่อใหม่ @@ -5266,6 +5300,10 @@ To connect, please ask your contact to create another connection link and check ผ่านเบราว์เซอร์ No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call การสนทนาทางวิดีโอ diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 16b66a27d6..d6f60cb702 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -382,6 +382,12 @@ - і багато іншого! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -875,6 +881,10 @@ Поганий хеш повідомлення No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Кращі повідомлення @@ -884,6 +894,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1323,6 +1337,10 @@ This is your own one-time link! Створіть адресу SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Створіть адресу, щоб люди могли з вами зв'язатися. @@ -2369,6 +2387,10 @@ This cannot be undone! Швидко і без очікування, поки відправник буде онлайн! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Улюблений @@ -2786,6 +2808,10 @@ This cannot be undone! Інкогніто No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Режим інкогніто @@ -3049,6 +3075,10 @@ This is your link for group %@! Обмеження No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4976,6 +5006,10 @@ It can happen because of some bug or when the connection is compromised.Щоб підключитися, ваш контакт може відсканувати QR-код або скористатися посиланням у додатку. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Щоб створити нове з'єднання @@ -5290,6 +5324,10 @@ To connect, please ask your contact to create another connection link and check Через браузер No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Відеодзвінок diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index d579e98bb9..1e9bd9ac1e 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -386,6 +386,12 @@ - 以及更多! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ 错误消息散列 No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages 更好的消息 @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! 创建 SimpleX 地址 No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. 创建一个地址,让人们与您联系。 @@ -2382,6 +2400,10 @@ This cannot be undone! 快速且无需等待发件人在线! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite 最喜欢 @@ -2799,6 +2821,10 @@ This cannot be undone! 隐身聊天 No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode 隐身模式 @@ -3062,6 +3088,10 @@ This is your link for group %@! 限制 No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ It can happen because of some bug or when the connection is compromised.您的联系人可以扫描二维码或使用应用程序中的链接来建立连接。 No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection 建立新连接 @@ -5308,6 +5342,10 @@ To connect, please ask your contact to create another connection link and check 通过浏览器 No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call 视频通话 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 390fd0f14f..e1a81d925c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -431,6 +431,37 @@ private val versionDescriptions: List = listOf( ) ) ), + VersionDescription( + version = "v5.4", + post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html", + features = listOf( + FeatureDescription( + icon = MR.images.ic_desktop, + titleId = MR.strings.v5_4_link_mobile_desktop, + descrId = MR.strings.v5_4_link_mobile_desktop_descr + ), + FeatureDescription( + icon = MR.images.ic_group, + titleId = MR.strings.v5_4_better_groups, + descrId = MR.strings.v5_4_better_groups_descr + ), + FeatureDescription( + icon = MR.images.ic_theater_comedy, + titleId = MR.strings.v5_4_incognito_groups, + descrId = MR.strings.v5_4_incognito_groups_descr + ), + FeatureDescription( + icon = MR.images.ic_back_hand, + titleId = MR.strings.v5_4_block_group_members, + descrId = MR.strings.v5_4_block_group_members_descr + ), + FeatureDescription( + icon = MR.images.ic_redeem, + titleId = MR.strings.v5_2_more_things, + descrId = MR.strings.v5_4_more_things_descr + ) + ) + ), ) private val lastVersion = versionDescriptions.last().version 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 616e78651d..177dcb55f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1608,6 +1608,25 @@ Toggle incognito when connecting. 6 new interface languages Arabic, Bulgarian, Finnish, Hebrew, Thai and Ukrainian - thanks to the users and Weblate. + Connect desktop and mobile! + Use your mobile app chat profile via desktop app. + Group improvements + Create groups incognito, block group members, faster join via link, and more. + Notify about contact deletion + You can optionally notify contacts when deleting them. + Checking SimpleX links + Detection of previously used and your own SimpleX links. + Spaces in profile names + You can now add spaces to your profile name. + Link mobile and desktop apps! 🔗 + Via secure quantum resistant protocol. + Better groups + Faster joining and more reliable messages. + Incognito groups + Create a group using a random profile. + Block group members + To hide unwanted messages. + - optionally notify deleted contacts.\n- profile names with spaces.\n- and more! seconds diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md new file mode 100644 index 0000000000..aef4dddf64 --- /dev/null +++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md @@ -0,0 +1,78 @@ +--- +layout: layouts/article.html +title: "SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups." +date: 2023-11-25 +preview: SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. +# image: images/20231125-remote-desktop.jpg +draft: true +imageWide: true +permalink: "/blog/20231125-simplex-chat-v5-4-quantum-resistant-mobile-from-desktop-better-groups.html" +--- + +TODO stub for release announcement + +# SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. + +**Published:** Nov 25, 2023 + +- [Quick start: control SimpleX Chat mobile app from CLI](#⚡️-quick-start-use-profiles-in-SimpleX-Chat-mobile-from-desktop-app) +- [What's the problem](#whats-the-problem) +- [Why didn't we use some existing solution?](#why-didnt-we-use-some-existing-solution) + +## ⚡️ Quick start: use profiles in SimpleX Chat mobile from desktop app + +## What's the problem? + +Currently you cannot use the same SimpleX Chat profile on mobile and desktop devices. Even though you can use small groups instead of direct conversations as a workaround, it is quite inconvenient – read status and delivery receipts become much less useful. + +So, we need a way to use the same profile on desktop as we use on mobile. + +If SimpleX Chat profile was stored on the server, the problem would have been simpler - you can just connect to it from another device. But even in this case, accessing the conversation history without compromising the security of double ratchet end-to-end encryption is not really possible. + +So we decided to implement the solution that is similar to what WhatsApp and WeChat did in early days - allowing a desktop device access profile on mobile via network. Unlike these big apps, we don't use the server to connect to mobile, but instead use the connection over the local network. + +The downside of this approach is that mobile device has to be with you and connected to the same local network (and in case of iOS, the app has to be in the foreground as well). But the upside is that we the connection can be secure, and that you do not have to have a copy of your profiles on the desktop, which usually has lower security. + +## Why didn't we use some existing solution? + +While there are several existing protocols for remote access, all of them are vulnerable to spoofing and man + + in many cases support of sending files and images is not very good, and sending videos and large files is simply impossible. There are currently these problems: + +- the sender has to be online for file transfer to complete, once it was confirmed by the recipient. +- when the file is sent to the group, the sender will have to transfer it separately to each member, creating a lot of traffic. +- the file transfer is slow, as it is sent in small chunks - approximately 16kb per message. + +As a result, we limited the supported size of files in the app to 8mb. Even for supported files, it is quite inefficient for sending any files to large groups. + +## SimpleX platform + +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 donated to SimpleX Chat! + +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 platform 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 From d0f43628ef2b32e828eda69f22d25d35c39647e3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:30:21 +0000 Subject: [PATCH 117/174] ui: translations (#3421) * Translated using Weblate (Italian) Currently translated at 100.0% (1483 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1332 of 1332 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1483 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 97.2% (1442 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1483 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1332 of 1332 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * android: fix formatted strings * ios: imp/exp localizations --------- Co-authored-by: Random Co-authored-by: Eric Co-authored-by: No name Co-authored-by: M1K4 --- .../bg.xcloc/Localized Contents/bg.xliff | 4 +- .../cs.xcloc/Localized Contents/cs.xliff | 4 +- .../de.xcloc/Localized Contents/de.xliff | 4 +- .../en.xcloc/Localized Contents/en.xliff | 6 +- .../es.xcloc/Localized Contents/es.xliff | 4 +- .../fi.xcloc/Localized Contents/fi.xliff | 4 +- .../fr.xcloc/Localized Contents/fr.xliff | 4 +- .../it.xcloc/Localized Contents/it.xliff | 36 ++++++- .../ja.xcloc/Localized Contents/ja.xliff | 4 +- .../nl.xcloc/Localized Contents/nl.xliff | 36 ++++++- .../pl.xcloc/Localized Contents/pl.xliff | 4 +- .../ru.xcloc/Localized Contents/ru.xliff | 4 +- .../th.xcloc/Localized Contents/th.xliff | 4 +- .../uk.xcloc/Localized Contents/uk.xliff | 4 +- .../Localized Contents/zh-Hans.xliff | 4 +- apps/ios/it.lproj/Localizable.strings | 93 +++++++++++++++++++ .../it.lproj/SimpleX--iOS--InfoPlist.strings | 3 + apps/ios/nl.lproj/Localizable.strings | 93 +++++++++++++++++++ .../nl.lproj/SimpleX--iOS--InfoPlist.strings | 3 + .../commonMain/resources/MR/es/strings.xml | 73 +++++++++++++-- .../commonMain/resources/MR/it/strings.xml | 44 +++++++++ .../commonMain/resources/MR/nl/strings.xml | 44 +++++++++ .../resources/MR/zh-rCN/strings.xml | 44 +++++++++ 23 files changed, 485 insertions(+), 38 deletions(-) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 9db0f95f00..b5ae218ab9 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -386,9 +386,9 @@ - и още! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 4d679ab2d5..1e7b2c1fba 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -386,9 +386,9 @@ - a více! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 6c0c0d6807..4d37da1f3a 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -393,9 +393,9 @@ - und mehr! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 7781cec534..6d70bd1221 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -395,12 +395,12 @@ - and more! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index b22eeb2554..cc93c0531d 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -386,9 +386,9 @@ - ¡y más! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 6999871574..c1c46aa42a 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -383,9 +383,9 @@ - ja paljon muuta! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index a0c5b6e991..78a23315cb 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -393,9 +393,9 @@ - et bien d'autres choses encore ! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index ec62aed15a..6f99b16b77 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -299,10 +299,12 @@ (new) + (nuovo) No comment provided by engineer. (this device v%@) + (questo dispositivo v%@) No comment provided by engineer. @@ -393,9 +395,9 @@ - e altro ancora! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. @@ -885,6 +887,7 @@ Bad desktop address + Indirizzo desktop errato No comment provided by engineer. @@ -1193,6 +1196,7 @@ Connect to desktop + Connetti al desktop No comment provided by engineer. @@ -1241,10 +1245,12 @@ Questo è il tuo link una tantum! Connected desktop + Desktop connesso No comment provided by engineer. Connected to desktop + Connesso al desktop No comment provided by engineer. @@ -1259,6 +1265,7 @@ Questo è il tuo link una tantum! Connecting to desktop + Connessione al desktop No comment provided by engineer. @@ -1283,6 +1290,7 @@ Questo è il tuo link una tantum! Connection terminated + Connessione terminata No comment provided by engineer. @@ -1781,14 +1789,17 @@ Non è reversibile! Desktop address + Indirizzo desktop No comment provided by engineer. Desktop app version %@ is not compatible with this app. + La versione dell'app desktop %@ non è compatibile con questa app. No comment provided by engineer. Desktop devices + Dispositivi desktop No comment provided by engineer. @@ -1883,6 +1894,7 @@ Non è reversibile! Disconnect desktop? + Disconnettere il desktop? No comment provided by engineer. @@ -1892,6 +1904,7 @@ Non è reversibile! Discover on network + Trova nella rete No comment provided by engineer. @@ -2066,10 +2079,12 @@ Non è reversibile! Encryption re-negotiation error + Errore di rinegoziazione crittografia message decrypt error item Encryption re-negotiation failed. + Rinegoziazione crittografia fallita. No comment provided by engineer. @@ -2104,6 +2119,7 @@ Non è reversibile! Enter this device name… + Inserisci il nome di questo dispositivo… No comment provided by engineer. @@ -2891,6 +2907,7 @@ Non è reversibile! Incompatible version + Versione incompatibile No comment provided by engineer. @@ -3065,6 +3082,7 @@ Questo è il tuo link per il gruppo %@! Keep the app open to use it from desktop + Tieni aperta l'app per usarla dal desktop No comment provided by engineer. @@ -3133,10 +3151,12 @@ Questo è il tuo link per il gruppo %@! Linked desktop options + Opzioni del desktop collegato No comment provided by engineer. Linked desktops + Desktop collegati No comment provided by engineer. @@ -3710,6 +3730,7 @@ Questo è il tuo link per il gruppo %@! Paste desktop address + Incolla l'indirizzo desktop No comment provided by engineer. @@ -4309,6 +4330,7 @@ Questo è il tuo link per il gruppo %@! Scan QR code from desktop + Scansiona codice QR dal desktop No comment provided by engineer. @@ -4533,6 +4555,7 @@ Questo è il tuo link per il gruppo %@! Session code + Codice di sessione No comment provided by engineer. @@ -5034,6 +5057,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. This device name + Il nome di questo dispositivo No comment provided by engineer. @@ -5236,10 +5260,12 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Unlink + Scollega No comment provided by engineer. Unlink desktop? + Scollegare il desktop? No comment provided by engineer. @@ -5334,6 +5360,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Use from desktop + Usa dal desktop No comment provided by engineer. @@ -5368,10 +5395,12 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Verify code with desktop + Verifica il codice con il desktop No comment provided by engineer. Verify connection + Verifica la connessione No comment provided by engineer. @@ -5381,6 +5410,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Verify connections + Verifica le connessioni No comment provided by engineer. @@ -6576,6 +6606,7 @@ I server di SimpleX non possono vedere il tuo profilo. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6748,7 @@ I server di SimpleX non possono vedere il tuo profilo. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX usa l'accesso alla rete locale per consentire di usare il profilo di chat tramite l'app desktop sulla stessa rete. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 37b942f7d1..bae6d393d0 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -383,9 +383,9 @@ - などなど! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 5119dc9a3f..8b6633c3db 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -299,10 +299,12 @@ (new) + (nieuw) No comment provided by engineer. (this device v%@) + (dit apparaat v%@) No comment provided by engineer. @@ -393,9 +395,9 @@ - en meer! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. @@ -885,6 +887,7 @@ Bad desktop address + Onjuist desktopadres No comment provided by engineer. @@ -1193,6 +1196,7 @@ Connect to desktop + Verbinden met desktop No comment provided by engineer. @@ -1241,10 +1245,12 @@ Dit is uw eigen eenmalige link! Connected desktop + Verbonden desktop No comment provided by engineer. Connected to desktop + Verbonden met desktop No comment provided by engineer. @@ -1259,6 +1265,7 @@ Dit is uw eigen eenmalige link! Connecting to desktop + Verbinding maken met desktop No comment provided by engineer. @@ -1283,6 +1290,7 @@ Dit is uw eigen eenmalige link! Connection terminated + Verbinding beëindigd No comment provided by engineer. @@ -1781,14 +1789,17 @@ Dit kan niet ongedaan gemaakt worden! Desktop address + Desktop adres No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Desktop-app-versie %@ is niet compatibel met deze app. No comment provided by engineer. Desktop devices + Desktop apparaten No comment provided by engineer. @@ -1883,6 +1894,7 @@ Dit kan niet ongedaan gemaakt worden! Disconnect desktop? + Desktop loskoppelen? No comment provided by engineer. @@ -1892,6 +1904,7 @@ Dit kan niet ongedaan gemaakt worden! Discover on network + Ontdek via netwerk No comment provided by engineer. @@ -2066,10 +2079,12 @@ Dit kan niet ongedaan gemaakt worden! Encryption re-negotiation error + Fout bij heronderhandeling van codering message decrypt error item Encryption re-negotiation failed. + Opnieuw onderhandelen over de codering is mislukt. No comment provided by engineer. @@ -2104,6 +2119,7 @@ Dit kan niet ongedaan gemaakt worden! Enter this device name… + Voer deze apparaatnaam in… No comment provided by engineer. @@ -2891,6 +2907,7 @@ Dit kan niet ongedaan gemaakt worden! Incompatible version + Incompatibele versie No comment provided by engineer. @@ -3065,6 +3082,7 @@ Dit is jouw link voor groep %@! Keep the app open to use it from desktop + Houd de app geopend om deze vanaf de desktop te gebruiken No comment provided by engineer. @@ -3133,10 +3151,12 @@ Dit is jouw link voor groep %@! Linked desktop options + Gekoppelde desktop opties No comment provided by engineer. Linked desktops + Gelinkte desktops No comment provided by engineer. @@ -3710,6 +3730,7 @@ Dit is jouw link voor groep %@! Paste desktop address + Desktopadres plakken No comment provided by engineer. @@ -4309,6 +4330,7 @@ Dit is jouw link voor groep %@! Scan QR code from desktop + Scan QR-code vanaf uw desktop No comment provided by engineer. @@ -4533,6 +4555,7 @@ Dit is jouw link voor groep %@! Session code + Sessie code No comment provided by engineer. @@ -5034,6 +5057,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This device name + Deze apparaatnaam No comment provided by engineer. @@ -5236,10 +5260,12 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Unlink + Ontkoppelen No comment provided by engineer. Unlink desktop? + Desktop ontkoppelen? No comment provided by engineer. @@ -5334,6 +5360,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Use from desktop + Gebruik vanaf desktop No comment provided by engineer. @@ -5368,10 +5395,12 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Verify code with desktop + Code verifiëren met desktop No comment provided by engineer. Verify connection + Controleer de verbinding No comment provided by engineer. @@ -5381,6 +5410,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Verify connections + Controleer verbindingen No comment provided by engineer. @@ -6576,6 +6606,7 @@ SimpleX servers kunnen uw profiel niet zien. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6748,7 @@ SimpleX servers kunnen uw profiel niet zien. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX maakt gebruik van lokale netwerktoegang om het gebruik van een gebruikerschatprofiel via de desktop-app op hetzelfde netwerk mogelijk te maken. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 518addb4ca..22d10aa434 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -393,9 +393,9 @@ - i więcej! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 7744fa908a..7fd4cd51b3 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -386,9 +386,9 @@ - и прочее! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 3c37295754..6ada6dbbbe 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -377,9 +377,9 @@ - และอื่น ๆ! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index d6f60cb702..868f420630 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -382,9 +382,9 @@ - і багато іншого! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 1e9bd9ac1e..77374aefa5 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -386,9 +386,9 @@ - 以及更多! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 256a64a663..eb77c2fe1e 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -43,6 +43,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nuovo)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(questo dispositivo v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +554,9 @@ /* No comment provided by engineer. */ "Back" = "Indietro"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Indirizzo desktop errato"; + /* integrity error chat item */ "bad message hash" = "hash del messaggio errato"; @@ -771,6 +780,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Connetti in incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Connetti al desktop"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "connettiti agli sviluppatori di SimpleX Chat."; @@ -801,9 +813,15 @@ /* No comment provided by engineer. */ "connected" = "connesso/a"; +/* No comment provided by engineer. */ +"Connected desktop" = "Desktop connesso"; + /* rcv group event chat item */ "connected directly" = "si è connesso/a direttamente"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Connesso al desktop"; + /* No comment provided by engineer. */ "connecting" = "in connessione"; @@ -828,6 +846,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connessione al server… (errore: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Connessione al desktop"; + /* chat list item title */ "connecting…" = "in connessione…"; @@ -846,6 +867,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Richiesta di connessione inviata!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Connessione terminata"; + /* No comment provided by engineer. */ "Connection timeout" = "Connessione scaduta"; @@ -1173,6 +1197,15 @@ /* No comment provided by engineer. */ "Description" = "Descrizione"; +/* No comment provided by engineer. */ +"Desktop address" = "Indirizzo desktop"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "La versione dell'app desktop %@ non è compatibile con questa app."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Dispositivi desktop"; + /* No comment provided by engineer. */ "Develop" = "Sviluppa"; @@ -1236,9 +1269,15 @@ /* server test step */ "Disconnect" = "Disconnetti"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Disconnettere il desktop?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Scopri ed unisciti ai gruppi"; +/* No comment provided by engineer. */ +"Discover on network" = "Trova nella rete"; + /* No comment provided by engineer. */ "Do it later" = "Fallo dopo"; @@ -1374,6 +1413,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "rinegoziazione della crittografia consentita per %@"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Errore di rinegoziazione crittografia"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Rinegoziazione crittografia fallita."; + /* chat item text */ "encryption re-negotiation required" = "richiesta rinegoziazione della crittografia"; @@ -1404,6 +1449,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Inserisci il server a mano"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Inserisci il nome di questo dispositivo…"; + /* placeholder */ "Enter welcome message…" = "Inserisci il messaggio di benvenuto…"; @@ -1893,6 +1941,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Versione del database incompatibile"; +/* No comment provided by engineer. */ +"Incompatible version" = "Versione incompatibile"; + /* PIN entry */ "Incorrect passcode" = "Codice di accesso errato"; @@ -2028,6 +2079,9 @@ /* No comment provided by engineer. */ "Joining group" = "Ingresso nel gruppo"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Tieni aperta l'app per usarla dal desktop"; + /* No comment provided by engineer. */ "Keep your connections" = "Mantieni le tue connessioni"; @@ -2064,6 +2118,12 @@ /* No comment provided by engineer. */ "Limitations" = "Limitazioni"; +/* No comment provided by engineer. */ +"Linked desktop options" = "Opzioni del desktop collegato"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Desktop collegati"; + /* No comment provided by engineer. */ "LIVE" = "IN DIRETTA"; @@ -2462,6 +2522,9 @@ /* No comment provided by engineer. */ "Paste" = "Incolla"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Incolla l'indirizzo desktop"; + /* No comment provided by engineer. */ "Paste image" = "Incolla immagine"; @@ -2846,6 +2909,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Scansiona codice QR"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Scansiona codice QR dal desktop"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scansiona il codice di sicurezza dall'app del tuo contatto."; @@ -2990,6 +3056,9 @@ /* No comment provided by engineer. */ "Servers" = "Server"; +/* No comment provided by engineer. */ +"Session code" = "Codice di sessione"; + /* No comment provided by engineer. */ "Set 1 day" = "Imposta 1 giorno"; @@ -3299,6 +3368,9 @@ /* notification title */ "this contact" = "questo contatto"; +/* No comment provided by engineer. */ +"This device name" = "Il nome di questo dispositivo"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Questo gruppo ha più di %lld membri, le ricevute di consegna non vengono inviate."; @@ -3416,6 +3488,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "A meno che il tuo contatto non abbia eliminato la connessione o che questo link non sia già stato usato, potrebbe essere un errore; per favore segnalalo.\nPer connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile."; +/* No comment provided by engineer. */ +"Unlink" = "Scollega"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Scollegare il desktop?"; + /* No comment provided by engineer. */ "Unlock" = "Sblocca"; @@ -3470,6 +3548,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Usa per connessioni nuove"; +/* No comment provided by engineer. */ +"Use from desktop" = "Usa dal desktop"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Usa interfaccia di chiamata iOS"; @@ -3491,12 +3572,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Utilizzo dei server SimpleX Chat."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Verifica il codice con il desktop"; + +/* No comment provided by engineer. */ +"Verify connection" = "Verifica la connessione"; + /* No comment provided by engineer. */ "Verify connection security" = "Verifica la sicurezza della connessione"; +/* No comment provided by engineer. */ +"Verify connections" = "Verifica le connessioni"; + /* No comment provided by engineer. */ "Verify security code" = "Verifica codice di sicurezza"; diff --git a/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings index fa6a1c2452..8830d77060 100644 --- a/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX usa Face ID per l'autenticazione locale"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX usa l'accesso alla rete locale per consentire di usare il profilo di chat tramite l'app desktop sulla stessa rete."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX ha bisogno dell'accesso al microfono per le chiamate audio e video e per registrare messaggi vocali."; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 302d7f74e9..4366d6cb93 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -43,6 +43,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nieuw)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(dit apparaat v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +554,9 @@ /* No comment provided by engineer. */ "Back" = "Terug"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Onjuist desktopadres"; + /* integrity error chat item */ "bad message hash" = "Onjuiste bericht hash"; @@ -771,6 +780,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Verbind incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Verbinden met desktop"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "maak verbinding met SimpleX Chat-ontwikkelaars."; @@ -801,9 +813,15 @@ /* No comment provided by engineer. */ "connected" = "verbonden"; +/* No comment provided by engineer. */ +"Connected desktop" = "Verbonden desktop"; + /* rcv group event chat item */ "connected directly" = "direct verbonden"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Verbonden met desktop"; + /* No comment provided by engineer. */ "connecting" = "Verbinden"; @@ -828,6 +846,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Verbinden met server... (fout: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Verbinding maken met desktop"; + /* chat list item title */ "connecting…" = "Verbinden…"; @@ -846,6 +867,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Verbindingsverzoek verzonden!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Verbinding beëindigd"; + /* No comment provided by engineer. */ "Connection timeout" = "Timeout verbinding"; @@ -1173,6 +1197,15 @@ /* No comment provided by engineer. */ "Description" = "Beschrijving"; +/* No comment provided by engineer. */ +"Desktop address" = "Desktop adres"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "Desktop-app-versie %@ is niet compatibel met deze app."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Desktop apparaten"; + /* No comment provided by engineer. */ "Develop" = "Ontwikkelen"; @@ -1236,9 +1269,15 @@ /* server test step */ "Disconnect" = "verbinding verbreken"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Desktop loskoppelen?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; +/* No comment provided by engineer. */ +"Discover on network" = "Ontdek via netwerk"; + /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1374,6 +1413,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "versleuteling heronderhandeling toegestaan voor % @"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Fout bij heronderhandeling van codering"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Opnieuw onderhandelen over de codering is mislukt."; + /* chat item text */ "encryption re-negotiation required" = "heronderhandeling van versleuteling vereist"; @@ -1404,6 +1449,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Voer de server handmatig in"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Voer deze apparaatnaam in…"; + /* placeholder */ "Enter welcome message…" = "Welkomst bericht invoeren…"; @@ -1893,6 +1941,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Incompatibele database versie"; +/* No comment provided by engineer. */ +"Incompatible version" = "Incompatibele versie"; + /* PIN entry */ "Incorrect passcode" = "Onjuiste toegangscode"; @@ -2028,6 +2079,9 @@ /* No comment provided by engineer. */ "Joining group" = "Deel nemen aan groep"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Houd de app geopend om deze vanaf de desktop te gebruiken"; + /* No comment provided by engineer. */ "Keep your connections" = "Behoud uw verbindingen"; @@ -2064,6 +2118,12 @@ /* No comment provided by engineer. */ "Limitations" = "Beperkingen"; +/* No comment provided by engineer. */ +"Linked desktop options" = "Gekoppelde desktop opties"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Gelinkte desktops"; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2462,6 +2522,9 @@ /* No comment provided by engineer. */ "Paste" = "Plakken"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Desktopadres plakken"; + /* No comment provided by engineer. */ "Paste image" = "Afbeelding plakken"; @@ -2846,6 +2909,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Scan QR-code"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Scan QR-code vanaf uw desktop"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scan de beveiligingscode van de app van uw contact."; @@ -2990,6 +3056,9 @@ /* No comment provided by engineer. */ "Servers" = "Servers"; +/* No comment provided by engineer. */ +"Session code" = "Sessie code"; + /* No comment provided by engineer. */ "Set 1 day" = "Stel 1 dag in"; @@ -3299,6 +3368,9 @@ /* notification title */ "this contact" = "dit contact"; +/* No comment provided by engineer. */ +"This device name" = "Deze apparaatnaam"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Deze groep heeft meer dan %lld -leden, ontvangstbevestigingen worden niet verzonden."; @@ -3416,6 +3488,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Tenzij uw contact de verbinding heeft verwijderd of deze link al is gebruikt, kan het een bug zijn. Meld het alstublieft.\nOm verbinding te maken, vraagt u uw contact om een andere verbinding link te maken en te controleren of u een stabiele netwerkverbinding heeft."; +/* No comment provided by engineer. */ +"Unlink" = "Ontkoppelen"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Desktop ontkoppelen?"; + /* No comment provided by engineer. */ "Unlock" = "Ontgrendelen"; @@ -3470,6 +3548,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Gebruik voor nieuwe verbindingen"; +/* No comment provided by engineer. */ +"Use from desktop" = "Gebruik vanaf desktop"; + /* No comment provided by engineer. */ "Use iOS call interface" = "De iOS-oproepinterface gebruiken"; @@ -3491,12 +3572,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "SimpleX Chat servers gebruiken."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Code verifiëren met desktop"; + +/* No comment provided by engineer. */ +"Verify connection" = "Controleer de verbinding"; + /* No comment provided by engineer. */ "Verify connection security" = "Controleer de verbindingsbeveiliging"; +/* No comment provided by engineer. */ +"Verify connections" = "Controleer verbindingen"; + /* No comment provided by engineer. */ "Verify security code" = "Controleer de beveiligingscode"; diff --git a/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings index 38af6191e8..0b9e594567 100644 --- a/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX gebruikt Face-ID voor lokale authenticatie"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX maakt gebruik van lokale netwerktoegang om het gebruik van een gebruikerschatprofiel via de desktop-app op hetzelfde netwerk mogelijk te maken."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX heeft microfoon toegang nodig voor audio en video oproepen en om spraak berichten op te nemen."; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 4bcc51663f..08cc7f9820 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -203,7 +203,7 @@ Nombre y avatar diferentes, aislamiento de transporte. Eliminar contacto Eliminar servidor - Nombre Mostrado + Introduce tu nombre: conectado DISPOSITIVO Contraseña base de datos @@ -229,9 +229,9 @@ Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. %ds eliminado - ¿Conectar mediante enlace de contacto\? - ¿Conectar mediante enlace de grupo\? - ¿Conectar mediante enlace de invitación\? + ¿Conectar mediante dirección de contacto? + ¿Unirse al grupo? + ¿Conectar mediante enlace de invitación? Conectar conectado conectando @@ -413,7 +413,7 @@ PARA CONSOLA Error al cambiar rol SERVIDORES - Nombre mostrado del grupo: + Introduce un nombre para el grupo: Preferencias de grupo Los miembros del grupo pueden enviar mensajes directos. Los miembros del grupo pueden eliminar mensajes de forma irreversible. @@ -482,7 +482,7 @@ Abrir en aplicación móvil, después pulse Conectar en la aplicación.]]> Marcar como leído Marcar como no leído - Código QR inválido + Código QR no válido ¡Código de seguridad incorrecto! Sintaxis Markdown No @@ -838,7 +838,7 @@ Pulsa el botón Para iniciar un chat nuevo Cambiar servidor de recepción - El grupo está totalmente descentralizado: sólo es visible para los miembros. + Completamente descentralizado: sólo visible para los miembros. Para conectarte mediante enlace ¡Error en prueba del servidor! Algunos servidores no superaron la prueba: @@ -1408,4 +1408,63 @@ \n- mayor rapidez y estabilidad. Enviar mensaje directo conectado directamente + Expandir + Error en renegociación de cifrado + contacto eliminado + Error + Crear grupo + Crear perfil + Escritorio conectado + Nuevo dispositivo móvil + Dirección desktop + Sólo un dispositivo puede funcionar al mismo tiempo + ¿Unirse a tu grupo? + %d mensajes marcados como borrados + ¡El grupo ya existe! + ¡Ya está en proceso de conexión! + Versión incompatible + (nuevo)]]> + Opciones escritorio enlazado + Desktop enlazados + Descubrir en red + , y hay %d eventos más + ¿Conectar vía enlace? + ¡Ya está en proceso de unirse al grupo! + %d mensajes moderados por %s + ¿Conectarte a tí mismo? + Móviles enlazados + Desktop + Conectado al escritorio + Cargando archivo + Conectando a desktop + La renegociación de cifrado ha fallado. + Dispositivo desktop + ¿Corregir el nombre a %s? + Elimina %d mensajes? + Enlazar móvil + ¿Conectar con %1$s? + Bloquear + %d mensajes bloqueados + Bloquear miembro + Móvil conectado + Eliminar y notificar contacto + Grupo abierto + Conexión terminada + (este dispositivo v%s)]]> + ¡Los mensajes de %s serán mostrados! + Introduce el nombre de este dispositivo… + Error + Conectar a desktop + Desconectar + Bloquear miembro? + %d eventos de grupo + ¡Nombre no válido! + Conectado a móvil + Dirección de escritorio incorrecta + Dispositivo + Ruta archivo no valida. + ¿Desconectar desktop? + Los mensajes nuevos de %s estarán ocultos! + La versión de aplicación del desktop %s no es compatible con esta aplicación. + bloqueado \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 3cdd4676b7..093ca05646 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1461,4 +1461,48 @@ bloccato Errore di rinegoziazione crittografia Rinegoziazione crittografia fallita. + Desktop connesso + Nuovo dispositivo mobile + Indirizzo desktop + Solo un dispositivo può funzionare nello stesso momento + Usa dal desktop nell\'app mobile e scansiona il codice QR]]> + Versione incompatibile + (nuovo)]]> + Scollegare il desktop? + Opzioni del desktop collegato + Desktop collegati + Trova nella rete + Questo dispositivo + Cellulari collegati + Desktop + Connesso al desktop + Il nome di questo dispositivo + Caricamento del file + Connessione al desktop + Dispositivi desktop + Collega un cellulare + Usa dal desktop + Cellulare connesso + Codice di sessione + Connessione terminata + (questo dispositivo v%s)]]> + Scollega + Il nome del dispositivo sarà condiviso con il client mobile connesso. + Verifica il codice sul cellulare + Inserisci il nome di questo dispositivo… + Errore + Connetti al desktop + Disconnetti + Connesso al cellulare + Indirizzo desktop errato + Incolla l\'indirizzo desktop + Verifica il codice con il desktop + Scansiona codice QR dal desktop + Dispositivi + Scansiona dal cellulare + Verifica le connessioni + Disconnettere il desktop? + Si prega di attendere mentre il file viene caricato dal cellulare collegato + La versione dell\'app desktop %s non è compatibile con questa app. + Verifica la connessione \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 522b22f7ab..c112844395 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1459,4 +1459,48 @@ U heeft al een verbinding aangevraagd via dit adres! Fout bij heronderhandeling van codering Opnieuw onderhandelen over de codering is mislukt. + Verbonden desktop + Nieuw mobiel apparaat + Desktop adres + Er kan slechts één apparaat tegelijkertijd werken + Gebruik vanaf desktop in de mobiele app en scan de QR-code]]> + Incompatibele versie + (nieuw)]]> + Desktop ontkoppelen? + Gekoppelde desktop opties + Gelinkte desktops + Ontdek via netwerk + Dit apparaat + Gekoppelde mobiele apparaten + Desktop + Verbonden met desktop + Deze apparaatnaam + Het bestand laden + Verbinding maken met desktop + Desktop apparaten + Koppel een mobiel + Gebruik vanaf desktop + Verbonden mobiel + Sessie code + Verbinding beëindigd + (dit apparaat v%s)]]> + Ontkoppelen + De apparaatnaam wordt gedeeld met de verbonden mobiele client. + Verifieer de code op mobiel + Voer deze apparaatnaam in… + Fout + Verbinden met desktop + Verbinding verbreken + Verbonden met mobiel + Onjuist desktopadres + Desktopadres plakken + Code verifiëren met desktop + Scan QR-code vanaf uw desktop + Apparaten + Vanaf mobiel scannen + Controleer verbindingen + Desktop loskoppelen? + Wacht terwijl het bestand wordt geladen vanaf de gekoppelde mobiele telefoon + Desktop-app-versie %s is niet compatibel met deze app. + Controleer de verbinding \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index a4f990455f..e672c189a4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1461,4 +1461,48 @@ 已封禁 加密重协商错误 加密重协商失败了。 + 已连接的桌面 + 新移动设备 + 桌面地址 + 同一时刻只有一台设备可以工作 + 从桌面使用并扫描二维码]]> + 不兼容的版本 + (新)]]> + 取消链接桌面端? + 已链接桌面选项 + 已链接桌面 + 网络发现 + 此设备 + 已链接的移动设备 + 桌面 + 已连接到桌面 + 此设备名称 + 加载文件中 + 正连接到桌面 + 桌面设备 + 链接移动设备 + 从桌面端使用 + 已连接的移动设备 + 会话码 + 连接被终止 + (此设备 v%s)]]> + 取消链接 + 将和已连接的移动设备分享设备名。 + 在移动端验证代码 + 输入此设备名… + 错误 + 连接到桌面 + 断开连接 + 已连接到移动设备 + 糟糕的桌面地址 + 粘贴桌面地址 + 用桌面端验证代码 + 从桌面扫描二维码 + 设备 + 从移动端扫描 + 验证连接 + 断开桌面连接? + 从已链接移动设备加载文件时请稍候片刻 + 桌面应用版本 %s 不兼容此应用。 + 验证连接 \ No newline at end of file From 64520a4cf434040f9e94d9aebf5989d12473feda Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 21:12:43 +0000 Subject: [PATCH 118/174] core: 5.4.0.5, update simplexmq --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- stack.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 4652ee3112..05857ba8f3 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 08410671323c056bbcf1a3f2756aad810b522e25 + tag: 757b7eec81341d8560a326deab303bb6fb6a26a3 source-repository-package type: git diff --git a/package.yaml b/package.yaml index da9de37131..3af5afbd3e 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.4 +version: 5.4.0.5 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index de10eb829a..cf61c63764 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."08410671323c056bbcf1a3f2756aad810b522e25" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; + "https://github.com/simplex-chat/simplexmq.git"."757b7eec81341d8560a326deab303bb6fb6a26a3" = "0kqnxpyz8v43802fncqxdg6i2ni70yv7jg7a1nbkny1w937fwf40"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d33ffe8b54..e7288e2290 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.4.0.4 +version: 5.4.0.5 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/stack.yaml b/stack.yaml index 3006b8a61d..39c96ef75b 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 08410671323c056bbcf1a3f2756aad810b522e25 + commit: 757b7eec81341d8560a326deab303bb6fb6a26a3 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From c40bfb0f4361dd1228707dfd3b72c5dc8020db20 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 22 Nov 2023 05:49:39 +0800 Subject: [PATCH 119/174] android, desktop: show remote host info (#3423) * android, desktop: show remote host info * hide alerts too --- .../simplex/common/platform/Share.android.kt | 3 - .../chat/simplex/common/model/SimpleXAPI.kt | 10 +- .../views/chatlist/ChatListNavLinkView.kt | 19 +- .../common/views/helpers/AlertManager.kt | 224 +++++++++++------- .../common/views/newchat/ScanToConnectView.kt | 40 +++- .../commonMain/resources/MR/base/strings.xml | 1 + .../kotlin/chat/simplex/common/DesktopApp.kt | 4 +- .../simplex/common/platform/Share.desktop.kt | 1 - .../views/chat/item/ChatItemView.desktop.kt | 1 - 9 files changed, 191 insertions(+), 112 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index eb6ed0bbf8..f0c5ea6941 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -8,15 +8,12 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler -import androidx.core.content.FileProvider -import androidx.core.net.toUri import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import java.io.BufferedOutputStream import java.io.File import chat.simplex.res.MR -import java.io.ByteArrayOutputStream actual fun ClipboardManager.shareText(text: String) { val sendIntent: Intent = Intent().apply { 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 9298388df0..5698e8cb0f 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,7 +4,6 @@ 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.ChatModel.remoteHostId import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* @@ -1845,7 +1844,13 @@ object ChatController { switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.RemoteHostStopped -> { + val disconnectedHost = chatModel.remoteHosts.firstOrNull { it.remoteHostId == r.remoteHostId_ } chatModel.newRemoteHostPairing.value = null + if (disconnectedHost != null) { + showToast( + generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) + ) + } if (chatModel.currentRemoteHost.value != null) { chatModel.currentRemoteHost.value = null switchUIRemoteHost(null) @@ -1968,6 +1973,9 @@ object ChatController { suspend fun switchUIRemoteHost(rhId: Long?) { // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null + ModalManager.center.closeModals() + ModalManager.end.closeModals() + AlertManager.shared.alertViews.clear() chatModel.currentRemoteHost.value = switchRemoteHost(rhId) reloadRemoteHosts() val user = apiGetActiveUser(rhId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index e121f9ee78..9d662758f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -129,7 +129,7 @@ fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) - GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() + GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId) else -> withBGApi { openChat(rhId, ChatInfo.Group(groupInfo), chatModel) } } } @@ -538,7 +538,8 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) } } - } + }, + hostDevice = hostDevice(rhId), ) } @@ -644,7 +645,8 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - } + }, + hostDevice = hostDevice(rhId), ) } @@ -654,7 +656,8 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactI chatModel.updateContact(rhId, contact) AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), - text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) + text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted), + hostDevice = hostDevice(rhId), ) return true } @@ -674,7 +677,8 @@ fun acceptGroupInvitationAlertDialog(rhId: Long?, groupInfo: GroupInfo, chatMode } }, dismissText = generalGetString(MR.strings.delete_verb), - onDismiss = { deleteGroup(rhId, groupInfo, chatModel) } + onDismiss = { deleteGroup(rhId, groupInfo, chatModel) }, + hostDevice = hostDevice(rhId), ) } @@ -700,10 +704,11 @@ fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { } } -fun groupInvitationAcceptedAlert() { +fun groupInvitationAcceptedAlert(rhId: Long?) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.joining_group), - generalGetString(MR.strings.youve_accepted_group_invitation_connecting_to_inviting_group_member) + generalGetString(MR.strings.youve_accepted_group_invitation_connecting_to_inviting_group_member), + hostDevice = hostDevice(rhId), ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index fa9c89384d..287f19ffdb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -1,6 +1,5 @@ package chat.simplex.common.views.helpers -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,10 +13,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* +import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource class AlertManager { var alertViews = mutableStateListOf<(@Composable () -> Unit)>() @@ -40,8 +41,11 @@ class AlertManager { AlertDialog( onDismissRequest = this::hideAlert, title = alertTitle(title), - text = alertText(text), - buttons = buttons, + buttons = { + AlertContent(text, null, extraPadding = true) { + buttons() + } + }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) ) } @@ -51,30 +55,16 @@ class AlertManager { title: String, text: AnnotatedString? = null, onDismissRequest: (() -> Unit)? = null, + hostDevice: Pair? = null, buttons: @Composable () -> Unit, ) { showAlert { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, - title = { - Text( - title, - Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING), - textAlign = TextAlign.Center, - fontSize = 20.sp - ) - }, + title = alertTitle(title), buttons = { - Column( - Modifier - .padding(bottom = DEFAULT_PADDING) - ) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { - if (text != null) { - Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary) - } - buttons() - } + AlertContent(text, hostDevice, extraPadding = true) { + buttons() } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -90,30 +80,32 @@ class AlertManager { dismissText: String = generalGetString(MR.strings.cancel_verb), onDismiss: (() -> Unit)? = null, onDismissRequest: (() -> Unit)? = null, - destructive: Boolean = false + destructive: Boolean = false, + hostDevice: Pair? = null, ) { showAlert { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), - text = alertText(text), buttons = { - Row ( - Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF), - horizontalArrangement = Arrangement.SpaceBetween - ) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() + AlertContent(text, hostDevice) { + Row( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + TextButton(onClick = { + onDismiss?.invoke() + hideAlert() + }) { Text(dismissText) } + TextButton(onClick = { + onConfirm?.invoke() + hideAlert() + }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } - TextButton(onClick = { - onDismiss?.invoke() - hideAlert() - }) { Text(dismissText) } - TextButton(onClick = { - onConfirm?.invoke() - hideAlert() - }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -135,20 +127,21 @@ class AlertManager { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), - text = alertText(text), buttons = { - Column( - Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING_HALF).padding(top = DEFAULT_PADDING, bottom = 2.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - TextButton(onClick = { - onDismiss?.invoke() - hideAlert() - }) { Text(dismissText) } - TextButton(onClick = { - onConfirm?.invoke() - hideAlert() - }) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) } + AlertContent(text, null) { + Column( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING_HALF).padding(top = DEFAULT_PADDING, bottom = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextButton(onClick = { + onDismiss?.invoke() + hideAlert() + }) { Text(dismissText) } + TextButton(onClick = { + onConfirm?.invoke() + hideAlert() + }) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) } + } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -158,29 +151,31 @@ class AlertManager { fun showAlertMsg( title: String, text: String? = null, - confirmText: String = generalGetString(MR.strings.ok) + confirmText: String = generalGetString(MR.strings.ok), + hostDevice: Pair? = null, ) { showAlert { AlertDialog( onDismissRequest = this::hideAlert, title = alertTitle(title), - text = alertText(text), buttons = { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - Row( - Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF), - horizontalArrangement = Arrangement.Center - ) { - TextButton( - onClick = { - hideAlert() - }, - Modifier.focusRequester(focusRequester) + AlertContent(text, hostDevice, extraPadding = true) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Row( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.Center ) { - Text(confirmText, color = Color.Unspecified) + TextButton( + onClick = { + hideAlert() + }, + Modifier.focusRequester(focusRequester) + ) { + Text(confirmText, color = Color.Unspecified) + } } } }, @@ -191,16 +186,17 @@ class AlertManager { fun showAlertMsgWithProgress( title: String, - text: String? = null + text: String? = null, ) { showAlert { AlertDialog( onDismissRequest = this::hideAlert, title = alertTitle(title), - text = alertText(text), buttons = { - Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { - CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + AlertContent(text, null) { + Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + } } } ) @@ -211,7 +207,8 @@ class AlertManager { title: StringResource, text: StringResource? = null, confirmText: StringResource = MR.strings.ok, - ) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText)) + hostDevice: Pair? = null, + ) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), hostDevice) @Composable fun showInView() { @@ -234,18 +231,75 @@ private fun alertTitle(title: String): (@Composable () -> Unit)? { } } -private fun alertText(text: String?): (@Composable () -> Unit)? { - return if (text == null) { - null - } else { - ({ - Text( - escapedHtmlToAnnotatedString(text, LocalDensity.current), - Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 16.sp, - color = MaterialTheme.colors.secondary - ) - }) +@Composable +private fun AlertContent(text: String?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { + Column( + Modifier + .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) + ) { + if (appPlatform.isDesktop) { + HostDeviceTitle(hostDevice, extraPadding = extraPadding) + } else { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + if (text != null) { + Text( + escapedHtmlToAnnotatedString(text, LocalDensity.current), + Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.secondary + ) + } + } + content() + } +} + +@Composable +private fun AlertContent(text: AnnotatedString?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { + Column( + Modifier + .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) + ) { + if (appPlatform.isDesktop) { + HostDeviceTitle(hostDevice, extraPadding = extraPadding) + } else { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + if (text != null) { + Text( + text, + Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.secondary + ) + } + } + content() + } +} + +fun hostDevice(rhId: Long?): Pair? = if (rhId == null && chatModel.remoteHosts.isNotEmpty()) { + null to ChatModel.controller.appPrefs.deviceNameForRemoteAccess.get()!! +} else if (rhId == null) { + null +} else { + rhId to (chatModel.remoteHosts.firstOrNull { it.remoteHostId == rhId }?.hostDeviceName?.ifEmpty { rhId.toString() } ?: rhId.toString()) +} + +@Composable +private fun HostDeviceTitle(hostDevice: Pair?, extraPadding: Boolean = false) { + if (hostDevice != null) { + Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(10.dp)) + Text(hostDevice.second, color = MaterialTheme.colors.secondary) + } + } else { + Spacer(Modifier.height(if (extraPadding) DEFAULT_PADDING * 2 else 0.dp)) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index bd111c9c32..fd39613629 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -4,7 +4,6 @@ import SectionBottomSpacer import SectionItemView import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview -import chat.simplex.common.platform.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -17,7 +16,7 @@ import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* -import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* @@ -65,6 +64,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -82,12 +82,14 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), + hostDevice = hostDevice(rhId), ) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_already_connecting), - generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link), + hostDevice = hostDevice(rhId), ) } } @@ -97,7 +99,8 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), + hostDevice = hostDevice(rhId), ) } } @@ -124,6 +127,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -143,6 +147,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -159,7 +164,8 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), + hostDevice = hostDevice(rhId), ) } is ContactAddressPlan.Known -> { @@ -168,7 +174,8 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), + hostDevice = hostDevice(rhId), ) } is ContactAddressPlan.ContactViaAddress -> { @@ -190,7 +197,8 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } } + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -215,6 +223,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -236,7 +245,8 @@ suspend fun planAndConnect( } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_already_joining_the_group), - generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), + hostDevice = hostDevice(rhId), ) } } @@ -246,7 +256,8 @@ suspend fun planAndConnect( openKnownGroup(chatModel, rhId, close, groupInfo) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) + String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName), + hostDevice = hostDevice(rhId), ) } } @@ -284,7 +295,8 @@ suspend fun connectViaUri( ConnectionLinkType.CONTACT -> generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) ConnectionLinkType.INVITATION -> generalGetString(MR.strings.you_will_be_connected_when_your_contacts_device_is_online) ConnectionLinkType.GROUP -> generalGetString(MR.strings.you_will_be_connected_when_group_host_device_is_online) - } + }, + hostDevice = hostDevice(rhId), ) } return r @@ -336,7 +348,8 @@ fun askCurrentOrIncognitoProfileAlert( Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - } + }, + hostDevice = hostDevice(rhId), ) } @@ -411,7 +424,8 @@ fun ownGroupLinkConfirmConnect( Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - } + }, + hostDevice = hostDevice(rhId), ) } 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 177dcb55f7..55cf4e6c98 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1668,6 +1668,7 @@ Unlink desktop? Unlink Disconnect + %s]]> Disconnect desktop? Only one device can work at the same time Use from desktop in mobile app and scan QR code]]> diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 2931e0e014..1a95317a6a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.ChatController @@ -19,6 +20,7 @@ import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.FileDialogChooser +import chat.simplex.common.views.helpers.escapedHtmlToAnnotatedString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -80,7 +82,7 @@ fun showApp() = application { if (toast != null) { Box(Modifier.fillMaxSize().padding(bottom = 20.dp), contentAlignment = Alignment.BottomCenter) { Text( - toast.first, + escapedHtmlToAnnotatedString(toast.first, LocalDensity.current), Modifier.background(MaterialTheme.colors.primary, RoundedCornerShape(100)).padding(vertical = 5.dp, horizontal = 10.dp), color = MaterialTheme.colors.onPrimary, style = MaterialTheme.typography.body1 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index a91bc5a761..f7728f9c63 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -4,7 +4,6 @@ import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.AnnotatedString import chat.simplex.common.model.* -import chat.simplex.common.views.helpers.getAppFileUri import chat.simplex.common.views.helpers.withApi import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 91efdf7908..905a6e3520 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -19,7 +19,6 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.io.File -import java.util.* @Composable actual fun ReactionIcon(text: String, fontSize: TextUnit) { From c1d89f2c0f27497da922fb596ba9e9099d004eb0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:21:01 +0000 Subject: [PATCH 120/174] ios: move hs_init to background thread (#3411) --- apps/ios/Shared/SimpleXApp.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 13e681ae25..f030f105bf 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -26,7 +26,9 @@ struct SimpleXApp: App { @State private var showInitializationView = false init() { - hs_init(0, nil) + DispatchQueue.global(qos: .background).sync { + hs_init(0, nil) + } UserDefaults.standard.register(defaults: appDefaults) setGroupDefaults() registerGroupDefaults() From aade3d359f0cf56e223a76a1abd7e6d3edaf6366 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 23:32:33 +0000 Subject: [PATCH 121/174] v5.4-beta.4: ios 182, android 161, desktop 17 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e98704d309..d2456b6966 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -118,11 +118,6 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; - 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A282B04FE2D00A71D61 /* libgmp.a */; }; - 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */; }; - 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */; }; - 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */; }; - 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -148,6 +143,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 */; }; + 5CF077FB2B0D60C100105111 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F62B0D60C000105111 /* libgmpxx.a */; }; + 5CF077FC2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F72B0D60C000105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a */; }; + 5CF077FD2B0D60C100105111 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F82B0D60C000105111 /* libgmp.a */; }; + 5CF077FE2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F92B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a */; }; + 5CF077FF2B0D60C100105111 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077FA2B0D60C100105111 /* libffi.a */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -399,11 +399,6 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; - 5CDA5A282B04FE2D00A71D61 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a"; sourceTree = ""; }; - 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a"; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -430,6 +425,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 = ""; }; + 5CF077F62B0D60C000105111 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF077F72B0D60C000105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a"; sourceTree = ""; }; + 5CF077F82B0D60C000105111 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CF077F92B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a"; sourceTree = ""; }; + 5CF077FA2B0D60C100105111 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */, + 5CF077FB2B0D60C100105111 /* libgmpxx.a in Frameworks */, + 5CF077FD2B0D60C100105111 /* libgmp.a in Frameworks */, + 5CF077FE2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */, - 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */, - 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */, + 5CF077FF2B0D60C100105111 /* libffi.a in Frameworks */, + 5CF077FC2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -575,11 +575,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */, - 5CDA5A282B04FE2D00A71D61 /* libgmp.a */, - 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */, - 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */, - 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */, + 5CF077FA2B0D60C100105111 /* libffi.a */, + 5CF077F82B0D60C000105111 /* libgmp.a */, + 5CF077F62B0D60C000105111 /* libgmpxx.a */, + 5CF077F92B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a */, + 5CF077F72B0D60C000105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a */, ); path = Libraries; sourceTree = ""; @@ -1494,7 +1494,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1537,7 +1537,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1618,7 +1618,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1650,7 +1650,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1682,7 +1682,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1728,7 +1728,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b16636d0d6..9c817f08f4 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.3 -android.version_code=160 +android.version_name=5.4-beta.4 +android.version_code=161 -desktop.version_name=5.4-beta.3 -desktop.version_code=16 +desktop.version_name=5.4-beta.4 +desktop.version_code=17 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 69acac53317c89f7b6b4770aae453c68d6df02fa Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:19:39 +0400 Subject: [PATCH 122/174] android: remove unused strings (#3424) --- .../src/commonMain/resources/MR/base/strings.xml | 10 ---------- 1 file changed, 10 deletions(-) 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 55cf4e6c98..0509bc1ebe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1608,16 +1608,6 @@ Toggle incognito when connecting. 6 new interface languages Arabic, Bulgarian, Finnish, Hebrew, Thai and Ukrainian - thanks to the users and Weblate. - Connect desktop and mobile! - Use your mobile app chat profile via desktop app. - Group improvements - Create groups incognito, block group members, faster join via link, and more. - Notify about contact deletion - You can optionally notify contacts when deleting them. - Checking SimpleX links - Detection of previously used and your own SimpleX links. - Spaces in profile names - You can now add spaces to your profile name. Link mobile and desktop apps! 🔗 Via secure quantum resistant protocol. Better groups From 9442121efa12e51770debfd9356255d5290f0bdb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 12:14:49 +0000 Subject: [PATCH 123/174] desktop: do not switch remote host when inactive host is disconnected (#3426) --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5698e8cb0f..4aed002619 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 @@ -1851,7 +1851,7 @@ object ChatController { generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) ) } - if (chatModel.currentRemoteHost.value != null) { + if (chatModel.remoteHostId == r.remoteHostId_) { chatModel.currentRemoteHost.value = null switchUIRemoteHost(null) } From 48d7afc959ced8984c62ae034b25f1fed391c50f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:35:32 +0800 Subject: [PATCH 124/174] desktop: enhancements to remote hosts experience (#3428) --- .../java/chat/simplex/app/MainActivity.kt | 2 +- .../main/java/chat/simplex/app/SimplexApp.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 3 +- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../chat/simplex/common/views/TerminalView.kt | 2 +- .../chat/simplex/common/views/WelcomeView.kt | 2 +- .../simplex/common/views/chat/ScanCodeView.kt | 2 +- .../common/views/chat/VerifyCodeView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 2 +- .../common/views/chatlist/UserPicker.kt | 23 +++++++++-- .../common/views/database/DatabaseView.kt | 2 +- .../common/views/helpers/AlertManager.kt | 2 +- .../common/views/helpers/CloseSheetBar.kt | 39 +++++++++++++------ .../simplex/common/views/helpers/Utils.kt | 2 +- .../common/views/newchat/AddContactView.kt | 2 +- .../common/views/newchat/AddGroupView.kt | 7 +++- .../newchat/ContactConnectionInfoView.kt | 6 ++- .../common/views/newchat/PasteToConnect.kt | 2 +- .../common/views/newchat/ScanToConnectView.kt | 2 +- .../common/views/onboarding/HowItWorks.kt | 2 +- .../common/views/remote/ConnectMobileView.kt | 10 ++--- .../common/views/usersettings/HelpView.kt | 2 +- .../views/usersettings/ProtocolServersView.kt | 16 +++++--- .../views/usersettings/ScanProtocolServer.kt | 2 +- .../common/views/usersettings/SettingsView.kt | 2 +- .../views/usersettings/UserAddressView.kt | 6 ++- .../views/usersettings/VersionInfoView.kt | 2 +- 27 files changed, 99 insertions(+), 51 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 4c5d595a83..a84590fb85 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -126,7 +126,7 @@ fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId, uri.toURI(), ChatModel) + if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId(), uri.toURI(), ChatModel) } } } 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 13908f69bf..b6afab4eaa 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 @@ -57,7 +57,7 @@ class SimplexApp: Application(), LifecycleEventObserver { updatingChatsMutex.withLock { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId)) + val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId())) /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ if (chatModel.currentUser.value?.userId == currentUserId) { val currentChatId = chatModel.chatId.value 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 660e7de151..dca3bc5b1a 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 @@ -111,7 +111,8 @@ object ChatModel { // remote controller val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) - val remoteHostId: Long? get() = currentRemoteHost?.value?.remoteHostId + val remoteHostId: Long? @Composable get() = remember { currentRemoteHost }.value?.remoteHostId + fun remoteHostId(): Long? = currentRemoteHost.value?.remoteHostId val newRemoteHostPairing = mutableStateOf?>(null) val remoteCtrlSession = mutableStateOf(null) 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 4aed002619..26595880db 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 @@ -1625,7 +1625,7 @@ object ChatController { || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { withApi { receiveFile(rhId, r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } } - if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId != rhId)) { + if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } @@ -1913,7 +1913,7 @@ object ChatController { } private fun activeUser(rhId: Long?, user: UserLike): Boolean = - rhId == chatModel.remoteHostId && user.userId == chatModel.currentUser.value?.userId + rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index ec2082557a..59f62f9c92 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -54,7 +54,7 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState Unit) { fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { - val rhId = chatModel.remoteHostId + val rhId = chatModel.remoteHostId() val user = chatModel.controller.apiCreateActiveUser( rhId, Profile(displayName.trim(), "", null) ) ?: return@withApi diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 8ce39eea35..bb479d8eb3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -17,7 +17,7 @@ fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () .fillMaxSize() .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.scan_code), false) + AppBarTitle(stringResource(MR.strings.scan_code), withPadding = false) Box( Modifier .fillMaxWidth() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index 1b25c52cfe..e1840dd885 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -63,7 +63,7 @@ private fun VerifyCodeLayout( .verticalScroll(rememberScrollState()) .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.security_code), false) + AppBarTitle(stringResource(MR.strings.security_code), withPadding = false) val splitCode = splitToParts(connectionCode, 24) Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) { if (connectionVerified) { 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 f9502bf908..301f090336 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 @@ -53,7 +53,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val url = chatModel.appOpenUrl.value if (url != null) { chatModel.appOpenUrl.value = null - connectIfOpenedViaUri(chatModel.remoteHostId, url, chatModel) + connectIfOpenedViaUri(chatModel.remoteHostId(), url, chatModel) } } if (appPlatform.isDesktop) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 20e856df6e..46a333f61c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -26,8 +26,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* -import chat.simplex.common.views.remote.ConnectDesktopView -import chat.simplex.common.views.remote.connectMobileDevice +import chat.simplex.common.views.remote.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay @@ -84,7 +83,7 @@ fun UserPicker( .filter { it } .collect { try { - val updatedUsers = chatModel.controller.listUsers(chatModel.remoteHostId).sortedByDescending { it.user.activeUser } + val updatedUsers = chatModel.controller.listUsers(chatModel.remoteHostId()).sortedByDescending { it.user.activeUser } var same = users.size == updatedUsers.size if (same) { for (i in 0 until minOf(users.size, updatedUsers.size)) { @@ -213,6 +212,14 @@ fun UserPicker( userPickerState.value = AnimatedViewState.GONE } Divider(Modifier.requiredHeight(1.dp)) + } else if (remoteHosts.isEmpty()) { + LinkAMobilePickerItem { + ModalManager.start.showModal { + ConnectMobileView() + } + userPickerState.value = AnimatedViewState.GONE + } + Divider(Modifier.requiredHeight(1.dp)) } if (showSettings) { SettingsPickerItem(settingsClicked) @@ -384,6 +391,16 @@ private fun UseFromDesktopPickerItem(onClick: () -> Unit) { } } +@Composable +private fun LinkAMobilePickerItem(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.link_a_mobile) + Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { 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 1f4be29664..bc43310b8e 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 @@ -627,7 +627,7 @@ private fun afterSetCiTTL( try { updatingChatsMutex.withLock { // this is using current remote host on purpose - if it changes during update, it will load correct chats - val chats = m.controller.apiGetChats(m.remoteHostId) + val chats = m.controller.apiGetChats(m.remoteHostId()) m.updateChats(chats) } } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 287f19ffdb..10bbae6bfc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -88,7 +88,7 @@ class AlertManager { onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), buttons = { - AlertContent(text, hostDevice) { + AlertContent(text, hostDevice, true) { Row( Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceBetween diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt index 848b21eb44..1a29a334a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt @@ -14,6 +14,8 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource @Composable fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) { @@ -47,23 +49,38 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @ } @Composable -fun AppBarTitle(title: String, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f) { +fun AppBarTitle(title: String, hostDevice: Pair? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f) { val theme = CurrentColors.collectAsState() val titleColor = CurrentColors.collectAsState().value.appColors.title val brush = if (theme.value.base == DefaultTheme.SIMPLEX) Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f)) else // color is not updated when changing themes if I pass null here Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f)) - Text( - title, - Modifier - .fillMaxWidth() - .padding(bottom = bottomPadding, start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,), - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.h1.copy(brush = brush), - color = MaterialTheme.colors.primaryVariant, - textAlign = TextAlign.Center - ) + Column { + Text( + title, + Modifier + .fillMaxWidth() + .padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,), + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h1.copy(brush = brush), + color = MaterialTheme.colors.primaryVariant, + textAlign = TextAlign.Center + ) + if (hostDevice != null) { + HostDeviceTitle(hostDevice) + } + Spacer(Modifier.height(bottomPadding)) + } +} + +@Composable +private fun HostDeviceTitle(hostDevice: Pair, extraPadding: Boolean = false) { + Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(10.dp)) + Text(hostDevice.second, color = MaterialTheme.colors.secondary) + } } @Preview/*( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index dbadec32f1..21b71d876b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -373,7 +373,7 @@ inline fun serializableSaver(): Saver = Saver( fun UriHandler.openVerifiedSimplexUri(uri: String) { val URI = try { URI.create(uri) } catch (e: Exception) { null } if (URI != null) { - connectIfOpenedViaUri(chatModel.remoteHostId, URI, ChatModel) + connectIfOpenedViaUri(chatModel.remoteHostId(), URI, ChatModel) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index a539bf4984..84080d5b97 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -79,7 +79,7 @@ fun AddContactLayout( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.SpaceBetween, ) { - AppBarTitle(stringResource(MR.strings.add_contact)) + AppBarTitle(stringResource(MR.strings.add_contact), hostDevice(rh?.remoteHostId)) SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) { if (connReq.isNotEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index aa5494e5a9..4f71e81b0d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -58,6 +58,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { } }, incognitoPref = chatModel.controller.appPrefs.incognito, + rhId, close ) } @@ -66,6 +67,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { fun AddGroupLayout( createGroup: (Boolean, GroupProfile) -> Unit, incognitoPref: SharedPreference, + rhId: Long?, close: () -> Unit ) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) @@ -98,7 +100,7 @@ fun AddGroupLayout( .verticalScroll(rememberScrollState()) .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.create_secret_group_title)) + AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId)) Box( Modifier .fillMaxWidth() @@ -174,7 +176,8 @@ fun PreviewAddGroupLayout() { AddGroupLayout( createGroup = { _, _ -> }, incognitoPref = SharedPreference({ false }, {}), - close = {} + close = {}, + rhId = null, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 50419cdaff..5e9495e866 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -56,6 +56,7 @@ fun ContactConnectionInfoView( connReq = connReqInvitation, contactConnection = contactConnection, focusAlias = focusAlias, + rhId = rhId, deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, @@ -80,6 +81,7 @@ private fun ContactConnectionInfoLayout( connReq: String?, contactConnection: PendingContactConnection, focusAlias: Boolean, + rhId: Long?, deleteConnection: () -> Unit, onLocalAliasChanged: (String) -> Unit, share: () -> Unit, @@ -114,7 +116,8 @@ private fun ContactConnectionInfoLayout( stringResource( if (contactConnection.initiated) MR.strings.you_invited_a_contact else MR.strings.you_accepted_connection - ) + ), + hostDevice(rhId) ) Text( stringResource( @@ -185,6 +188,7 @@ private fun PreviewContactConnectionInfoView() { connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", contactConnection = PendingContactConnection.getSampleData(), focusAlias = false, + rhId = null, deleteConnection = {}, onLocalAliasChanged = {}, share = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index 3e4447d355..dacf937575 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -67,7 +67,7 @@ fun PasteToConnectLayout( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), verticalArrangement = Arrangement.SpaceBetween, ) { - AppBarTitle(stringResource(MR.strings.connect_via_link), false) + AppBarTitle(stringResource(MR.strings.connect_via_link), hostDevice(rhId), withPadding = false) Box(Modifier.padding(top = DEFAULT_PADDING, bottom = 6.dp)) { TextEditor( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index fd39613629..4deeda3e24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -469,7 +469,7 @@ fun ConnectContactLayout( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), verticalArrangement = Arrangement.SpaceBetween ) { - AppBarTitle(stringResource(MR.strings.scan_QR_code), false) + AppBarTitle(stringResource(MR.strings.scan_QR_code), hostDevice(rh?.remoteHostId), withPadding = false) Box( Modifier .fillMaxWidth() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index e3dfb2b736..6c76acc3e8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -26,7 +26,7 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? .fillMaxWidth() .padding(horizontal = DEFAULT_PADDING), ) { - AppBarTitle(stringResource(MR.strings.how_simplex_works), false) + AppBarTitle(stringResource(MR.strings.how_simplex_works), withPadding = false) ReadableText(MR.strings.many_people_asked_how_can_it_deliver) ReadableText(MR.strings.to_protect_privacy_simplex_has_ids_for_queues) ReadableText(MR.strings.you_control_servers_to_receive_your_contacts_to_send) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 0d90e59450..974f71cf2c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -36,12 +36,10 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun ConnectMobileView( - m: ChatModel -) { +fun ConnectMobileView() { val connecting = rememberSaveable() { mutableStateOf(false) } val remoteHosts = remember { chatModel.remoteHosts } - val deviceName = m.controller.appPrefs.deviceNameForRemoteAccess + val deviceName = chatModel.controller.appPrefs.deviceNameForRemoteAccess LaunchedEffect(Unit) { controller.reloadRemoteHosts() } @@ -49,11 +47,11 @@ fun ConnectMobileView( deviceName = remember { deviceName.state }, remoteHosts = remoteHosts, connecting, - connectedHost = remember { m.currentRemoteHost }, + connectedHost = remember { chatModel.currentRemoteHost }, updateDeviceName = { withBGApi { if (it != "") { - m.controller.setLocalDeviceName(it) + chatModel.controller.setLocalDeviceName(it) deviceName.set(it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt index c5bde44947..33e183aaaf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt @@ -26,7 +26,7 @@ fun HelpLayout(userDisplayName: String) { .verticalScroll(rememberScrollState()) .padding(horizontal = DEFAULT_PADDING), ){ - AppBarTitle(String.format(stringResource(MR.strings.personal_welcome), userDisplayName), false) + AppBarTitle(String.format(stringResource(MR.strings.personal_welcome), userDisplayName), withPadding = false) ChatHelpView() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index cbcb7344f5..66dde9f96c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -29,13 +29,12 @@ import kotlinx.coroutines.launch @Composable fun ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { - // TODO close if remote host changes - var presetServers by remember { mutableStateOf(emptyList()) } - var servers by remember { + var presetServers by remember(rhId) { mutableStateOf(emptyList()) } + var servers by remember(rhId) { mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList()) } - val currServers = remember { mutableStateOf(servers) } - val testing = rememberSaveable { mutableStateOf(false) } + val currServers = remember(rhId) { mutableStateOf(servers) } + val testing = rememberSaveable(rhId) { mutableStateOf(false) } val serversUnchanged = remember { derivedStateOf { servers == currServers.value || testing.value } } val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } } val saveDisabled = remember { @@ -51,7 +50,12 @@ fun ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtoco } } - LaunchedEffect(Unit) { + KeyChangeEffect(rhId) { + m.userSMPServersUnsaved.value = null + servers = emptyList() + } + + LaunchedEffect(rhId) { val res = m.controller.getUserProtoServers(rhId, serverProtocol) if (res != null) { currServers.value = res.protoServers diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt index ac74bd04d8..77cb0ead13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt @@ -22,7 +22,7 @@ fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { .fillMaxSize() .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr), false) + AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr), withPadding = false) Box( Modifier .fillMaxWidth() 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 7bd060e8d6..4e86d33a4e 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 @@ -158,7 +158,7 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, it.currentUser.value?.remoteHostId, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) ChatPreferencesItem(showCustomModal, stopped = stopped) if (appPlatform.isDesktop) { - SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true) } 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) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 98989a775a..d248241086 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -65,6 +65,7 @@ fun UserAddressView( UserAddressLayout( userAddress = userAddress.value, shareViaProfile, + rhId, onCloseHandler, createAddress = { withApi { @@ -169,6 +170,7 @@ fun UserAddressView( private fun UserAddressLayout( userAddress: UserContactLinkRec?, shareViaProfile: MutableState, + rhId: Long?, onCloseHandler: MutableState<(close: () -> Unit) -> Unit>, createAddress: () -> Unit, learnMore: () -> Unit, @@ -181,7 +183,7 @@ private fun UserAddressLayout( Column( Modifier.verticalScroll(rememberScrollState()), ) { - AppBarTitle(stringResource(MR.strings.simplex_address), false) + AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(rhId), withPadding = false) Column( Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally, @@ -438,6 +440,7 @@ fun PreviewUserAddressLayoutNoAddress() { setProfileAddress = { _ -> }, learnMore = {}, shareViaProfile = remember { mutableStateOf(false) }, + rhId = null, onCloseHandler = remember { mutableStateOf({}) }, sendEmail = {}, ) @@ -471,6 +474,7 @@ fun PreviewUserAddressLayoutAddressCreated() { setProfileAddress = { _ -> }, learnMore = {}, shareViaProfile = remember { mutableStateOf(false) }, + rhId = null, onCloseHandler = remember { mutableStateOf({}) }, sendEmail = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 010b94e034..06a4762210 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -18,7 +18,7 @@ fun VersionInfoView(info: CoreVersionInfo) { Column( Modifier.padding(horizontal = DEFAULT_PADDING), ) { - AppBarTitle(stringResource(MR.strings.app_version_title), false) + AppBarTitle(stringResource(MR.strings.app_version_title), withPadding = false) if (appPlatform.isAndroid) { Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.ANDROID_VERSION_NAME)) Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) From cec0fe2702eea0af84a43ad56541d6205e448dae Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:47:46 +0400 Subject: [PATCH 125/174] ios, android: add author group member role to fix decoding (hidden from UI) (#3429) --- .../Shared/Views/Chat/Group/AddGroupMembersView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 11 +++++++---- .../kotlin/chat/simplex/common/model/ChatModel.kt | 4 +++- .../common/views/chat/group/AddGroupMembersView.kt | 4 +++- .../src/commonMain/resources/MR/base/strings.xml | 1 + 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index d206b9b418..b89c006c61 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -157,7 +157,7 @@ struct AddGroupMembersViewCommon: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole { + if role <= groupInfo.membership.memberRole && role != .author { Text(role.text) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 551ed2794d..dc4cdda462 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1847,7 +1847,7 @@ public struct GroupMember: Identifiable, Decodable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole } + return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .author } } public var memberIncognito: Bool { @@ -1887,6 +1887,7 @@ public struct GroupMemberIds: Decodable { public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { case observer = "observer" + case author = "author" case member = "member" case admin = "admin" case owner = "owner" @@ -1896,6 +1897,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec public var text: String { switch self { case .observer: return NSLocalizedString("observer", comment: "member role") + case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") @@ -1905,9 +1907,10 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec private var comparisonValue: Int { switch self { case .observer: return 0 - case .member: return 1 - case .admin: return 2 - case .owner: return 3 + case .author: return 1 + case .member: return 2 + case .admin: return 3 + case .owner: return 4 } } 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 dca3bc5b1a..7541b7f34f 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 @@ -1253,7 +1253,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole } + GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author } } val memberIncognito = memberProfile.profileId != memberContactProfileId @@ -1295,12 +1295,14 @@ data class GroupMemberIds( @Serializable enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons + @SerialName("author") Author("author"), @SerialName("member") Member("member"), @SerialName("admin") Admin("admin"), @SerialName("owner") Owner("owner"); val text: String get() = when (this) { Observer -> generalGetString(MR.strings.group_member_role_observer) + Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) Admin -> generalGetString(MR.strings.group_member_role_admin) Owner -> generalGetString(MR.strings.group_member_role_owner) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index ff23d40b82..37ee9729f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -205,7 +205,9 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState observer + author member admin owner From 324f614e00bcdfae45e6e51a51304b4ccb427ea5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 17:40:10 +0000 Subject: [PATCH 126/174] core: return remote controller port to UI (#3430) --- src/Simplex/Chat.hs | 5 +++-- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 6 ++++-- tests/RemoteTests.hs | 8 ++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 06a603584b..b67fa1f3dd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -104,6 +104,7 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version +import Simplex.RemoteControl.Invitation (RCSignedInvitation (..), RCInvitation (..)) import System.Exit (ExitCode, exitFailure, exitSuccess) import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) @@ -1966,8 +1967,8 @@ processChatCommand = \case ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ StartRemoteHost rh_ -> withUser_ $ do - (remoteHost_, inv) <- startRemoteHost rh_ - pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} + (remoteHost_, inv@RCSignedInvitation {invitation = RCInvitation {port}}) <- startRemoteHost rh_ + pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv, ctrlPort = show port} StopRemoteHost rh_ -> withUser_ $ closeRemoteHost rh_ >> ok_ DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7533649c69..e470a6fd89 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -657,7 +657,7 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} - | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} + | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index fc37569fdd..07664b9539 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -285,11 +285,13 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe rhi_ ] CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted {remoteHost_, invitation} -> - [ maybe "new remote host started" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " started") remoteHost_, + CRRemoteHostStarted {remoteHost_, invitation, ctrlPort} -> + [ plain $ maybe ("new remote host" <> started) (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> show rhId <> started) remoteHost_, "Remote session invitation:", plain invitation ] + where + started = " started on port " <> ctrlPort CRRemoteHostSessionCode {remoteHost_, sessionCode} -> [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, "Compare session code with host:", diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index b7b34999b0..9ede37f9a4 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -116,7 +116,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil mobileBob ##> "/set device name MobileBob" mobileBob <## "ok" desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <##. "remote host 1 started on port " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobileBob ##> ("/connect remote ctrl " <> inv) @@ -425,7 +425,7 @@ startRemote mobile desktop = do mobile ##> "/set device name Mobile" mobile <## "ok" desktop ##> "/start remote host new" - desktop <## "new remote host started" + desktop <##. "new remote host started on port " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -440,7 +440,7 @@ startRemote mobile desktop = do startRemoteStored :: TestCC -> TestCC -> IO () startRemoteStored mobile desktop = do desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <##. "remote host 1 started on port " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -454,7 +454,7 @@ startRemoteStored mobile desktop = do startRemoteDiscover :: TestCC -> TestCC -> IO () startRemoteDiscover mobile desktop = do desktop ##> "/start remote host 1 multicast=on" - desktop <## "remote host 1 started" + desktop <##. "remote host 1 started on port " desktop <## "Remote session invitation:" _inv <- getTermLine desktop -- will use multicast instead mobile ##> "/find remote ctrl" From 0c1d78ab08e055ddbf9e2984681e3f52979369c8 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 23 Nov 2023 04:17:05 +0800 Subject: [PATCH 127/174] desktop: specifying port in connect remote host page (#3432) * desktop: specifying port in connect remote host page * shorter string --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/SimpleXAPI.kt | 10 +++++----- .../chat/simplex/common/views/helpers/Utils.kt | 2 +- .../common/views/remote/ConnectMobileView.kt | 17 +++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 3 ++- .../src/commonMain/resources/MR/it/strings.xml | 2 +- .../src/commonMain/resources/MR/nl/strings.xml | 2 +- .../commonMain/resources/MR/zh-rCN/strings.xml | 2 +- 7 files changed, 24 insertions(+), 14 deletions(-) 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 26595880db..bfcec69da8 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 @@ -1392,10 +1392,10 @@ object ChatController { chatModel.remoteHosts.addAll(hosts) } - suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair? { + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Triple? { val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) - if (r is CR.RemoteHostStarted) return r.remoteHost_ to r.invitation - apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) + if (r is CR.RemoteHostStarted) return Triple(r.remoteHost_, r.invitation, r.ctrlPort) + apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } @@ -1851,7 +1851,7 @@ object ChatController { generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) ) } - if (chatModel.remoteHostId == r.remoteHostId_) { + if (chatModel.remoteHostId() == r.remoteHostId_) { chatModel.currentRemoteHost.value = null switchUIRemoteHost(null) } @@ -3774,7 +3774,7 @@ sealed class CR { // remote events (desktop) @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() @Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR() - @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String): CR() + @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String, val ctrlPort: String): CR() @Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR() @Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 21b71d876b..6e12681dd2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -56,7 +56,7 @@ fun annotatedStringResource(id: StringResource): AnnotatedString { fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString { val density = LocalDensity.current return remember(id) { - escapedHtmlToAnnotatedString(id.localized().format(args), density) + escapedHtmlToAnnotatedString(id.localized().format(args = args), density) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 974f71cf2c..163d15ce62 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -161,7 +161,8 @@ private fun ConnectMobileViewLayout( title: String, invitation: String?, deviceName: String?, - sessionCode: String? + sessionCode: String?, + port: String? ) { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), @@ -169,13 +170,14 @@ private fun ConnectMobileViewLayout( ) { AppBarTitle(title) SectionView { - if (invitation != null && sessionCode == null) { + if (invitation != null && sessionCode == null && port != null) { QRCode( invitation, Modifier .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) .aspectRatio(1f) ) SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code)) + SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port)) if (remember { controller.appPrefs.developerTools.state }.value) { val clipboard = LocalClipboardManager.current @@ -232,6 +234,7 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { private fun showAddingMobileDevice(connecting: MutableState) { ModalManager.start.showModalCloseable { close -> val invitation = rememberSaveable { mutableStateOf(null) } + val port = rememberSaveable { mutableStateOf(null) } val pairing = remember { chatModel.newRemoteHostPairing } val sessionCode = when (val state = pairing.value?.second) { is RemoteHostSessionState.PendingConfirmation -> state.sessionCode @@ -247,7 +250,8 @@ private fun showAddingMobileDevice(connecting: MutableState) { title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), invitation = invitation.value, deviceName = remoteDeviceName, - sessionCode = cachedSessionCode + sessionCode = cachedSessionCode, + port = port.value ) val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) } LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { @@ -266,6 +270,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { if (r != null) { connecting.value = true invitation.value = r.second + port.value = r.third } } onDispose { @@ -284,6 +289,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState ModalManager.start.showModalCloseable { close -> val pairing = remember { chatModel.newRemoteHostPairing } val invitation = rememberSaveable { mutableStateOf(null) } + val port = rememberSaveable { mutableStateOf(null) } val sessionCode = when (val state = pairing.value?.second) { is RemoteHostSessionState.PendingConfirmation -> state.sessionCode else -> null @@ -298,6 +304,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState invitation = invitation.value, deviceName = pairing.value?.first?.hostDeviceName ?: rh.hostDeviceName, sessionCode = cachedSessionCode, + port = port.value ) var remoteHostId by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { @@ -307,6 +314,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState connecting.value = true remoteHostId = rh_?.remoteHostId invitation.value = inv + port.value = r.third } } LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { @@ -343,7 +351,8 @@ private fun showConnectedMobileDevice(rh: RemoteHostInfo, disconnectHost: () -> title = stringResource(MR.strings.connected_to_mobile), invitation = null, deviceName = rh.hostDeviceName, - sessionCode = sessionCode + sessionCode = sessionCode, + port = null, ) Spacer(Modifier.height(DEFAULT_PADDING_HALF)) SectionItemView(disconnectHost) { 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 4f29d8cb34..622ba4abb5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1662,7 +1662,8 @@ %s]]> Disconnect desktop? Only one device can work at the same time - Use from desktop in mobile app and scan QR code]]> + Use from desktop in mobile app and scan QR code.]]> + %s]]> Bad desktop address Incompatible version Desktop app version %s is not compatible with this app. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 093ca05646..8aa847c78b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1465,7 +1465,7 @@ Nuovo dispositivo mobile Indirizzo desktop Solo un dispositivo può funzionare nello stesso momento - Usa dal desktop nell\'app mobile e scansiona il codice QR]]> + Usa dal desktop nell\'app mobile e scansiona il codice QR.]]> Versione incompatibile (nuovo)]]> Scollegare il desktop? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index c112844395..801df9d609 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1463,7 +1463,7 @@ Nieuw mobiel apparaat Desktop adres Er kan slechts één apparaat tegelijkertijd werken - Gebruik vanaf desktop in de mobiele app en scan de QR-code]]> + Gebruik vanaf desktop in de mobiele app en scan de QR-code.]]> Incompatibele versie (nieuw)]]> Desktop ontkoppelen? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index e672c189a4..90f475135f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1465,7 +1465,7 @@ 新移动设备 桌面地址 同一时刻只有一台设备可以工作 - 从桌面使用并扫描二维码]]> + 从桌面使用并扫描二维码.]]> 不兼容的版本 (新)]]> 取消链接桌面端? From 15fdab597bb89d6721689dcd3b1ba25f3645fd69 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 23 Nov 2023 01:36:52 +0400 Subject: [PATCH 128/174] core: shuffle members when sending messages and introductions; send to admins and owners first (#3431) * core: shuffle members when sending messages and introductions; send to admins and owners first * refactor --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b67fa1f3dd..2696b5311b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,19 +39,20 @@ import Data.Either (fromRight, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn) +import Data.List (find, foldl', isSuffixOf, partition, sortBy, sortOn) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) +import Data.Ord (comparing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) import Data.Time.Clock.System (SystemTime, systemToUTCTime) -import Data.Word (Word32) +import Data.Word (Word16, Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call @@ -3552,7 +3553,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do members <- withStore' $ \db -> getGroupMembers db user gInfo intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m - forM_ intros $ \intro -> + shuffledIntros <- liftIO $ shuffleMembers intros $ \GroupMemberIntro {reMember = GroupMember {memberRole}} -> memberRole + forM_ shuffledIntros $ \intro -> processIntro intro `catchChatError` (toView . CRChatError (Just user)) where sendXGrpLinkMem = do @@ -5525,7 +5527,8 @@ sendGroupMessage' :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> [Group sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) -- TODO collect failed deliveries into a single error - rs <- forM (filter memberCurrent members) $ \m -> + recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) $ \GroupMember {memberRole} -> memberRole + rs <- forM recipientMembers $ \m -> messageMember m msg `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) let sentToMembers = catMaybes rs pure (msg, sentToMembers) @@ -5563,6 +5566,15 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do XGrpMsgForward {} -> True _ -> False +shuffleMembers :: [a] -> (a -> GroupMemberRole) -> IO [a] +shuffleMembers ms role = do + let (adminMs, otherMs) = partition ((GRAdmin <=) . role) ms + liftM2 (<>) (shuffle adminMs) (shuffle otherMs) + where + random :: IO Word16 + random = randomRIO (0, 65535) + shuffle xs = map snd . sortBy (comparing fst) <$> mapM (\x -> (,x) <$> random) xs + sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do pendingMessages <- withStore' $ \db -> getPendingGroupMessages db groupMemberId From 4af4fbae2be547591060b65b5cd722718cc744d8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:10:41 +0000 Subject: [PATCH 129/174] ios: close sheet when disconnecting from desktop (#3435) --- apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift | 2 +- .../common/src/commonMain/resources/MR/base/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 0f6ef7be0d..0ee2312889 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -382,7 +382,7 @@ struct ConnectDesktopView: View { private func disconnectButton() -> some View { Button { - disconnectDesktop() + disconnectDesktop(.dismiss) } label: { Label("Disconnect", systemImage: "multiply") } 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 622ba4abb5..44f950c325 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1659,7 +1659,7 @@ Unlink desktop? Unlink Disconnect - %s]]> + %s was disconnected]]> Disconnect desktop? Only one device can work at the same time Use from desktop in mobile app and scan QR code.]]> From 2d4e99d6101285a639aeb9579fceac91e6fd0b0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:11:32 +0000 Subject: [PATCH 130/174] cli: set device name for remote control via CLI option (#3427) * cli: set device name for remote control via CLI option * fix * add property in tests --- apps/simplex-broadcast-bot/src/Broadcast/Options.hs | 1 + .../src/Directory/Options.hs | 1 + src/Simplex/Chat.hs | 7 ++++--- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/Mobile.hs | 4 +++- src/Simplex/Chat/Options.hs | 11 +++++++++++ src/Simplex/Chat/Terminal.hs | 3 ++- tests/ChatClient.hs | 1 + 8 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 76b349a499..3758af2fcb 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -74,6 +74,7 @@ mkChatOpts :: BroadcastBotOpts -> ChatOpts mkChatOpts BroadcastBotOpts {coreOptions} = ChatOpts { coreOptions, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 1f06afe116..8f28c9013a 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -72,6 +72,7 @@ mkChatOpts :: DirectoryOpts -> ChatOpts mkChatOpts DirectoryOpts {coreOptions} = ChatOpts { coreOptions, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2696b5311b..bbbb4a9249 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -153,7 +153,8 @@ defaultChatConfig = cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes coreApi = False, - highlyAvailable = False + highlyAvailable = False, + deviceNameForRemote = "" } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -197,7 +198,7 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} firstTime = dbNew chatStore @@ -215,7 +216,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty - localDeviceName <- newTVarIO "" -- TODO set in config + localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName multicastSubscribers <- newTMVarIO 0 remoteSessionSeq <- newTVarIO 0 remoteHostSessions <- atomically TM.empty diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e470a6fd89..aa23587456 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -135,7 +135,8 @@ data ChatConfig = ChatConfig cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds coreApi :: Bool, - highlyAvailable :: Bool + highlyAvailable :: Bool, + deviceNameForRemote :: Text } data DefaultAgentServers = DefaultAgentServers diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index ffcf5a0cea..9127102543 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -181,6 +181,7 @@ mobileChatOpts dbFilePrefix dbKey = tbqSize = 1024, highlyAvailable = False }, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, @@ -197,7 +198,8 @@ defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, logLevel = CLLError, - coreApi = True + coreApi = True, + deviceNameForRemote = "Mobile" } getActiveUser_ :: SQLiteStore -> IO (Maybe User) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 04aef29dfa..7ce6305d20 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -19,6 +19,7 @@ where import Control.Logger.Simple (LogLevel (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B +import Data.Text (Text) import Numeric.Natural (Natural) import Options.Applicative import Simplex.Chat.Controller (ChatLogLevel (..), updateStr, versionNumber, versionString) @@ -32,6 +33,7 @@ import System.FilePath (combine) data ChatOpts = ChatOpts { coreOptions :: CoreChatOpts, + deviceName :: Maybe Text, chatCmd :: String, chatCmdDelay :: Int, chatServerPort :: Maybe String, @@ -200,6 +202,14 @@ coreChatOptsP appDir defaultDbFileName = do chatOptsP :: FilePath -> FilePath -> Parser ChatOpts chatOptsP appDir defaultDbFileName = do coreOptions <- coreChatOptsP appDir defaultDbFileName + deviceName <- + optional $ + strOption + ( long "device-name" + <> short 'e' + <> metavar "DEVICE" + <> help "Device name to use in connections with remote hosts and controller" + ) chatCmd <- strOption ( long "execute" @@ -268,6 +278,7 @@ chatOptsP appDir defaultDbFileName = do pure ChatOpts { coreOptions, + deviceName, chatCmd, chatCmdDelay, chatServerPort, diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 68aaa51318..89d234f944 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -35,7 +35,8 @@ terminalChatConfig = ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"], xftp = defaultXFTPServers, netCfg = defaultNetworkConfig - } + }, + deviceNameForRemote = "SimpleX CLI" } simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c9f7c9dc87..b75fa38419 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -74,6 +74,7 @@ testOpts = tbqSize = 16, highlyAvailable = False }, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, From 01f351e65a735f3081171b1d9baf29849802b551 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:12:42 +0000 Subject: [PATCH 131/174] ios: haskell RTS options (#3433) --- apps/ios/Shared/SimpleXApp.swift | 3 ++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 +++++++ apps/ios/SimpleXChat/SimpleX.h | 4 +++- apps/ios/SimpleXChat/hs_init.c | 25 ++++++++++++++++++++++ apps/ios/SimpleXChat/hs_init.h | 14 ++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 apps/ios/SimpleXChat/hs_init.c create mode 100644 apps/ios/SimpleXChat/hs_init.h diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index f030f105bf..fd1ec9511b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -27,7 +27,8 @@ struct SimpleXApp: App { init() { DispatchQueue.global(qos: .background).sync { - hs_init(0, nil) + haskell_init() +// hs_init(0, nil) } UserDefaults.standard.register(defaults: appDefaults) setGroupDefaults() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d2456b6966..3326dc0601 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -118,6 +118,8 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; + 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -399,6 +401,8 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = ""; }; + 5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -808,6 +812,8 @@ 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, 5CE2BA96284537A800EC33A6 /* dummy.m */, + 5CD67B8D2B0E858A00C510B1 /* hs_init.h */, + 5CD67B8E2B0E858A00C510B1 /* hs_init.c */, ); path = SimpleXChat; sourceTree = ""; @@ -892,6 +898,7 @@ buildActionMask = 2147483647; files = ( 5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */, + 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */, 5CE2BA952845354B00EC33A6 /* SimpleX.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1262,6 +1269,7 @@ 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, + 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */, 5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */, 5CE2BA79284530CC00EC33A6 /* SimpleXChat.docc in Sources */, 5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */, diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 250f1cb73f..2872922a9b 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -9,7 +9,7 @@ #ifndef SimpleX_h #define SimpleX_h -#endif /* SimpleX_h */ +#include "hs_init.h" extern void hs_init(int argc, char **argv[]); @@ -42,3 +42,5 @@ extern char *chat_encrypt_file(char *fromPath, char *toPath); // chat_decrypt_file returns null-terminated string with the error message extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); + +#endif /* SimpleX_h */ diff --git a/apps/ios/SimpleXChat/hs_init.c b/apps/ios/SimpleXChat/hs_init.c new file mode 100644 index 0000000000..7a5ea24560 --- /dev/null +++ b/apps/ios/SimpleXChat/hs_init.c @@ -0,0 +1,25 @@ +// +// hs_init.c +// SimpleXChat +// +// Created by Evgeny on 22/11/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +#include "hs_init.h" + +extern void hs_init_with_rtsopts(int * argc, char **argv[]); + +void haskell_init(void) { + int argc = 5; + char *argv[] = { + "simplex", + "+RTS", // requires `hs_init_with_rtsopts` + "-A16m", // chunk size for new allocations + "-H64m", // initial heap size + "-xn", // non-moving GC + 0 + }; + char **pargv = argv; + hs_init_with_rtsopts(&argc, &pargv); +} diff --git a/apps/ios/SimpleXChat/hs_init.h b/apps/ios/SimpleXChat/hs_init.h new file mode 100644 index 0000000000..48850e819c --- /dev/null +++ b/apps/ios/SimpleXChat/hs_init.h @@ -0,0 +1,14 @@ +// +// hs_init.h +// SimpleXChat +// +// Created by Evgeny on 22/11/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +#ifndef hs_init_h +#define hs_init_h + +void haskell_init(void); + +#endif /* hs_init_h */ From d0419df3964e61b47190a31044cbb07634fe7de0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:34:30 +0000 Subject: [PATCH 132/174] android: close Use from desktop when disconnecting --- .../simplex/common/views/remote/ConnectDesktopView.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index d631836dd6..8d3cfddcec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -53,6 +53,7 @@ fun ConnectDesktopView(close: () -> Unit) { ModalView(close = closeWithAlert) { ConnectDesktopLayout( deviceName = deviceName.value!!, + close ) } val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } @@ -67,7 +68,7 @@ fun ConnectDesktopView(close: () -> Unit) { } @Composable -private fun ConnectDesktopLayout(deviceName: String) { +private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { val sessionAddress = remember { mutableStateOf("") } val remoteCtrls = remember { mutableStateListOf() } val session = remember { chatModel.remoteCtrlSession }.value @@ -89,7 +90,7 @@ private fun ConnectDesktopLayout(deviceName: String) { } } - is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl) + is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl, close) } } else { ConnectDesktop(deviceName, remoteCtrls, sessionAddress) @@ -205,7 +206,7 @@ private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { } @Composable -private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { +private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: () -> Unit) { AppBarTitle(stringResource(MR.strings.connected_to_desktop)) SectionView(stringResource(MR.strings.connected_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(rc.deviceViewName) @@ -223,7 +224,7 @@ private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { SectionSpacer() SectionView { - DisconnectButton(::disconnectDesktop) + DisconnectButton { disconnectDesktop(close) } } } From 954b7150af057a770657b67c2da646527ed9e426 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 23 Nov 2023 00:37:33 +0200 Subject: [PATCH 133/174] android, desktop: set RTS options for core (#3418) * desktop: allow settings RTS options * set initial heap and arena sizes * add non-moving GC for even more productivity/less reallocs * add RTS options for android too --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../src/commonMain/cpp/android/simplex-api.c | 14 ++++++++++++-- .../src/commonMain/cpp/desktop/simplex-api.c | 7 +++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index b729e3b7f5..54478425f1 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -5,7 +5,7 @@ //#include // from the RTS -void hs_init(int * argc, char **argv[]); +void hs_init_with_rtsopts(int * argc, char **argv[]); // from android-support void setLineBuffering(void); @@ -32,7 +32,17 @@ Java_chat_simplex_common_platform_CoreKt_pipeStdOutToSocket(JNIEnv *env, __unuse JNIEXPORT void JNICALL Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused jclass clazz) { - hs_init(NULL, NULL); + int argc = 5; + char *argv[] = { + "simplex", + "+RTS", // requires `hs_init_with_rtsopts` + "-A16m", // chunk size for new allocations + "-H64m", // initial heap size + "-xn", // non-moving GC + NULL + }; + char **pargv = argv; + hs_init_with_rtsopts(&argc, &pargv); setLineBuffering(); } diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 1b0d11a2b7..e2cd7ed55c 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -4,11 +4,14 @@ #include // from the RTS -void hs_init(int * argc, char **argv[]); +void hs_init_with_rtsopts(int * argc, char **argv[]); JNIEXPORT void JNICALL Java_chat_simplex_common_platform_CoreKt_initHS(JNIEnv *env, jclass clazz) { - hs_init(NULL, NULL); + int argc = 5; + char *argv[] = {"simplex", "+RTS", "-A16m", "-H64m", "-xn", NULL}; // see android/simplex-api.c for details + char **pargv = argv; + hs_init_with_rtsopts(&argc, &pargv); } // from simplex-chat From 1b7baa244a3d2a541bad4e225cc3dd16b215ae7d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 08:39:08 +0000 Subject: [PATCH 134/174] core: track network statuses in CLI (#3434) --- src/Simplex/Chat.hs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bbbb4a9249..2ad92b4ef9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2855,17 +2855,17 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m () - contactSubsToView rs cts ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI + contactSubsToView rs cts ce = do + chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) + ifM (asks $ coreApi . config) (notifyAPI statuses) notifyCLI where notifyCLI = do let cRs = resultsFor rs cts cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors - notifyAPI = do - let statuses = M.foldrWithKey' addStatus [] cts - chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) - toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses + notifyAPI = toView . CRNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) + statuses = M.foldrWithKey' addStatus [] cts where addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] addStatus _ Contact {activeConn = Nothing} nss = nss @@ -3086,12 +3086,12 @@ processAgentMessageNoConn = \case where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv conns nsStatus event = ifM (asks $ coreApi . config) notifyAPI notifyCLI + serverEvent srv conns nsStatus event = do + chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds + ifM (asks $ coreApi . config) (notifyAPI connIds) notifyCLI where - notifyAPI = do - let connIds = map AgentConnId conns - chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds - toView $ CRNetworkStatus nsStatus connIds + connIds = map AgentConnId conns + notifyAPI = toView . CRNetworkStatus nsStatus notifyCLI = do cs <- withStore' (`getConnectionsContacts` conns) toView $ event srv cs From d3f9616f9ba869f1ce62a12f230baa9433e2426c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 10:07:26 +0000 Subject: [PATCH 135/174] core: report controller info when found via multicast (#3437) * core: report controller info when found via multicast * handle parse error --- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/Remote.hs | 21 +++++++++++++-------- src/Simplex/Chat/View.hs | 32 ++++++++++++++++++-------------- tests/RemoteTests.hs | 3 +-- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index aa23587456..d4f9c93317 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -665,7 +665,7 @@ data ChatResponse | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect + | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} @@ -703,7 +703,7 @@ allowRemoteEvent = \case CRRemoteHostStopped _ -> False CRRemoteFileStored {} -> False CRRemoteCtrlList _ -> False - CRRemoteCtrlFound _ -> False + CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False CRRemoteCtrlConnected _ -> False diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d8d53aa0c4..29eeec3c52 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -397,12 +397,15 @@ findKnownRemoteCtrl = do cmdOk <- newEmptyTMVarIO action <- async $ handleCtrlError sseq "findKnownRemoteCtrl.discover" $ do atomically $ takeTMVar cmdOk - (RCCtrlPairing {ctrlFingerprint}, inv) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings + (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- + timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings + ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing) rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" Just rc -> pure rc atomically $ putTMVar foundCtrl (rc, inv) - toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching)} + let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_ + toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionSearching {action, foundCtrl} _ -> Left $ ChatErrorRemoteCtrl RCEBadState @@ -439,7 +442,8 @@ startRemoteCtrlSession = do connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> SessionSeq -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq "connectRemoteCtrl" $ do - (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app + ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName} <- parseCtrlAppInfo app + v <- checkAppVersion ctrlInfo rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v @@ -467,18 +471,19 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} - parseCtrlAppInfo ctrlAppInfo = do - ctrlInfo@CtrlAppInfo {appVersionRange} <- - liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo - v <- case compatibleAppVersion hostAppVersionRange appVersionRange of + checkAppVersion CtrlAppInfo {appVersionRange} = + case compatibleAppVersion hostAppVersionRange appVersionRange of Just (AppCompatible v) -> pure v Nothing -> throwError $ ChatErrorRemoteCtrl $ RCEBadVersion $ maxVersion appVersionRange - pure (ctrlInfo, v) getHostAppInfo appVersion = do hostDeviceName <- chatReadVar localDeviceName encryptFiles <- chatReadVar encryptLocalFiles pure HostAppInfo {appVersion, deviceName = hostDeviceName, encoding = localEncoding, encryptFiles} +parseCtrlAppInfo :: ChatMonad m => JT.Value -> m CtrlAppInfo +parseCtrlAppInfo ctrlAppInfo = do + liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo + handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> RemoteCrypto -> TBQueue ChatResponse -> HTTP2Request -> m () handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 07664b9539..1867ee2f61 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -306,18 +306,16 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlFound rc -> - ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo = CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (AppVersion ctrlVersion)}, appVersion = AppVersion v} -> - [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") - <> (if T.null deviceName then "" else plain deviceName <> ", ") - <> ("v" <> plain (V.showVersion ctrlVersion) <> ctrlVersionInfo) + CRRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> + [ "remote controller " <> sShow remoteCtrlId <> " found: " + <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ ] where - ctrlVersionInfo - | ctrlVersion < v = " (older than this app - upgrade controller)" - | ctrlVersion > v = " (newer than this app - upgrade it)" - | otherwise = "" + deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " + CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> + [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") + <> viewRemoteCtrl ctrlAppInfo appVersion True + ] CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, "Compare session code with controller and use:", @@ -1734,10 +1732,16 @@ viewRemoteCtrls = \case RCSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" RCSConnected _ -> " (connected)" --- TODO fingerprint, accepted? -viewRemoteCtrl :: RemoteCtrlInfo -> StyledString -viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName} = - plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName +viewRemoteCtrl :: CtrlAppInfo -> AppVersion -> Bool -> StyledString +viewRemoteCtrl CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (AppVersion ctrlVersion)} (AppVersion v) compatible = + (if T.null deviceName then "" else plain deviceName <> ", ") + <> ("v" <> plain (V.showVersion ctrlVersion) <> ctrlVersionInfo) + where + ctrlVersionInfo + | ctrlVersion < v = " (older than this app - upgrade controller" <> showCompatible <> ")" + | ctrlVersion > v = " (newer than this app - upgrade it" <> showCompatible <> ")" + | otherwise = "" + showCompatible = if compatible then "" else ", " <> bold' "not compatible" viewChatError :: ChatLogLevel -> Bool -> ChatError -> [StyledString] viewChatError logLevel testView = \case diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 9ede37f9a4..9f77245d94 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -459,8 +459,7 @@ startRemoteDiscover mobile desktop = do _inv <- getTermLine desktop -- will use multicast instead mobile ##> "/find remote ctrl" mobile <## "ok" - mobile <## "remote controller found:" - mobile <## "1. My desktop" + mobile <## ("remote controller 1 found: My desktop, v" <> versionNumber) mobile ##> "/confirm remote ctrl 1" mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) From d837f87f09359b7db40fef65bd79a521efc385f3 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 23 Nov 2023 13:00:57 +0200 Subject: [PATCH 136/174] fix circular cancel at rcDiscoverCtrl (#3438) --- src/Simplex/Chat/Remote.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 29eeec3c52..ff271ba1c5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -659,7 +659,8 @@ cancelActiveRemoteCtrl sseq_ = handleAny (logError . tshow) $ do cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case RCSessionStarting -> pure () - RCSessionSearching {action} -> uninterruptibleCancel action + RCSessionSearching {action} -> + unless handlingError $ uninterruptibleCancel action RCSessionConnecting {rcsClient, rcsWaitSession} -> do unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient From 4d3529a3e0295361563da048476dee7d81a71d36 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 17:00:13 +0000 Subject: [PATCH 137/174] desktop: fix incorrect remote host for active user (#3441) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 3 +++ .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) 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 7541b7f34f..6739cdee70 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 @@ -638,6 +638,9 @@ data class User( val addressShared: Boolean = profile.contactLink != null + fun updateRemoteHostId(rh: Long?): User = + if (rh == null) this else this.copy(remoteHostId = rh) + companion object { val sampleData = User( remoteHostId = null, 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 bfcec69da8..8f837e1c5a 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 @@ -468,7 +468,7 @@ object ChatController { suspend fun apiGetActiveUser(rh: Long?): User? { val r = sendCmd(rh, CC.ShowActiveUser()) - if (r is CR.ActiveUser) return r.user + if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") chatModel.userCreated.value = false return null @@ -476,7 +476,7 @@ object ChatController { 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)) - if (r is CR.ActiveUser) return r.user + 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 || r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.UserExists @@ -501,7 +501,7 @@ object ChatController { suspend fun apiSetActiveUser(rh: Long?, userId: Long, viewPwd: String?): User { val r = sendCmd(rh, CC.ApiSetActiveUser(userId, viewPwd)) - if (r is CR.ActiveUser) return if (rh == null) r.user else r.user.copy(remoteHostId = rh) + if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } @@ -538,7 +538,7 @@ object ChatController { private suspend fun setUserPrivacy(rh: Long?, cmd: CC): User { val r = sendCmd(rh, cmd) - if (r is CR.UserPrivacy) return if (rh == null) r.updatedUser else r.updatedUser.copy(remoteHostId = rh) + if (r is CR.UserPrivacy) return r.updatedUser.updateRemoteHostId(rh) else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } From f7903c5c83329d6bd1a8d28e082cc3583acff414 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 24 Nov 2023 03:49:53 +0800 Subject: [PATCH 138/174] desktop: close remote host connecting screen on stop of host (#3440) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 4 ++-- .../simplex/common/views/remote/ConnectMobileView.kt | 10 ++++++---- 3 files changed, 9 insertions(+), 7 deletions(-) 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 6739cdee70..18eba71fb9 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 @@ -113,7 +113,7 @@ object ChatModel { val currentRemoteHost = mutableStateOf(null) val remoteHostId: Long? @Composable get() = remember { currentRemoteHost }.value?.remoteHostId fun remoteHostId(): Long? = currentRemoteHost.value?.remoteHostId - val newRemoteHostPairing = mutableStateOf?>(null) + val remoteHostPairing = mutableStateOf?>(null) val remoteCtrlSession = mutableStateOf(null) fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { 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 8f837e1c5a..5a706efe69 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 @@ -1836,7 +1836,7 @@ object ChatController { is CR.GroupMemberRatchetSync -> chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) is CR.RemoteHostSessionCode -> { - chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) + chatModel.remoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) } is CR.RemoteHostConnected -> { // TODO needs to update it instead in sessions @@ -1845,7 +1845,7 @@ object ChatController { } is CR.RemoteHostStopped -> { val disconnectedHost = chatModel.remoteHosts.firstOrNull { it.remoteHostId == r.remoteHostId_ } - chatModel.newRemoteHostPairing.value = null + chatModel.remoteHostPairing.value = null if (disconnectedHost != null) { showToast( generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 163d15ce62..a22cbfd9bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -235,7 +235,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { ModalManager.start.showModalCloseable { close -> val invitation = rememberSaveable { mutableStateOf(null) } val port = rememberSaveable { mutableStateOf(null) } - val pairing = remember { chatModel.newRemoteHostPairing } + val pairing = remember { chatModel.remoteHostPairing } val sessionCode = when (val state = pairing.value?.second) { is RemoteHostSessionState.PendingConfirmation -> state.sessionCode else -> null @@ -271,6 +271,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { connecting.value = true invitation.value = r.second port.value = r.third + chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting } } onDispose { @@ -279,7 +280,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { chatController.stopRemoteHost(null) } } - chatModel.newRemoteHostPairing.value = null + chatModel.remoteHostPairing.value = null } } } @@ -287,7 +288,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { ModalManager.start.showModalCloseable { close -> - val pairing = remember { chatModel.newRemoteHostPairing } + val pairing = remember { chatModel.remoteHostPairing } val invitation = rememberSaveable { mutableStateOf(null) } val port = rememberSaveable { mutableStateOf(null) } val sessionCode = when (val state = pairing.value?.second) { @@ -315,6 +316,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState remoteHostId = rh_?.remoteHostId invitation.value = inv port.value = r.third + chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting } } LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { @@ -334,7 +336,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState chatController.stopRemoteHost(remoteHostId) } } - chatModel.newRemoteHostPairing.value = null + chatModel.remoteHostPairing.value = null } } } From b2dbb558f9a242f94ad5a7963ffebb34f0b3ea11 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 24 Nov 2023 05:00:11 +0800 Subject: [PATCH 139/174] android, desktop: connect remote desktop via multicast (#3442) * android, desktop: connect remote desktop via multicast * changes * string --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 48 ++++- .../views/helpers/DefaultBasicTextField.kt | 2 +- .../common/views/remote/ConnectDesktopView.kt | 166 +++++++++++++++--- .../common/views/remote/ConnectMobileView.kt | 8 +- .../commonMain/resources/MR/base/strings.xml | 7 +- 6 files changed, 200 insertions(+), 36 deletions(-) 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 18eba71fb9..144f461a97 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 @@ -2921,7 +2921,7 @@ enum class NotificationPreviewMode { } data class RemoteCtrlSession( - val ctrlAppInfo: CtrlAppInfo, + val ctrlAppInfo: CtrlAppInfo?, val appVersion: String, val sessionState: UIRemoteCtrlSessionState ) { @@ -2939,6 +2939,7 @@ data class RemoteCtrlSession( @Serializable sealed class RemoteCtrlSessionState { @Serializable @SerialName("starting") object Starting: RemoteCtrlSessionState() + @Serializable @SerialName("searching") object Searching: RemoteCtrlSessionState() @Serializable @SerialName("connecting") object Connecting: RemoteCtrlSessionState() @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() @@ -2946,6 +2947,8 @@ sealed class RemoteCtrlSessionState { sealed class UIRemoteCtrlSessionState { @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() + @Serializable @SerialName("searching") object Searching: UIRemoteCtrlSessionState() + @Serializable @SerialName("found") data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): UIRemoteCtrlSessionState() @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() 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 5a706efe69..20fa4abf58 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 @@ -170,6 +170,7 @@ class AppPreferences { val confirmRemoteSessions = mkBoolPreference(SHARED_PREFS_CONFIRM_REMOTE_SESSIONS, false) val connectRemoteViaMulticast = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST, false) + val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true) val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) private fun mkIntPreference(prefName: String, default: Int) = @@ -314,6 +315,7 @@ class AppPreferences { private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" private const val SHARED_PREFS_CONFIRM_REMOTE_SESSIONS = "ConfirmRemoteSessions" private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" + private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" } } @@ -1432,14 +1434,25 @@ object ChatController { suspend fun connectRemoteCtrl(desktopAddress: String): Pair { val r = sendCmd(null, CC.ConnectRemoteCtrl(desktopAddress)) - if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null - else if (r is CR.ChatCmdError) return null to r - else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") + return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) null to r + else { + apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } } suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.ConfirmRemoteCtrl(rcId)) + suspend fun confirmRemoteCtrl(rcId: Long): Pair { + val r = sendCmd(null, CC.ConfirmRemoteCtrl(remoteCtrlId = rcId)) + return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) null to r + else { + apiErrorAlert("confirmRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } + } suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode)) @@ -1857,8 +1870,15 @@ object ChatController { } } is CR.RemoteCtrlFound -> { - // TODO multicast - Log.d(TAG, "RemoteCtrlFound: ${r.remoteCtrl}") + val sess = chatModel.remoteCtrlSession.value + if (sess != null && sess.sessionState is UIRemoteCtrlSessionState.Searching) { + val state = UIRemoteCtrlSessionState.Found(remoteCtrl = r.remoteCtrl, compatible = r.compatible) + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = r.ctrlAppInfo_, + appVersion = r.appVersion, + sessionState = state + ) + } } is CR.RemoteCtrlSessionCode -> { val state = UIRemoteCtrlSessionState.PendingConfirmation(remoteCtrl_ = r.remoteCtrl_, sessionCode = r.sessionCode) @@ -1870,7 +1890,13 @@ object ChatController { chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) } is CR.RemoteCtrlStopped -> { - switchToLocalSession() + val sess = chatModel.remoteCtrlSession.value + if (sess != null) { + chatModel.remoteCtrlSession.value = null + if (sess.sessionState is UIRemoteCtrlSessionState.Connected) { + switchToLocalSession() + } + } } else -> Log.d(TAG , "unsupported event: ${r.responseType}") @@ -3782,7 +3808,7 @@ sealed class CR { @Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR() // remote events (mobile) @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo, val ctrlAppInfo_: CtrlAppInfo?, val appVersion: String, val compatible: Boolean): CR() @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR() @Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR() @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @@ -4080,7 +4106,11 @@ sealed class CR { is RemoteHostStopped -> "remote host ID: $remoteHostId_" is RemoteFileStored -> "remote host ID: $remoteHostId\nremoteFileSource:\n" + json.encodeToString(remoteFileSource) is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlFound -> json.encodeToString(remoteCtrl) + is RemoteCtrlFound -> "remote ctrl: " + json.encodeToString(remoteCtrl) + + "\nctrlAppInfo: " + + (if (ctrlAppInfo_ == null) "null" else json.encodeToString(ctrlAppInfo_)) + + "\nappVersion: $appVersion" + + "\ncompatible: $compatible" is RemoteCtrlConnecting -> "remote ctrl: " + (if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index 71801e7a5f..08dfaa0dfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -125,7 +125,7 @@ fun DefaultConfigurableTextField( keyboardType: KeyboardType = KeyboardType.Text, dependsOn: State? = null, ) { - var valid by remember { mutableStateOf(validKey(state.value.text)) } + var valid by remember { mutableStateOf(isValid(state.value.text)) } var showKey by remember { mutableStateOf(false) } val icon = if (valid) { if (showKey) painterResource(MR.images.ic_visibility_off_filled) else painterResource(MR.images.ic_visibility_filled) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 8d3cfddcec..44e6969b8d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -37,6 +38,7 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -69,15 +71,21 @@ fun ConnectDesktopView(close: () -> Unit) { @Composable private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { + val showConnectScreen = remember { mutableStateOf(true) } val sessionAddress = remember { mutableStateOf("") } val remoteCtrls = remember { mutableStateListOf() } val session = remember { chatModel.remoteCtrlSession }.value Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), ) { - if (session != null) { + val discovery = if (session == null) null else session.sessionState is UIRemoteCtrlSessionState.Searching + if (discovery == true || (discovery == null && !showConnectScreen.value)) { + SearchingDesktop(deviceName, remoteCtrls) + } else if (session != null) { when (session.sessionState) { is UIRemoteCtrlSessionState.Starting -> ConnectingDesktop(session, null) + is UIRemoteCtrlSessionState.Searching -> SearchingDesktop(deviceName, remoteCtrls) + is UIRemoteCtrlSessionState.Found -> FoundDesktop(session, session.sessionState.remoteCtrl, session.sessionState.compatible, remember { controller.appPrefs.connectRemoteViaMulticastAuto.state }, deviceName, remoteCtrls, sessionAddress) is UIRemoteCtrlSessionState.Connecting -> ConnectingDesktop(session, session.sessionState.remoteCtrl_) is UIRemoteCtrlSessionState.PendingConfirmation -> { if (controller.appPrefs.confirmRemoteSessions.get() || session.sessionState.remoteCtrl_ == null) { @@ -97,11 +105,21 @@ private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { } SectionBottomSpacer() } - DisposableEffect(Unit) { + LaunchedEffect(Unit) { setDeviceName(deviceName) updateRemoteCtrls(remoteCtrls) + val useMulticast = useMulticast(remoteCtrls) + showConnectScreen.value = !useMulticast + if (chatModel.remoteCtrlSession.value != null) { + disconnectDesktop() + } else if (useMulticast) { + findKnownDesktop(showConnectScreen) + } + } + DisposableEffect(Unit) { onDispose { if (chatModel.remoteCtrlSession.value != null) { + showConnectScreen.value = false disconnectDesktop() } } @@ -146,7 +164,75 @@ private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { SectionSpacer() SectionView { - DisconnectButton(::disconnectDesktop) + DisconnectButton(onClick = ::disconnectDesktop) + } +} + +@Composable +private fun SearchingDesktop(deviceName: String, remoteCtrls: SnapshotStateList) { + AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text(stringResource(MR.strings.waiting_for_desktop), fontStyle = FontStyle.Italic) + } + SectionSpacer() + DisconnectButton(stringResource(MR.strings.scan_QR_code).replace('\n', ' '), MR.images.ic_qr_code, ::disconnectDesktop) +} + +@Composable +private fun FoundDesktop( + session: RemoteCtrlSession, + rc: RemoteCtrlInfo, + compatible: Boolean, + connectRemoteViaMulticastAuto: State, + deviceName: String, + remoteCtrls: SnapshotStateList, + sessionAddress: MutableState, +) { + AppBarTitle(stringResource(MR.strings.found_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + CtrlDeviceVersionText(session) + if (!compatible) { + Text(stringResource(MR.strings.not_compatible), color = MaterialTheme.colors.error) + } + } + + SectionSpacer() + + if (compatible) { + SectionItemView({ confirmKnownDesktop(sessionAddress, rc) }) { + Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.connect_button), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.connect_button)) + } + } + + if (!compatible || !connectRemoteViaMulticastAuto.value) { + DisconnectButton(stringResource(MR.strings.cancel_verb), onClick = ::disconnectDesktop) + } + + if (compatible && connectRemoteViaMulticastAuto.value) { + LaunchedEffect(Unit) { + confirmKnownDesktop(sessionAddress, rc) + } } } @@ -174,7 +260,7 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC } SectionView { - DisconnectButton(::disconnectDesktop) + DisconnectButton(onClick = ::disconnectDesktop) } } @@ -182,7 +268,7 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { val newDesktop = annotatedStringResource(MR.strings.new_desktop) val text = remember(rc) { - var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo.deviceName) + var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo?.deviceName ?: "") if (rc == null) { t = t + AnnotatedString(" ") + newDesktop } @@ -195,7 +281,7 @@ private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { val thisDeviceVersion = annotatedStringResource(MR.strings.this_device_version, session.appVersion) val text = remember(session) { - val v = AnnotatedString(session.ctrlAppInfo.appVersionRange.maxVersion) + val v = AnnotatedString(session.ctrlAppInfo?.appVersionRange?.maxVersion ?: "") var t = AnnotatedString("v$v") if (v.text != session.appVersion) { t = t + AnnotatedString(" ") + thisDeviceVersion @@ -243,7 +329,8 @@ private fun SessionCodeText(code: String) { private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList, updateDeviceName: (String) -> Unit) { DeviceNameField(deviceName) { updateDeviceName(it) } if (remoteCtrls.isNotEmpty()) { - SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } }) { + SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } + }) { Text(generalGetString(MR.strings.linked_desktops)) } } @@ -336,8 +423,13 @@ private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { PreferenceToggle(stringResource(MR.strings.verify_connections), remember { controller.appPrefs.confirmRemoteSessions.state }.value) { controller.appPrefs.confirmRemoteSessions.set(it) } - PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value && false) { - controller.appPrefs.confirmRemoteSessions.set(it) + PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value) { + controller.appPrefs.connectRemoteViaMulticast.set(it) + } + if (remember { controller.appPrefs.connectRemoteViaMulticast.state }.value) { + PreferenceToggle(stringResource(MR.strings.multicast_connect_automatically), remember { controller.appPrefs.connectRemoteViaMulticastAuto.state }.value) { + controller.appPrefs.connectRemoteViaMulticastAuto.set(it) + } } } SectionBottomSpacer() @@ -355,13 +447,11 @@ private fun setDeviceName(name: String) { } } -private fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { - withBGApi { - val res = controller.listRemoteCtrls() - if (res != null) { - remoteCtrls.clear() - remoteCtrls.addAll(res) - } +private suspend fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { + val res = controller.listRemoteCtrls() + if (res != null) { + remoteCtrls.clear() + remoteCtrls.addAll(res) } } @@ -369,9 +459,34 @@ private fun processDesktopQRCode(sessionAddress: MutableState, resp: Str connectDesktopAddress(sessionAddress, resp) } -private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { +private fun findKnownDesktop(showConnectScreen: MutableState) { withBGApi { - val res = controller.connectRemoteCtrl(desktopAddress = addr) + if (controller.findKnownRemoteCtrl()) { + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = null, + appVersion = "", + sessionState = UIRemoteCtrlSessionState.Searching + ) + showConnectScreen.value = true + } + } +} + +private fun confirmKnownDesktop(sessionAddress: MutableState, rc: RemoteCtrlInfo) { + connectDesktop(sessionAddress) { + controller.confirmRemoteCtrl(rc.remoteCtrlId) + } +} + +private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { + connectDesktop(sessionAddress) { + controller.connectRemoteCtrl(addr) + } +} + +private fun connectDesktop(sessionAddress: MutableState, connect: suspend () -> Pair) { + withBGApi { + val res = connect() if (res.first != null) { val (rc_, ctrlAppInfo, v) = res.first!! sessionAddress.value = "" @@ -409,18 +524,25 @@ private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList Unit) { +private fun DisconnectButton(label: String = generalGetString(MR.strings.disconnect_remote_host), icon: ImageResource = MR.images.ic_close, onClick: () -> Unit) { SectionItemView(onClick) { - Icon(painterResource(MR.images.ic_close), generalGetString(MR.strings.disconnect_remote_host), tint = MaterialTheme.colors.secondary) + Icon(painterResource(icon), label, tint = MaterialTheme.colors.secondary) TextIconSpaced(false) - Text(generalGetString(MR.strings.disconnect_remote_host)) + Text(label) } } +private fun useMulticast(remoteCtrls: List): Boolean = + controller.appPrefs.connectRemoteViaMulticast.get() && remoteCtrls.isNotEmpty() + private fun disconnectDesktop(close: (() -> Unit)? = null) { withBGApi { controller.stopRemoteCtrl() - switchToLocalSession() + if (chatModel.remoteCtrlSession.value?.sessionState is UIRemoteCtrlSessionState.Connected) { + switchToLocalSession() + } else { + chatModel.remoteCtrlSession.value = null + } close?.invoke() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index a22cbfd9bb..53f0339bee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @@ -90,6 +91,9 @@ fun ConnectMobileLayout( SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) + PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), remember { controller.appPrefs.offerRemoteMulticast.state }.value) { + controller.appPrefs.offerRemoteMulticast.set(it) + } SectionDividerSpaced(maxBottomPadding = false) } SectionView(stringResource(MR.strings.devices).uppercase()) { @@ -266,7 +270,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { } DisposableEffect(Unit) { withBGApi { - val r = chatModel.controller.startRemoteHost(null) + val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get()) if (r != null) { connecting.value = true invitation.value = r.second @@ -309,7 +313,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState ) var remoteHostId by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { - val r = chatModel.controller.startRemoteHost(rh.remoteHostId) + val r = chatModel.controller.startRemoteHost(rh.remoteHostId, controller.appPrefs.offerRemoteMulticast.get()) if (r != null) { val (rh_, inv) = r connecting.value = true 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 44f950c325..22f5d2fbec 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1670,6 +1670,8 @@ Connection terminated Session code Connecting to desktop + Waiting for desktop… + Found desktop Connect to desktop Connected to desktop Connected desktop @@ -1681,9 +1683,12 @@ Scan QR code from desktop Desktop address Verify connections - Discover on network + Discover via local network + Discoverable via local network + Connect automatically Paste desktop address Desktop + Not compatible! Coming soon! From 8f0a9cd6090920c89667557e082c1449c8a2ca0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 21:22:29 +0000 Subject: [PATCH 140/174] ios: connect remote desktop via multicast (#3436) * ios: connect remote desktop via multicast * works * fix camera freeze when leaving linked devices view * label * fix linked devices * fix compatible * string --- apps/ios/Shared/Model/ChatModel.swift | 8 +- apps/ios/Shared/Model/SimpleXAPI.swift | 29 +++- .../RemoteAccess/ConnectDesktopView.swift | 152 ++++++++++++++++-- .../Views/UserSettings/SettingsView.swift | 6 +- apps/ios/SimpleXChat/APITypes.swift | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- 6 files changed, 173 insertions(+), 29 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 90e4272b0d..4c0f361024 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -770,7 +770,7 @@ final class GMember: ObservableObject, Identifiable { } struct RemoteCtrlSession { - var ctrlAppInfo: CtrlAppInfo + var ctrlAppInfo: CtrlAppInfo? var appVersion: String var sessionState: UIRemoteCtrlSessionState @@ -782,6 +782,10 @@ struct RemoteCtrlSession { if case .connected = sessionState { true } else { false } } + var discovery: Bool { + if case .searching = sessionState { true } else { false } + } + var sessionCode: String? { switch sessionState { case let .pendingConfirmation(_, sessionCode): sessionCode @@ -793,6 +797,8 @@ struct RemoteCtrlSession { enum UIRemoteCtrlSessionState { case starting + case searching + case found(remoteCtrl: RemoteCtrlInfo, compatible: Bool) case connecting(remoteCtrl_: RemoteCtrlInfo?) case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9e4cc7cd02..e010de3e8f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -919,8 +919,10 @@ func findKnownRemoteCtrl() async throws { try await sendCommandOkResp(.findKnownRemoteCtrl) } -func confirmRemoteCtrl(_ rcId: Int64) async throws { - try await sendCommandOkResp(.confirmRemoteCtrl(remoteCtrlId: rcId)) +func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { + let r = await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId)) + if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } + throw r } func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo { @@ -1714,9 +1716,17 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) } - case let .remoteCtrlFound(remoteCtrl): - // TODO multicast - logger.debug("\(String(describing: remoteCtrl))") + case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): + await MainActor.run { + if let sess = m.remoteCtrlSession, case .searching = sess.sessionState { + let state = UIRemoteCtrlSessionState.found(remoteCtrl: remoteCtrl, compatible: compatible) + m.remoteCtrlSession = RemoteCtrlSession( + ctrlAppInfo: ctrlAppInfo_, + appVersion: appVersion, + sessionState: state + ) + } + } case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): await MainActor.run { let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode) @@ -1731,8 +1741,13 @@ func processReceivedMsg(_ res: ChatResponse) async { case .remoteCtrlStopped: // This delay is needed to cancel the session that fails on network failure, // e.g. when user did not grant permission to access local network yet. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - switchToLocalSession() + if let sess = m.remoteCtrlSession { + m.remoteCtrlSession = nil + if case .connected = sess.sessionState { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + switchToLocalSession() + } + } } default: logger.debug("unsupported event: \(res.responseType)") diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 0ee2312889..1f120860e2 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -16,12 +16,19 @@ struct ConnectDesktopView: View { var viaSettings = false @AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name @AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false - @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = false - @AppStorage(DEFAULT_OFFER_REMOTE_MULTICAST) private var offerRemoteMulticast = true + @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true + @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) private var connectRemoteViaMulticastAuto = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var sessionAddress: String = "" @State private var remoteCtrls: [RemoteCtrlInfo] = [] @State private var alert: ConnectDesktopAlert? + @State private var showConnectScreen = true + @State private var showQRCodeScanner = true + @State private var firstAppearance = true + + private var useMulticast: Bool { + connectRemoteViaMulticast && !remoteCtrls.isEmpty + } private enum ConnectDesktopAlert: Identifiable { case unlinkDesktop(rc: RemoteCtrlInfo) @@ -67,9 +74,14 @@ struct ConnectDesktopView: View { var viewBody: some View { Group { - if let session = m.remoteCtrlSession { + let discovery = m.remoteCtrlSession?.discovery + if discovery == true || (discovery == nil && !showConnectScreen) { + searchingDesktopView() + } else if let session = m.remoteCtrlSession { switch session.sessionState { case .starting: connectingDesktopView(session, nil) + case .searching: searchingDesktopView() + case let .found(rc, compatible): foundDesktopView(session, rc, compatible) case let .connecting(rc_): connectingDesktopView(session, rc_) case let .pendingConfirmation(rc_, sessCode): if confirmRemoteSessions || rc_ == nil { @@ -81,16 +93,35 @@ struct ConnectDesktopView: View { } case let .connected(rc, _): activeSessionView(session, rc) } - } else { + // The hack below prevents camera freezing when exiting linked devices view. + // Using showQRCodeScanner inside connectDesktopView or passing it as parameter still results in freezing. + } else if showQRCodeScanner || firstAppearance { connectDesktopView() + } else { + connectDesktopView(showScanner: false) } } .onAppear { setDeviceName(deviceName) updateRemoteCtrls() + showConnectScreen = !useMulticast + if m.remoteCtrlSession != nil { + disconnectDesktop() + } else if useMulticast { + findKnownDesktop() + } + // The hack below prevents camera freezing when exiting linked devices view. + // `firstAppearance` prevents camera flicker when the view first opens. + // moving `showQRCodeScanner = false` to `onDisappear` (to avoid `firstAppearance`) does not prevent freeze. + showQRCodeScanner = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + firstAppearance = false + showQRCodeScanner = true + } } .onDisappear { if m.remoteCtrlSession != nil { + showConnectScreen = false disconnectDesktop() } } @@ -134,12 +165,14 @@ struct ConnectDesktopView: View { .interactiveDismissDisabled(m.activeRemoteCtrl) } - private func connectDesktopView() -> some View { + private func connectDesktopView(showScanner: Bool = true) -> some View { List { Section("This device name") { devicesView() } - scanDesctopAddressView() + if showScanner { + scanDesctopAddressView() + } if developerTools { desktopAddressView() } @@ -167,6 +200,56 @@ struct ConnectDesktopView: View { .navigationTitle("Connecting to desktop") } + private func searchingDesktopView() -> some View { + List { + Section("This device name") { + devicesView() + } + Section("Found desktop") { + Text("Waiting for desktop...").italic() + Button { + disconnectDesktop(.dismiss) + } label: { + Label("Scan QR code", systemImage: "qrcode") + } + } + } + .navigationTitle("Connecting to desktop") + } + + @ViewBuilder private func foundDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo, _ compatible: Bool) -> some View { + let v = List { + Section("This device name") { + devicesView() + } + Section("Found desktop") { + ctrlDeviceNameText(session, rc) + ctrlDeviceVersionText(session) + if !compatible { + Text("Not compatible!").foregroundColor(.red) + } else if !connectRemoteViaMulticastAuto { + Button { + confirmKnownDesktop(rc) + } label: { + Label("Connect", systemImage: "checkmark") + } + } + } + if !compatible && !connectRemoteViaMulticastAuto { + Section { + disconnectButton("Cancel") + } + } + } + .navigationTitle("Found desktop") + + if compatible && connectRemoteViaMulticastAuto { + v.onAppear { confirmKnownDesktop(rc) } + } else { + v + } + } + private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View { List { Section("Connected to desktop") { @@ -191,7 +274,7 @@ struct ConnectDesktopView: View { } private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text { - var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo.deviceName) + var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo?.deviceName ?? "") if (rc == nil) { t = t + Text(" ") + Text("(new)").italic() } @@ -199,8 +282,8 @@ struct ConnectDesktopView: View { } private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text { - let v = session.ctrlAppInfo.appVersionRange.maxVersion - var t = Text("v\(v)") + let v = session.ctrlAppInfo?.appVersionRange.maxVersion + var t = Text("v\(v ?? "")") if v != session.appVersion { t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic() } @@ -301,7 +384,10 @@ struct ConnectDesktopView: View { Section("Linked desktop options") { Toggle("Verify connections", isOn: $confirmRemoteSessions) - Toggle("Discover on network", isOn: $connectRemoteViaMulticast).disabled(true) + Toggle("Discover via local network", isOn: $connectRemoteViaMulticast) + if connectRemoteViaMulticast { + Toggle("Connect automatically", isOn: $connectRemoteViaMulticastAuto) + } } } .navigationTitle("Linked desktops") @@ -335,10 +421,42 @@ struct ConnectDesktopView: View { } } - private func connectDesktopAddress(_ addr: String) { + private func findKnownDesktop() { Task { do { - let (rc_, ctrlAppInfo, v) = try await connectRemoteCtrl(desktopAddress: addr) + try await findKnownRemoteCtrl() + await MainActor.run { + m.remoteCtrlSession = RemoteCtrlSession( + ctrlAppInfo: nil, + appVersion: "", + sessionState: .searching + ) + showConnectScreen = true + } + } catch let e { + await MainActor.run { + errorAlert(e) + } + } + } + } + + private func confirmKnownDesktop(_ rc: RemoteCtrlInfo) { + connectDesktop_ { + try await confirmRemoteCtrl(rc.remoteCtrlId) + } + } + + private func connectDesktopAddress(_ addr: String) { + connectDesktop_ { + try await connectRemoteCtrl(desktopAddress: addr) + } + } + + private func connectDesktop_(_ connect: @escaping () async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String)) { + Task { + do { + let (rc_, ctrlAppInfo, v) = try await connect() await MainActor.run { sessionAddress = "" m.remoteCtrlSession = RemoteCtrlSession( @@ -380,11 +498,11 @@ struct ConnectDesktopView: View { } } - private func disconnectButton() -> some View { + private func disconnectButton(_ label: LocalizedStringKey = "Disconnect") -> some View { Button { disconnectDesktop(.dismiss) } label: { - Label("Disconnect", systemImage: "multiply") + Label(label, systemImage: "multiply") } } @@ -393,7 +511,11 @@ struct ConnectDesktopView: View { do { try await stopRemoteCtrl() await MainActor.run { - switchToLocalSession() + if case .connected = m.remoteCtrlSession?.sessionState { + switchToLocalSession() + } else { + m.remoteCtrlSession = nil + } switch action { case .back: dismiss() case .dismiss: dismiss() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 423786eb66..f889d9c394 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -56,7 +56,7 @@ let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" -let DEFAULT_OFFER_REMOTE_MULTICAST = "offerRemoteMulticast" +let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -91,8 +91,8 @@ let appDefaults: [String: Any] = [ DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, DEFAULT_CONFIRM_REMOTE_SESSIONS: false, - DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: false, - DEFAULT_OFFER_REMOTE_MULTICAST: true + DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, + DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, ] enum SimpleXLinkMode: String, Identifiable { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index e7409a072b..ad0e5ee100 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -609,7 +609,7 @@ public enum ChatResponse: Decodable, Error { case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) @@ -903,7 +903,7 @@ public enum ChatResponse: Decodable, Error { case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) @@ -1546,6 +1546,7 @@ public struct RemoteCtrlInfo: Decodable { public enum RemoteCtrlSessionState: Decodable { case starting + case searching case connecting case pendingConfirmation(sessionCode: String) case connected(sessionCode: 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 20fa4abf58..56b4b0a139 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 @@ -1394,7 +1394,7 @@ object ChatController { chatModel.remoteHosts.addAll(hosts) } - suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Triple? { + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true): Triple? { val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) if (r is CR.RemoteHostStarted) return Triple(r.remoteHost_, r.invitation, r.ctrlPort) apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) From 6f3174d0a10f4174bb2e44fdc39c184578343c00 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 21:25:32 +0000 Subject: [PATCH 141/174] android, desktop: remove unnecessary serialization --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 144f461a97..754e9dfe26 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 @@ -2946,10 +2946,10 @@ sealed class RemoteCtrlSessionState { } sealed class UIRemoteCtrlSessionState { - @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() - @Serializable @SerialName("searching") object Searching: UIRemoteCtrlSessionState() - @Serializable @SerialName("found") data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): UIRemoteCtrlSessionState() - @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() - @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() - @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() + object Starting: UIRemoteCtrlSessionState() + object Searching: UIRemoteCtrlSessionState() + data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): UIRemoteCtrlSessionState() + data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() + data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() + data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() } From 74e80eb34837bbb4d1c118e5362ef0f2637a1050 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 24 Nov 2023 00:00:20 +0200 Subject: [PATCH 142/174] core: add remote stop reason and state (#3444) * add remote stop reason and state * rename --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat/Controller.hs | 25 +++++++++++++++--- src/Simplex/Chat/Remote.hs | 46 ++++++++++++++++++---------------- src/Simplex/Chat/View.hs | 8 +++--- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d4f9c93317..d40810167e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -662,14 +662,14 @@ data ChatResponse | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} - | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId} + | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId, rhsState :: RemoteHostSessionState, rhStopReason :: RemoteHostStopReason} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} - | CRRemoteCtrlStopped + | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -700,14 +700,14 @@ allowRemoteEvent = \case CRRemoteHostSessionCode {} -> False CRNewRemoteHost _ -> False CRRemoteHostConnected _ -> False - CRRemoteHostStopped _ -> False + CRRemoteHostStopped {} -> False CRRemoteFileStored {} -> False CRRemoteCtrlList _ -> False CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False CRRemoteCtrlConnected _ -> False - CRRemoteCtrlStopped -> False + CRRemoteCtrlStopped {} -> False CRSQLResult _ -> False CRSlowSQLQueries {} -> False _ -> True @@ -1083,6 +1083,12 @@ data RemoteHostError | RHEProtocolError RemoteProtocolError deriving (Show, Exception) +data RemoteHostStopReason + = RHSRConnectionFailed ChatError + | RHSRCrashed ChatError + | RHSRDisconnected + deriving (Show, Exception) + -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError = RCEInactive -- ^ No session is running @@ -1098,6 +1104,13 @@ data RemoteCtrlError | RCEProtocolError {protocolError :: RemoteProtocolError} deriving (Show, Exception) +data RemoteCtrlStopReason + = RCSRDiscoveryFailed ChatError + | RCSRConnectionFailed ChatError + | RCSRSetupFailed ChatError + | RCSRDisconnected + deriving (Show, Exception) + data ArchiveError = AEImport {chatError :: ChatError} | AEImportFile {file :: String, chatError :: ChatError} @@ -1323,6 +1336,10 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) $(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index ff271ba1c5..e2137b35a2 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -169,12 +169,12 @@ startRemoteHost rh_ = do handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a handleConnectError rhKey sessSeq action = action `catchChatError` \err -> do logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err - cancelRemoteHostSession (Just sessSeq) rhKey + cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey throwError err handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m () handleHostError sessSeq rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just sessSeq) + readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars @@ -220,7 +220,7 @@ startRemoteHost rh_ = do onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () onDisconnected rhKey sseq = do logDebug $ "HTTP2 client disconnected: " <> tshow (rhKey, sseq) - cancelRemoteHostSession (Just sseq) rhKey + cancelRemoteHostSession (Just (sseq, RHSRDisconnected)) rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ @@ -246,24 +246,25 @@ closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey cancelRemoteHostSession Nothing rhKey -cancelRemoteHostSession :: ChatMonad m => Maybe SessionSeq -> RHKey -> m () -cancelRemoteHostSession sseq_ rhKey = do +cancelRemoteHostSession :: ChatMonad m => Maybe (SessionSeq, RemoteHostStopReason) -> RHKey -> m () +cancelRemoteHostSession handlerInfo_ rhKey = do sessions <- asks remoteHostSessions crh <- asks currentRemoteHost deregistered <- atomically $ TM.lookup rhKey sessions >>= \case Nothing -> pure Nothing - Just (sessSeq, _) | maybe False (/= sessSeq) sseq_ -> pure Nothing -- ignore cancel from a ghost session handler + Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> pure Nothing -- ignore cancel from a ghost session handler Just (_, rhs) -> do TM.delete rhKey sessions modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH pure $ Just rhs forM_ deregistered $ \session -> do liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) - when handlingError $ toView $ CRRemoteHostStopped rhId_ + forM_ (snd <$> handlerInfo_) $ \rhStopReason -> + toView $ CRRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} where - handlingError = isJust sseq_ - rhId_ = case rhKey of + handlingError = isJust handlerInfo_ + remoteHostId_ = case rhKey of RHNew -> Nothing RHId rhId -> Just rhId @@ -395,7 +396,7 @@ findKnownRemoteCtrl = do sseq <- startRemoteCtrlSession foundCtrl <- newEmptyTMVarIO cmdOk <- newEmptyTMVarIO - action <- async $ handleCtrlError sseq "findKnownRemoteCtrl.discover" $ do + action <- async $ handleCtrlError sseq RCSRDiscoveryFailed "findKnownRemoteCtrl.discover" $ do atomically $ takeTMVar cmdOk (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings @@ -441,7 +442,7 @@ startRemoteCtrlSession = do Right sseq <$ writeTVar session (Just (sseq, RCSessionStarting)) connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> SessionSeq -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq "connectRemoteCtrl" $ do +connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq RCSRConnectionFailed "connectRemoteCtrl" $ do ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName} <- parseCtrlAppInfo app v <- checkAppVersion ctrlInfo rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca @@ -452,7 +453,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk - handleCtrlError sseq "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars + handleCtrlError sseq RCSRConnectionFailed "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionConnecting {remoteCtrlId_ = remoteCtrlId' <$> rc_, rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState @@ -602,7 +603,7 @@ verifyRemoteCtrlSession execChatCommand sessCode' = do Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive Just (sseq, RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation}) -> pure (sseq, rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState - handleCtrlError sseq "verifyRemoteCtrlSession" $ do + handleCtrlError sseq RCSRSetupFailed "verifyRemoteCtrlSession" $ do let verified = sameVerificationCode sessCode' sessionCode timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode @@ -630,31 +631,32 @@ verifyRemoteCtrlSession execChatCommand sessCode' = do monitor sseq server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - cancelActiveRemoteCtrl (Just sseq) + cancelActiveRemoteCtrl $ Just (sseq, RCSRDisconnected) stopRemoteCtrl :: ChatMonad m => m () stopRemoteCtrl = cancelActiveRemoteCtrl Nothing -handleCtrlError :: ChatMonad m => SessionSeq -> Text -> m a -> m a -handleCtrlError sseq name action = +handleCtrlError :: ChatMonad m => SessionSeq -> (ChatError -> RemoteCtrlStopReason) -> Text -> m a -> m a +handleCtrlError sseq mkReason name action = action `catchChatError` \e -> do logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl (Just sseq) + cancelActiveRemoteCtrl $ Just (sseq, mkReason e) throwError e -- | Stop session controller, unless session update key is present but stale -cancelActiveRemoteCtrl :: ChatMonad m => Maybe SessionSeq -> m () -cancelActiveRemoteCtrl sseq_ = handleAny (logError . tshow) $ do +cancelActiveRemoteCtrl :: ChatMonad m => Maybe (SessionSeq, RemoteCtrlStopReason) -> m () +cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do var <- asks remoteCtrlSession session_ <- atomically $ readTVar var >>= \case Nothing -> pure Nothing - Just (oldSeq, _) | maybe False (/= oldSeq) sseq_ -> pure Nothing + Just (oldSeq, _) | maybe False (/= oldSeq) (fst <$> handlerInfo_) -> pure Nothing Just (_, s) -> Just s <$ writeTVar var Nothing forM_ session_ $ \session -> do liftIO $ cancelRemoteCtrl handlingError session - when handlingError $ toView CRRemoteCtrlStopped + forM_ (snd <$> handlerInfo_) $ \rcStopReason -> + toView CRRemoteCtrlStopped {rcsState = rcsSessionState session, rcStopReason} where - handlingError = isJust sseq_ + handlingError = isJust handlerInfo_ cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1867ee2f61..9cd26650fb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -299,8 +299,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ] CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] - CRRemoteHostStopped rhId_ -> - [ maybe "new remote host" (mappend "remote host " . sShow) rhId_ <> " stopped" + CRRemoteHostStopped {remoteHostId_} -> + [ maybe "new remote host" (mappend "remote host " . sShow) remoteHostId_ <> " stopped" ] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] @@ -311,7 +311,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ ] where - deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " + deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") <> viewRemoteCtrl ctrlAppInfo appVersion True @@ -323,7 +323,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"] + CRRemoteCtrlStopped {} -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = From b1cf1656a0ad2b2775ea0613cac3bed555d2a4bf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:48:14 +0000 Subject: [PATCH 143/174] core: cli remote control help section (#3445) --- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Help.hs | 31 ++++++++++++++++++++++++++++++- src/Simplex/Chat/View.hs | 2 ++ tests/RemoteTests.hs | 1 + 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2ad92b4ef9..cf3767b5c9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -6073,8 +6073,9 @@ chatCommandP = ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, ("/help address" <|> "/ha") $> ChatHelp HSMyAddress, - "/help incognito" $> ChatHelp HSIncognito, + ("/help incognito" <|> "/hi") $> ChatHelp HSIncognito, ("/help messages" <|> "/hm") $> ChatHelp HSMessages, + ("/help remote" <|> "/hr") $> ChatHelp HSRemote, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, ("/help" <|> "/h") $> ChatHelp HSMain, @@ -6181,7 +6182,6 @@ chatCommandP = "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), - -- "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, "/switch remote host " *> (SwitchRemoteHost <$> ("local" $> Nothing <|> (Just <$> A.decimal))), "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False))))), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d40810167e..3c0054ec1b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -203,7 +203,7 @@ data ChatController = ChatController contactMergeEnabled :: TVar Bool } -data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase deriving (Show) data ChatCommand diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index c2a5720633..5d0548ca3f 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -10,6 +10,7 @@ module Simplex.Chat.Help myAddressHelpInfo, incognitoHelpInfo, messagesHelpInfo, + remoteHelpInfo, markdownInfo, settingsInfo, databaseHelpInfo, @@ -87,7 +88,7 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "settings", "db"], + indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db"], indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/chats " <> " - most recent chats", @@ -272,6 +273,34 @@ messagesHelpInfo = indent <> highlight "! #team (hi) " <> " - to edit your message in the group #team" ] +remoteHelpInfo :: [StyledString] +remoteHelpInfo = + map + styleMarkdown + [ green "Remote control", + "You can use CLI as a remote controller for a mobile app or as a remote host for a desktop app (or another CLI).", + "For example, you can run CLI on a server and use it from a desktop computer, connecting via SSH port forwarding.", + "", + indent <> highlight "/set device name " <> " - set CLI name for remote connections", + "", + green "Using as remote controller", + indent <> highlight "/start remote host new " <> " - pair and connect a new remote host", + indent <> highlight "/start remote host [multicast=on] " <> " - start connection with a known (paired) remote host", + indent <> highlight "/stop remote host new " <> " - cancel pairing with a new remote host", + indent <> highlight "/stop remote host " <> " - stop connection with connected remote host", + indent <> highlight "/switch remote host local " <> " - switch to using local database", + indent <> highlight "/switch remote host " <> " - switch to connected remote host", + indent <> highlight "/list remote hosts " <> " - list known remote hosts", + indent <> highlight "/delete remote host " <> " - delete (unpair) known remote hosts - also deletes all host files from controller", + "", + green "Using as remote host", + indent <> highlight "/connect remote ctrl " <> " - connect to remote controller via the adress from /start remote host", + indent <> highlight "/stop remote ctrl " <> " - stop connection with remote controller", + indent <> highlight "/find remote ctrl " <> " - find known remote controller on the local network (it should be started with multicast=on)", + indent <> highlight "/list remote ctrls " <> " - list known remote controllers", + indent <> highlight "/delete remote ctrl " <> " - delete known remote controller" + ] + markdownInfo :: [StyledString] markdownInfo = map diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9cd26650fb..4dbda6da69 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -136,6 +136,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe HSIncognito -> incognitoHelpInfo HSMessages -> messagesHelpInfo HSMarkdown -> markdownInfo + HSRemote -> remoteHelpInfo HSSettings -> settingsInfo HSDatabase -> databaseHelpInfo CRWelcome user -> chatWelcome user @@ -310,6 +311,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [ "remote controller " <> sShow remoteCtrlId <> " found: " <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ ] + <> [ "use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] where deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 9f77245d94..147f1d9395 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -460,6 +460,7 @@ startRemoteDiscover mobile desktop = do mobile ##> "/find remote ctrl" mobile <## "ok" mobile <## ("remote controller 1 found: My desktop, v" <> versionNumber) + mobile <## "use /confirm remote ctrl 1 to connect" mobile ##> "/confirm remote ctrl 1" mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) From c9aec88c3934e77a7dbd85c346ba8b8c925c6fe1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 13:14:52 +0000 Subject: [PATCH 144/174] desktop: fix sending videos via connected mobile --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 56b4b0a139..5b44312cc3 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 @@ -2348,7 +2348,7 @@ sealed class CC { is DeleteRemoteHost -> "/delete remote host $remoteHostId" is StoreRemoteFile -> "/store remote file $remoteHostId " + - (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)} ") + + (if (storeEncrypted == null) "" else "encrypt=${onOff(storeEncrypted)} ") + localPath is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" From bfd13f059a2320b98e80530931627cd5a3545dfb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 14:18:31 +0000 Subject: [PATCH 145/174] ui: translations (#3446) * Translated using Weblate (Dutch) Currently translated at 100.0% (1502 of 1502 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Russian) Currently translated at 97.7% (1459 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (1493 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1502 of 1502 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Russian) Currently translated at 97.7% (1459 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (1493 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Russian) Currently translated at 100.0% (1493 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 99.8% (1339 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Arabic) Currently translated at 92.4% (1380 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1494 of 1494 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Added translation using Weblate (Hungarian) * Translated using Weblate (Dutch) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Hungarian) Currently translated at 0.5% (8 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 1.4% (21 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 2.0% (30 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 2.1% (32 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 10.9% (163 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 12.3% (184 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 14.8% (222 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Hungarian) Currently translated at 21.6% (323 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 97.7% (1461 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (French) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Hungarian) Currently translated at 21.9% (328 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (German) Currently translated at 100.0% (1500 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1500 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1500 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Turkish) Currently translated at 70.0% (1051 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * ru: corrections * export/import * ios: export * ru: corrections --------- Co-authored-by: M1K4 Co-authored-by: Float Co-authored-by: Hosted Weblate Co-authored-by: J R Co-authored-by: Random Co-authored-by: jonnysemon Co-authored-by: Istvan Novak Co-authored-by: Eric Co-authored-by: mlanp Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: xe1st --- .../bg.xcloc/Localized Contents/bg.xliff | 24 +- .../cs.xcloc/Localized Contents/cs.xliff | 24 +- .../de.xcloc/Localized Contents/de.xliff | 66 +++- .../en.xcloc/Localized Contents/en.xliff | 31 +- .../es.xcloc/Localized Contents/es.xliff | 24 +- .../fi.xcloc/Localized Contents/fi.xliff | 24 +- .../fr.xcloc/Localized Contents/fr.xliff | 66 +++- .../it.xcloc/Localized Contents/it.xliff | 36 +- .../ja.xcloc/Localized Contents/ja.xliff | 24 +- .../nl.xcloc/Localized Contents/nl.xliff | 36 +- .../pl.xcloc/Localized Contents/pl.xliff | 24 +- .../ru.xcloc/Localized Contents/ru.xliff | 140 ++++++- .../th.xcloc/Localized Contents/th.xliff | 24 +- .../uk.xcloc/Localized Contents/uk.xliff | 24 +- .../Localized Contents/zh-Hans.xliff | 24 +- apps/ios/de.lproj/Localizable.strings | 117 ++++++ .../de.lproj/SimpleX--iOS--InfoPlist.strings | 3 + apps/ios/fr.lproj/Localizable.strings | 117 ++++++ .../fr.lproj/SimpleX--iOS--InfoPlist.strings | 3 + apps/ios/it.lproj/Localizable.strings | 30 +- apps/ios/nl.lproj/Localizable.strings | 30 +- apps/ios/ru.lproj/Localizable.strings | 321 ++++++++++++++++ .../ru.lproj/SimpleX--iOS--InfoPlist.strings | 3 + .../commonMain/resources/MR/ar/strings.xml | 8 + .../commonMain/resources/MR/de/strings.xml | 63 +++ .../commonMain/resources/MR/fr/strings.xml | 58 +++ .../commonMain/resources/MR/hu/strings.xml | 359 ++++++++++++++++++ .../commonMain/resources/MR/it/strings.xml | 14 + .../commonMain/resources/MR/nl/strings.xml | 21 +- .../commonMain/resources/MR/ru/strings.xml | 124 +++++- .../commonMain/resources/MR/tr/strings.xml | 23 ++ .../resources/MR/zh-rCN/strings.xml | 21 +- 32 files changed, 1858 insertions(+), 48 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index b5ae218ab9..7a2afea082 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -1172,6 +1172,10 @@ Свързване server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Свързване инкогнито @@ -1862,8 +1866,8 @@ This cannot be undone! Открийте и се присъединете към групи No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! За конзолата No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Френски интерфейс @@ -3449,6 +3457,10 @@ This is your link for group %@! Няма получени или изпратени файлове No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Известия @@ -5396,6 +5408,10 @@ To connect, please ask your contact to create another connection link and check Гласово съобщение… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Изчаква се получаването на файла @@ -5926,6 +5942,10 @@ SimpleX сървърите не могат да видят вашия профи аудио разговор (не е e2e криптиран) No comment provided by engineer. + + author + member role + bad message ID лошо ID на съобщението diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 1e7b2c1fba..be8b23658b 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1172,6 +1172,10 @@ Připojit server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Spojit se inkognito @@ -1862,8 +1866,8 @@ This cannot be undone! Objevte a připojte skupiny No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! Pro konzoli No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Francouzské rozhraní @@ -3449,6 +3457,10 @@ This is your link for group %@! Žádné přijaté ani odeslané soubory No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Oznámení @@ -5396,6 +5408,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Hlasová zpráva… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Čekání na soubor @@ -5926,6 +5942,10 @@ Servery SimpleX nevidí váš profil. zvukový hovor (nešifrovaný e2e) No comment provided by engineer. + + author + member role + bad message ID špatné ID zprávy diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 4d37da1f3a..29f5dd4e3f 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -299,10 +299,12 @@ (new) + (Neu) No comment provided by engineer. (this device v%@) + (Dieses Gerät hat v%@) No comment provided by engineer. @@ -397,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - Optionale Benachrichtigung von gelöschten Kontakten. +- Profilnamen mit Leerzeichen. +- Und mehr! No comment provided by engineer. @@ -885,6 +890,7 @@ Bad desktop address + Falsche Desktop-Adresse No comment provided by engineer. @@ -899,6 +905,7 @@ Better groups + Bessere Gruppen No comment provided by engineer. @@ -913,6 +920,7 @@ Block group members + Gruppenmitglieder blockieren No comment provided by engineer. @@ -1186,6 +1194,10 @@ Verbinden server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Inkognito verbinden @@ -1193,6 +1205,7 @@ Connect to desktop + Mit dem Desktop verbinden No comment provided by engineer. @@ -1241,10 +1254,12 @@ Das ist Ihr eigener Einmal-Link! Connected desktop + Verbundener Desktop No comment provided by engineer. Connected to desktop + Mit dem Desktop verbunden No comment provided by engineer. @@ -1259,6 +1274,7 @@ Das ist Ihr eigener Einmal-Link! Connecting to desktop + Mit dem Desktop verbinden No comment provided by engineer. @@ -1283,6 +1299,7 @@ Das ist Ihr eigener Einmal-Link! Connection terminated + Verbindung beendet No comment provided by engineer. @@ -1367,6 +1384,7 @@ Das ist Ihr eigener Einmal-Link! Create a group using a random profile. + Erstellen Sie eine Gruppe mit einem zufälligen Profil. No comment provided by engineer. @@ -1781,14 +1799,17 @@ Das kann nicht rückgängig gemacht werden! Desktop address + Desktop-Adresse No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Desktop App-Version %@ ist mit dieser App nicht kompatibel. No comment provided by engineer. Desktop devices + Desktop-Geräte No comment provided by engineer. @@ -1883,6 +1904,7 @@ Das kann nicht rückgängig gemacht werden! Disconnect desktop? + Desktop-Verbindung trennen? No comment provided by engineer. @@ -1890,8 +1912,8 @@ Das kann nicht rückgängig gemacht werden! Gruppen entdecken und ihnen beitreten No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2066,10 +2088,12 @@ Das kann nicht rückgängig gemacht werden! Encryption re-negotiation error + Fehler bei der Neuverhandlung der Verschlüsselung message decrypt error item Encryption re-negotiation failed. + Neuverhandlung der Verschlüsselung fehlgeschlagen. No comment provided by engineer. @@ -2104,6 +2128,7 @@ Das kann nicht rückgängig gemacht werden! Enter this device name… + Geben Sie diesen Gerätenamen ein… No comment provided by engineer. @@ -2433,6 +2458,7 @@ Das kann nicht rückgängig gemacht werden! Faster joining and more reliable messages. + Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung. No comment provided by engineer. @@ -2530,6 +2556,10 @@ Das kann nicht rückgängig gemacht werden! Für Konsole No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Französische Bedienoberfläche @@ -2857,6 +2887,7 @@ Das kann nicht rückgängig gemacht werden! Incognito groups + Inkognito-Gruppen No comment provided by engineer. @@ -2891,6 +2922,7 @@ Das kann nicht rückgängig gemacht werden! Incompatible version + Inkompatible Version No comment provided by engineer. @@ -3065,6 +3097,7 @@ Das ist Ihr Link für die Gruppe %@! Keep the app open to use it from desktop + Die App muss geöffnet bleiben, um sie vom Desktop aus nutzen zu können No comment provided by engineer. @@ -3129,14 +3162,17 @@ Das ist Ihr Link für die Gruppe %@! Link mobile and desktop apps! 🔗 + Verknüpfe Mobiltelefon- und Desktop-Apps! 🔗 No comment provided by engineer. Linked desktop options + Verknüpfte Desktop-Optionen No comment provided by engineer. Linked desktops + Verknüpfte Desktops No comment provided by engineer. @@ -3489,6 +3525,10 @@ Das ist Ihr Link für die Gruppe %@! Keine empfangenen oder gesendeten Dateien No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Benachrichtigungen @@ -3710,6 +3750,7 @@ Das ist Ihr Link für die Gruppe %@! Paste desktop address + Desktop-Adresse einfügen No comment provided by engineer. @@ -4309,6 +4350,7 @@ Das ist Ihr Link für die Gruppe %@! Scan QR code from desktop + Den QR-Code vom Desktop scannen No comment provided by engineer. @@ -4533,6 +4575,7 @@ Das ist Ihr Link für die Gruppe %@! Session code + Sitzungscode No comment provided by engineer. @@ -5034,6 +5077,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This device name + Dieser Gerätename No comment provided by engineer. @@ -5073,6 +5117,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro To hide unwanted messages. + Um unerwünschte Nachrichten zu verbergen. No comment provided by engineer. @@ -5236,10 +5281,12 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Unlink + Entkoppeln No comment provided by engineer. Unlink desktop? + Desktop entkoppeln? No comment provided by engineer. @@ -5334,6 +5381,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Use from desktop + Vom Desktop aus nutzen No comment provided by engineer. @@ -5368,10 +5416,12 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verify code with desktop + Code mit dem Desktop überprüfen No comment provided by engineer. Verify connection + Verbindung überprüfen No comment provided by engineer. @@ -5381,6 +5431,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verify connections + Verbindungen überprüfen No comment provided by engineer. @@ -5395,6 +5446,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Via secure quantum resistant protocol. + Über ein sicheres quantenbeständiges Protokoll. No comment provided by engineer. @@ -5447,6 +5499,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sprachnachrichten… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Warte auf Datei @@ -5992,6 +6048,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Audioanruf (nicht E2E verschlüsselt) No comment provided by engineer. + + author + member role + bad message ID Ungültige Nachrichten-ID @@ -6576,6 +6636,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6778,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX nutzt den lokalen Netzwerkzugriff, um die Nutzung von Benutzer-Chatprofilen über eine Desktop-App im gleichen Netzwerk zu erlauben. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 6d70bd1221..23498b2128 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1194,6 +1194,11 @@ Connect server test step + + Connect automatically + Connect automatically + No comment provided by engineer. + Connect incognito Connect incognito @@ -1908,9 +1913,9 @@ This cannot be undone! Discover and join groups No comment provided by engineer. - - Discover on network - Discover on network + + Discover via local network + Discover via local network No comment provided by engineer. @@ -2553,6 +2558,11 @@ This cannot be undone! For console No comment provided by engineer. + + Found desktop + Found desktop + No comment provided by engineer. + French interface French interface @@ -3518,6 +3528,11 @@ This is your link for group %@! No received or sent files No comment provided by engineer. + + Not compatible! + Not compatible! + No comment provided by engineer. + Notifications Notifications @@ -5488,6 +5503,11 @@ To connect, please ask your contact to create another connection link and check Voice message… No comment provided by engineer. + + Waiting for desktop... + Waiting for desktop... + No comment provided by engineer. + Waiting for file Waiting for file @@ -6033,6 +6053,11 @@ SimpleX servers cannot see your profile. audio call (not e2e encrypted) No comment provided by engineer. + + author + author + member role + bad message ID bad message ID diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index cc93c0531d..ccc7ee3446 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1172,6 +1172,10 @@ Conectar server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Conectar incognito @@ -1862,8 +1866,8 @@ This cannot be undone! Descubre y únete a grupos No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! Para consola No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Interfaz en francés @@ -3449,6 +3457,10 @@ This is your link for group %@! Sin archivos recibidos o enviados No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Notificaciones @@ -5397,6 +5409,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Mensaje de voz… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Esperando archivo @@ -5927,6 +5943,10 @@ Los servidores de SimpleX no pueden ver tu perfil. llamada (sin cifrar) No comment provided by engineer. + + author + member role + bad message ID ID de mensaje erróneo diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index c1c46aa42a..cf161efae4 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1167,6 +1167,10 @@ Yhdistä server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Yhdistä Incognito @@ -1857,8 +1861,8 @@ This cannot be undone! Löydä ryhmiä ja liity niihin No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2491,6 +2495,10 @@ This cannot be undone! Konsoliin No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Ranskalainen käyttöliittymä @@ -3440,6 +3448,10 @@ This is your link for group %@! Ei vastaanotettuja tai lähetettyjä tiedostoja No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Ilmoitukset @@ -5383,6 +5395,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Ääniviesti… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Odottaa tiedostoa @@ -5913,6 +5929,10 @@ SimpleX-palvelimet eivät näe profiiliasi. äänipuhelu (ei e2e-salattu) No comment provided by engineer. + + author + member role + bad message ID virheellinen viestin tunniste diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 78a23315cb..af54b7ceb9 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -299,10 +299,12 @@ (new) + (nouveau) No comment provided by engineer. (this device v%@) + (cet appareil v%@) No comment provided by engineer. @@ -397,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - option pour notifier les contacts supprimés. +- noms de profil avec espaces. +- et plus encore ! No comment provided by engineer. @@ -885,6 +890,7 @@ Bad desktop address + Mauvaise adresse de bureau No comment provided by engineer. @@ -899,6 +905,7 @@ Better groups + Des groupes plus performants No comment provided by engineer. @@ -913,6 +920,7 @@ Block group members + Bloquer des membres d'un groupe No comment provided by engineer. @@ -1186,6 +1194,10 @@ Se connecter server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Se connecter incognito @@ -1193,6 +1205,7 @@ Connect to desktop + Se connecter au bureau No comment provided by engineer. @@ -1241,10 +1254,12 @@ Il s'agit de votre propre lien unique ! Connected desktop + Bureau connecté No comment provided by engineer. Connected to desktop + Connecté au bureau No comment provided by engineer. @@ -1259,6 +1274,7 @@ Il s'agit de votre propre lien unique ! Connecting to desktop + Connexion au bureau No comment provided by engineer. @@ -1283,6 +1299,7 @@ Il s'agit de votre propre lien unique ! Connection terminated + Connexion terminée No comment provided by engineer. @@ -1367,6 +1384,7 @@ Il s'agit de votre propre lien unique ! Create a group using a random profile. + Création de groupes via un profil aléatoire. No comment provided by engineer. @@ -1781,14 +1799,17 @@ Cette opération ne peut être annulée ! Desktop address + Adresse de bureau No comment provided by engineer. Desktop app version %@ is not compatible with this app. + La version de l'application de bureau %@ n'est pas compatible avec cette application. No comment provided by engineer. Desktop devices + Appareils de bureau No comment provided by engineer. @@ -1883,6 +1904,7 @@ Cette opération ne peut être annulée ! Disconnect desktop? + Déconnecter le bureau ? No comment provided by engineer. @@ -1890,8 +1912,8 @@ Cette opération ne peut être annulée ! Découvrir et rejoindre des groupes No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2066,10 +2088,12 @@ Cette opération ne peut être annulée ! Encryption re-negotiation error + Erreur lors de la renégociation du chiffrement message decrypt error item Encryption re-negotiation failed. + La renégociation du chiffrement a échoué. No comment provided by engineer. @@ -2104,6 +2128,7 @@ Cette opération ne peut être annulée ! Enter this device name… + Entrez le nom de l'appareil… No comment provided by engineer. @@ -2433,6 +2458,7 @@ Cette opération ne peut être annulée ! Faster joining and more reliable messages. + Connexion plus rapide et messages plus fiables. No comment provided by engineer. @@ -2530,6 +2556,10 @@ Cette opération ne peut être annulée ! Pour la console No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Interface en français @@ -2857,6 +2887,7 @@ Cette opération ne peut être annulée ! Incognito groups + Groupes incognito No comment provided by engineer. @@ -2891,6 +2922,7 @@ Cette opération ne peut être annulée ! Incompatible version + Version incompatible No comment provided by engineer. @@ -3065,6 +3097,7 @@ Voici votre lien pour le groupe %@ ! Keep the app open to use it from desktop + Garder l'application ouverte pour l'utiliser depuis le bureau No comment provided by engineer. @@ -3129,14 +3162,17 @@ Voici votre lien pour le groupe %@ ! Link mobile and desktop apps! 🔗 + Liez vos applications mobiles et de bureau ! 🔗 No comment provided by engineer. Linked desktop options + Options de bureau lié No comment provided by engineer. Linked desktops + Bureaux liés No comment provided by engineer. @@ -3489,6 +3525,10 @@ Voici votre lien pour le groupe %@ ! Aucun fichier reçu ou envoyé No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Notifications @@ -3710,6 +3750,7 @@ Voici votre lien pour le groupe %@ ! Paste desktop address + Coller l'adresse du bureau No comment provided by engineer. @@ -4309,6 +4350,7 @@ Voici votre lien pour le groupe %@ ! Scan QR code from desktop + Scanner le code QR du bureau No comment provided by engineer. @@ -4533,6 +4575,7 @@ Voici votre lien pour le groupe %@ ! Session code + Code de session No comment provided by engineer. @@ -5034,6 +5077,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. This device name + Ce nom d'appareil No comment provided by engineer. @@ -5073,6 +5117,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. To hide unwanted messages. + Pour cacher les messages indésirables. No comment provided by engineer. @@ -5236,10 +5281,12 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Unlink + Délier No comment provided by engineer. Unlink desktop? + Délier le bureau ? No comment provided by engineer. @@ -5334,6 +5381,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use from desktop + Utilisation depuis le bureau No comment provided by engineer. @@ -5368,10 +5416,12 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Verify code with desktop + Vérifier le code avec le bureau No comment provided by engineer. Verify connection + Vérifier la connexion No comment provided by engineer. @@ -5381,6 +5431,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Verify connections + Vérifier les connexions No comment provided by engineer. @@ -5395,6 +5446,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Via secure quantum resistant protocol. + Via un protocole sécurisé de cryptographie post-quantique. No comment provided by engineer. @@ -5447,6 +5499,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Message vocal… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file En attente du fichier @@ -5992,6 +6048,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. appel audio (sans chiffrement) No comment provided by engineer. + + author + member role + bad message ID ID de message incorrecte @@ -6576,6 +6636,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6778,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX utilise un accès au réseau local pour permettre l'utilisation du profil de chat de l'utilisateur via l'application de bureau au sein de ce même réseau. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 6f99b16b77..bf1b1ee386 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -399,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - avvisa facoltativamente i contatti eliminati. +- nomi del profilo con spazi. +- e molto altro! No comment provided by engineer. @@ -902,6 +905,7 @@ Better groups + Gruppi migliorati No comment provided by engineer. @@ -916,6 +920,7 @@ Block group members + Blocca i membri dei gruppi No comment provided by engineer. @@ -1189,6 +1194,10 @@ Connetti server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Connetti in incognito @@ -1375,6 +1384,7 @@ Questo è il tuo link una tantum! Create a group using a random profile. + Crea un gruppo usando un profilo casuale. No comment provided by engineer. @@ -1902,9 +1912,8 @@ Non è reversibile! Scopri ed unisciti ai gruppi No comment provided by engineer. - - Discover on network - Trova nella rete + + Discover via local network No comment provided by engineer. @@ -2449,6 +2458,7 @@ Non è reversibile! Faster joining and more reliable messages. + Ingresso più veloce e messaggi più affidabili. No comment provided by engineer. @@ -2546,6 +2556,10 @@ Non è reversibile! Per console No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Interfaccia francese @@ -2873,6 +2887,7 @@ Non è reversibile! Incognito groups + Gruppi in incognito No comment provided by engineer. @@ -3147,6 +3162,7 @@ Questo è il tuo link per il gruppo %@! Link mobile and desktop apps! 🔗 + Collega le app mobile e desktop! 🔗 No comment provided by engineer. @@ -3509,6 +3525,10 @@ Questo è il tuo link per il gruppo %@! Nessun file ricevuto o inviato No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Notifiche @@ -5097,6 +5117,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. To hide unwanted messages. + Per nascondere messaggi indesiderati. No comment provided by engineer. @@ -5425,6 +5446,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Via secure quantum resistant protocol. + Tramite protocollo sicuro resistente alla quantistica. No comment provided by engineer. @@ -5477,6 +5499,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Messaggio vocale… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file In attesa del file @@ -6022,6 +6048,10 @@ I server di SimpleX non possono vedere il tuo profilo. chiamata audio (non crittografata e2e) No comment provided by engineer. + + author + member role + bad message ID ID messaggio errato diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index bae6d393d0..11ca09ba3b 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1169,6 +1169,10 @@ 接続 server test step + + Connect automatically + No comment provided by engineer. + Connect incognito シークレットモードで接続 @@ -1859,8 +1863,8 @@ This cannot be undone! グループを見つけて参加する No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2494,6 +2498,10 @@ This cannot be undone! コンソール No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface フランス語UI @@ -3443,6 +3451,10 @@ This is your link for group %@! 送受信済みのファイルがありません No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications 通知 @@ -5379,6 +5391,10 @@ To connect, please ask your contact to create another connection link and check 音声メッセージ… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file ファイル待ち @@ -5909,6 +5925,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 音声通話 (エンドツーエンド暗号化なし) No comment provided by engineer. + + author + member role + bad message ID メッセージ ID が正しくありません diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 8b6633c3db..d2be34ec6c 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -399,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - optioneel verwijderde contacten op de hoogte stellen. +- profielnamen met spaties. +- en meer! No comment provided by engineer. @@ -902,6 +905,7 @@ Better groups + Betere groepen No comment provided by engineer. @@ -916,6 +920,7 @@ Block group members + Groepsleden blokkeren No comment provided by engineer. @@ -1189,6 +1194,10 @@ Verbind server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Verbind incognito @@ -1375,6 +1384,7 @@ Dit is uw eigen eenmalige link! Create a group using a random profile. + Maak een groep met een willekeurig profiel. No comment provided by engineer. @@ -1902,9 +1912,8 @@ Dit kan niet ongedaan gemaakt worden! Ontdek en sluit je aan bij groepen No comment provided by engineer. - - Discover on network - Ontdek via netwerk + + Discover via local network No comment provided by engineer. @@ -2449,6 +2458,7 @@ Dit kan niet ongedaan gemaakt worden! Faster joining and more reliable messages. + Snellere deelname en betrouwbaardere berichten. No comment provided by engineer. @@ -2546,6 +2556,10 @@ Dit kan niet ongedaan gemaakt worden! Voor console No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Franse interface @@ -2873,6 +2887,7 @@ Dit kan niet ongedaan gemaakt worden! Incognito groups + Incognitogroepen No comment provided by engineer. @@ -3147,6 +3162,7 @@ Dit is jouw link voor groep %@! Link mobile and desktop apps! 🔗 + Koppel mobiele en desktop-apps! 🔗 No comment provided by engineer. @@ -3509,6 +3525,10 @@ Dit is jouw link voor groep %@! Geen ontvangen of verzonden bestanden No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Meldingen @@ -5097,6 +5117,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. To hide unwanted messages. + Om ongewenste berichten te verbergen. No comment provided by engineer. @@ -5425,6 +5446,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Via secure quantum resistant protocol. + Via een beveiligd kwantumbestendig protocol. No comment provided by engineer. @@ -5477,6 +5499,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Spraakbericht… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Wachten op bestand @@ -6022,6 +6048,10 @@ SimpleX servers kunnen uw profiel niet zien. audio oproep (niet e2e versleuteld) No comment provided by engineer. + + author + member role + bad message ID Onjuiste bericht-ID diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 22d10aa434..61147d7f98 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -1186,6 +1186,10 @@ Połącz server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Połącz incognito @@ -1890,8 +1894,8 @@ To nie może być cofnięte! Odkrywaj i dołączaj do grup No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2530,6 +2534,10 @@ To nie może być cofnięte! Dla konsoli No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Francuski interfejs @@ -3489,6 +3497,10 @@ To jest twój link do grupy %@! Brak odebranych lub wysłanych plików No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Powiadomienia @@ -5447,6 +5459,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Wiadomość głosowa… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Oczekiwanie na plik @@ -5992,6 +6008,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. połączenie audio (nie szyfrowane e2e) No comment provided by engineer. + + author + member role + bad message ID zły identyfikator wiadomości diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 7fd4cd51b3..abd3d01d87 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ и %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ соединен(а) No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ и %lld членов группы No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld событий No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld сообщений заблокировано No comment provided by engineer. %lld messages marked deleted + %lld сообщений помечено удалёнными No comment provided by engineer. %lld messages moderated by %@ + %lld сообщений модерировано членом %@ No comment provided by engineer. @@ -292,10 +299,12 @@ (new) + (новое) No comment provided by engineer. (this device v%@) + (это устройство v%@) No comment provided by engineer. @@ -390,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - опционально уведомляйте удалённые контакты. +- имена профилей с пробелами. +- и прочее! No comment provided by engineer. @@ -408,6 +420,7 @@ 0 sec + 0 сек time to disappear @@ -637,6 +650,7 @@ All new messages from %@ will be hidden! + Все новые сообщения от %@ будут скрыты! No comment provided by engineer. @@ -746,10 +760,12 @@ Already connecting! + Уже соединяется! No comment provided by engineer. Already joining the group! + Вступление в группу уже начато! No comment provided by engineer. @@ -874,6 +890,7 @@ Bad desktop address + Неверный адрес компьютера No comment provided by engineer. @@ -888,6 +905,7 @@ Better groups + Улучшенные группы No comment provided by engineer. @@ -897,18 +915,22 @@ Block + Заблокировать No comment provided by engineer. Block group members + Блокируйте членов группы No comment provided by engineer. Block member + Заблокировать члена группы No comment provided by engineer. Block member? + Заблокировать члена группы? No comment provided by engineer. @@ -1172,6 +1194,10 @@ Соединиться server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Соединиться Инкогнито @@ -1179,24 +1205,31 @@ Connect to desktop + Подключиться к компьютеру No comment provided by engineer. Connect to yourself? + Соединиться с самим собой? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Соединиться с самим собой? +Это ваш собственный адрес SimpleX! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Соединиться с самим собой? +Это ваша собственная одноразовая ссылка! No comment provided by engineer. Connect via contact address + Соединиться через адрес No comment provided by engineer. @@ -1216,14 +1249,17 @@ This is your own one-time link! Connect with %@ + Соединиться с %@ No comment provided by engineer. Connected desktop + Подключенный компьютер No comment provided by engineer. Connected to desktop + Компьютер подключен No comment provided by engineer. @@ -1238,6 +1274,7 @@ This is your own one-time link! Connecting to desktop + Подключение к компьютеру No comment provided by engineer. @@ -1262,6 +1299,7 @@ This is your own one-time link! Connection terminated + Подключение прервано No comment provided by engineer. @@ -1331,6 +1369,7 @@ This is your own one-time link! Correct name to %@? + Исправить имя на %@? No comment provided by engineer. @@ -1345,6 +1384,7 @@ This is your own one-time link! Create a group using a random profile. + Создайте группу, используя случайный профиль. No comment provided by engineer. @@ -1359,6 +1399,7 @@ This is your own one-time link! Create group + Создать группу No comment provided by engineer. @@ -1383,6 +1424,7 @@ This is your own one-time link! Create profile + Создать профиль No comment provided by engineer. @@ -1545,6 +1587,7 @@ This is your own one-time link! Delete %lld messages? + Удалить %lld сообщений? No comment provided by engineer. @@ -1574,6 +1617,7 @@ This is your own one-time link! Delete and notify contact + Удалить и уведомить контакт No comment provided by engineer. @@ -1609,6 +1653,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Удалить контакт? +Это не может быть отменено! No comment provided by engineer. @@ -1753,14 +1799,17 @@ This cannot be undone! Desktop address + Адрес компьютера No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Версия настольного приложения %@ несовместима с этим приложением. No comment provided by engineer. Desktop devices + Компьютеры No comment provided by engineer. @@ -1855,6 +1904,7 @@ This cannot be undone! Disconnect desktop? + Отключить компьютер? No comment provided by engineer. @@ -1862,8 +1912,8 @@ This cannot be undone! Найдите и вступите в группы No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2038,10 +2088,12 @@ This cannot be undone! Encryption re-negotiation error + Ошибка нового соглашения о шифровании message decrypt error item Encryption re-negotiation failed. + Ошибка нового соглашения о шифровании. No comment provided by engineer. @@ -2056,6 +2108,7 @@ This cannot be undone! Enter group name… + Введите имя группы… No comment provided by engineer. @@ -2075,6 +2128,7 @@ This cannot be undone! Enter this device name… + Введите имя этого устройства… No comment provided by engineer. @@ -2089,6 +2143,7 @@ This cannot be undone! Enter your name… + Введите ваше имя… No comment provided by engineer. @@ -2148,6 +2203,7 @@ This cannot be undone! Error creating member contact + Ошибка создания контакта с членом группы No comment provided by engineer. @@ -2282,6 +2338,7 @@ This cannot be undone! Error sending member contact invitation + Ошибка отправки приглашения члену группы No comment provided by engineer. @@ -2366,6 +2423,7 @@ This cannot be undone! Expand + Раскрыть chat item action @@ -2400,6 +2458,7 @@ This cannot be undone! Faster joining and more reliable messages. + Быстрое вступление и надежная доставка сообщений. No comment provided by engineer. @@ -2497,6 +2556,10 @@ This cannot be undone! Для консоли No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Французский интерфейс @@ -2519,6 +2582,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Группа полностью децентрализована – она видна только членам. No comment provided by engineer. @@ -2543,10 +2607,12 @@ This cannot be undone! Group already exists + Группа уже существует No comment provided by engineer. Group already exists! + Группа уже существует! No comment provided by engineer. @@ -2821,6 +2887,7 @@ This cannot be undone! Incognito groups + Инкогнито группы No comment provided by engineer. @@ -2855,6 +2922,7 @@ This cannot be undone! Incompatible version + Несовместимая версия No comment provided by engineer. @@ -2906,6 +2974,7 @@ This cannot be undone! Invalid name! + Неверное имя! No comment provided by engineer. @@ -3001,6 +3070,7 @@ This cannot be undone! Join group? + Вступить в группу? No comment provided by engineer. @@ -3010,11 +3080,14 @@ This cannot be undone! Join with current profile + Вступить с активным профилем No comment provided by engineer. Join your group? This is your link for group %@! + Вступить в вашу группу? +Это ваша ссылка на группу %@! No comment provided by engineer. @@ -3024,6 +3097,7 @@ This is your link for group %@! Keep the app open to use it from desktop + Оставьте приложение открытым, чтобы использовать его с компьютера No comment provided by engineer. @@ -3088,14 +3162,17 @@ This is your link for group %@! Link mobile and desktop apps! 🔗 + Свяжите мобильное и настольное приложения! 🔗 No comment provided by engineer. Linked desktop options + Опции связанных компьютеров No comment provided by engineer. Linked desktops + Связанные компьютеры No comment provided by engineer. @@ -3250,6 +3327,7 @@ This is your link for group %@! Messages from %@ will be shown! + Сообщения от %@ будут показаны! No comment provided by engineer. @@ -3447,6 +3525,10 @@ This is your link for group %@! Нет полученных или отправленных файлов No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Уведомления @@ -3583,6 +3665,7 @@ This is your link for group %@! Open + Открыть No comment provided by engineer. @@ -3602,6 +3685,7 @@ This is your link for group %@! Open group + Открыть группу No comment provided by engineer. @@ -3666,6 +3750,7 @@ This is your link for group %@! Paste desktop address + Вставить адрес компьютера No comment provided by engineer. @@ -3815,10 +3900,12 @@ This is your link for group %@! Profile name + Имя профиля No comment provided by engineer. Profile name: + Имя профиля: No comment provided by engineer. @@ -4068,10 +4155,12 @@ This is your link for group %@! Repeat connection request? + Повторить запрос на соединение? No comment provided by engineer. Repeat join request? + Повторить запрос на вступление? No comment provided by engineer. @@ -4261,6 +4350,7 @@ This is your link for group %@! Scan QR code from desktop + Сканировать QR код с компьютера No comment provided by engineer. @@ -4345,6 +4435,7 @@ This is your link for group %@! Send direct message to connect + Отправьте сообщение чтобы соединиться No comment provided by engineer. @@ -4484,6 +4575,7 @@ This is your link for group %@! Session code + Код сессии No comment provided by engineer. @@ -4798,6 +4890,7 @@ This is your link for group %@! Tap to Connect + Нажмите чтобы соединиться No comment provided by engineer. @@ -4984,6 +5077,7 @@ It can happen because of some bug or when the connection is compromised. This device name + Имя этого устройства No comment provided by engineer. @@ -4998,10 +5092,12 @@ It can happen because of some bug or when the connection is compromised. This is your own SimpleX address! + Это ваш собственный адрес SimpleX! No comment provided by engineer. This is your own one-time link! + Это ваша собственная одноразовая ссылка! No comment provided by engineer. @@ -5021,6 +5117,7 @@ It can happen because of some bug or when the connection is compromised. To hide unwanted messages. + Чтобы скрыть нежелательные сообщения. No comment provided by engineer. @@ -5107,14 +5204,17 @@ You will be prompted to complete authentication before this feature is enabled.< Unblock + Разблокировать No comment provided by engineer. Unblock member + Разблокировать члена группы No comment provided by engineer. Unblock member? + Разблокировать члена группы? No comment provided by engineer. @@ -5181,10 +5281,12 @@ To connect, please ask your contact to create another connection link and check Unlink + Забыть No comment provided by engineer. Unlink desktop? + Забыть компьютер? No comment provided by engineer. @@ -5279,6 +5381,7 @@ To connect, please ask your contact to create another connection link and check Use from desktop + Использовать с компьютера No comment provided by engineer. @@ -5313,10 +5416,12 @@ To connect, please ask your contact to create another connection link and check Verify code with desktop + Сверьте код с компьютером No comment provided by engineer. Verify connection + Проверить соединение No comment provided by engineer. @@ -5326,6 +5431,7 @@ To connect, please ask your contact to create another connection link and check Verify connections + Проверять соединения No comment provided by engineer. @@ -5340,6 +5446,7 @@ To connect, please ask your contact to create another connection link and check Via secure quantum resistant protocol. + Через безопасный квантово-устойчивый протокол. No comment provided by engineer. @@ -5392,6 +5499,10 @@ To connect, please ask your contact to create another connection link and check Голосовое сообщение… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Ожидается прием файла @@ -5494,31 +5605,39 @@ To connect, please ask your contact to create another connection link and check You are already connecting to %@. + Вы уже соединяетесь с %@. No comment provided by engineer. You are already connecting via this one-time link! + Вы уже соединяетесь по этой одноразовой ссылке! No comment provided by engineer. You are already in group %@. + Вы уже состоите в группе %@. No comment provided by engineer. You are already joining the group %@. + Вы уже вступаете в группу %@. No comment provided by engineer. You are already joining the group via this link! + Вы уже вступаете в группу по этой ссылке! No comment provided by engineer. You are already joining the group via this link. + Вы уже вступаете в группу по этой ссылке. No comment provided by engineer. You are already joining the group! Repeat join request? + Вы уже вступаете в группу! +Повторить запрос на вступление? No comment provided by engineer. @@ -5618,11 +5737,14 @@ Repeat join request? You have already requested connection via this address! + Вы уже запросили соединение через этот адрес! No comment provided by engineer. You have already requested connection! Repeat connection request? + Вы уже запросили соединение! +Повторить запрос? No comment provided by engineer. @@ -5677,6 +5799,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Соединение будет установлено, когда владелец ссылки группы будет онлайн. Пожалуйста, подождите или проверьте позже! No comment provided by engineer. @@ -5696,6 +5819,7 @@ Repeat connection request? You will connect to all group members. + Вы соединитесь со всеми членами группы. No comment provided by engineer. @@ -5819,6 +5943,7 @@ You can change it in Settings. Your profile + Ваш профиль No comment provided by engineer. @@ -5915,6 +6040,7 @@ SimpleX серверы не могут получить доступ к Ваше and %lld other events + и %lld других событий No comment provided by engineer. @@ -5922,6 +6048,10 @@ SimpleX серверы не могут получить доступ к Ваше аудиозвонок (не e2e зашифрованный) No comment provided by engineer. + + author + member role + bad message ID ошибка ID сообщения @@ -5934,6 +6064,7 @@ SimpleX серверы не могут получить доступ к Ваше blocked + заблокировано No comment provided by engineer. @@ -6008,6 +6139,7 @@ SimpleX серверы не могут получить доступ к Ваше connected directly + соединен(а) напрямую rcv group event chat item @@ -6107,6 +6239,7 @@ SimpleX серверы не могут получить доступ к Ваше deleted contact + удалил(а) контакт rcv direct event chat item @@ -6473,6 +6606,7 @@ SimpleX серверы не могут получить доступ к Ваше send direct message + отправьте сообщение No comment provided by engineer. @@ -6502,6 +6636,7 @@ SimpleX серверы не могут получить доступ к Ваше v%@ + v%@ No comment provided by engineer. @@ -6643,6 +6778,7 @@ SimpleX серверы не могут получить доступ к Ваше SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX использует доступ к локальной сети, чтобы разрешить использование профиля чата через компьютер в той же сети. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 6ada6dbbbe..bcb39e6e03 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1159,6 +1159,10 @@ เชื่อมต่อ server test step + + Connect automatically + No comment provided by engineer. + Connect incognito No comment provided by engineer. @@ -1844,8 +1848,8 @@ This cannot be undone! Discover and join groups No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2476,6 +2480,10 @@ This cannot be undone! สำหรับคอนโซล No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface อินเทอร์เฟซภาษาฝรั่งเศส @@ -3421,6 +3429,10 @@ This is your link for group %@! ไม่มีไฟล์ที่ได้รับหรือส่ง No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications การแจ้งเตือน @@ -5354,6 +5366,10 @@ To connect, please ask your contact to create another connection link and check ข้อความเสียง… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file กำลังรอไฟล์ @@ -5882,6 +5898,10 @@ SimpleX servers cannot see your profile. การโทรด้วยเสียง (ไม่ได้ encrypt จากต้นจนจบ) No comment provided by engineer. + + author + member role + bad message ID ID ข้อความที่ไม่ดี diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 868f420630..abd58231a9 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1166,6 +1166,10 @@ Підключіться server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Підключайтеся інкогніто @@ -1854,8 +1858,8 @@ This cannot be undone! Discover and join groups No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2486,6 +2490,10 @@ This cannot be undone! Для консолі No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Французький інтерфейс @@ -3435,6 +3443,10 @@ This is your link for group %@! Немає отриманих або відправлених файлів No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Сповіщення @@ -5378,6 +5390,10 @@ To connect, please ask your contact to create another connection link and check Голосове повідомлення… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Очікування файлу @@ -5908,6 +5924,10 @@ SimpleX servers cannot see your profile. аудіовиклик (без шифрування e2e) No comment provided by engineer. + + author + member role + bad message ID невірний ідентифікатор повідомлення diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 77374aefa5..d96537be3e 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -1172,6 +1172,10 @@ 连接 server test step + + Connect automatically + No comment provided by engineer. + Connect incognito 在隐身状态下连接 @@ -1862,8 +1866,8 @@ This cannot be undone! 发现和加入群组 No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! 用于控制台 No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface 法语界面 @@ -3449,6 +3457,10 @@ This is your link for group %@! 未收到或发送文件 No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications 通知 @@ -5396,6 +5408,10 @@ To connect, please ask your contact to create another connection link and check 语音消息…… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file 等待文件中 @@ -5926,6 +5942,10 @@ SimpleX 服务器无法看到您的资料。 语音通话(非端到端加密) No comment provided by engineer. + + author + member role + bad message ID 错误消息 ID diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 00489be829..c860a24998 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilere Zustellung von Nachrichten.\n- ein bisschen verbesserte Gruppen.\n- und mehr!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- Optionale Benachrichtigung von gelöschten Kontakten.\n- Profilnamen mit Leerzeichen.\n- Und mehr!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- Bis zu 5 Minuten lange Sprachnachrichten.\n- Zeitdauer für verschwindende Nachrichten anpassen.\n- Nachrichten-Historie bearbeiten."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(Neu)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(Dieses Gerät hat v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +557,9 @@ /* No comment provided by engineer. */ "Back" = "Zurück"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Falsche Desktop-Adresse"; + /* integrity error chat item */ "bad message hash" = "Ungültiger Nachrichten-Hash"; @@ -560,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Falsche Nachrichten-ID"; +/* No comment provided by engineer. */ +"Better groups" = "Bessere Gruppen"; + /* No comment provided by engineer. */ "Better messages" = "Verbesserungen bei Nachrichten"; /* No comment provided by engineer. */ "Block" = "Blockieren"; +/* No comment provided by engineer. */ +"Block group members" = "Gruppenmitglieder blockieren"; + /* No comment provided by engineer. */ "Block member" = "Mitglied blockieren"; @@ -771,6 +789,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Inkognito verbinden"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Mit dem Desktop verbinden"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "Mit den SimpleX Chat-Entwicklern verbinden."; @@ -801,9 +822,15 @@ /* No comment provided by engineer. */ "connected" = "Verbunden"; +/* No comment provided by engineer. */ +"Connected desktop" = "Verbundener Desktop"; + /* rcv group event chat item */ "connected directly" = "Direkt miteinander verbunden"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Mit dem Desktop verbunden"; + /* No comment provided by engineer. */ "connecting" = "verbinde"; @@ -828,6 +855,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Mit dem Server verbinden… (Fehler: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Mit dem Desktop verbinden"; + /* chat list item title */ "connecting…" = "Verbinde…"; @@ -846,6 +876,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Verbindungsanfrage wurde gesendet!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Verbindung beendet"; + /* No comment provided by engineer. */ "Connection timeout" = "Verbindungszeitüberschreitung"; @@ -900,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Erstellen"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Erstellen Sie eine Gruppe mit einem zufälligen Profil."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können."; @@ -1173,6 +1209,15 @@ /* No comment provided by engineer. */ "Description" = "Beschreibung"; +/* No comment provided by engineer. */ +"Desktop address" = "Desktop-Adresse"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "Desktop App-Version %@ ist mit dieser App nicht kompatibel."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Desktop-Geräte"; + /* No comment provided by engineer. */ "Develop" = "Entwicklung"; @@ -1236,6 +1281,9 @@ /* server test step */ "Disconnect" = "Trennen"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Desktop-Verbindung trennen?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Gruppen entdecken und ihnen beitreten"; @@ -1374,6 +1422,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "Neuaushandlung der Verschlüsselung von %@ erlaubt"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Fehler bei der Neuverhandlung der Verschlüsselung"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Neuverhandlung der Verschlüsselung fehlgeschlagen."; + /* chat item text */ "encryption re-negotiation required" = "Neuaushandlung der Verschlüsselung notwendig"; @@ -1404,6 +1458,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Geben Sie den Server manuell ein"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Geben Sie diesen Gerätenamen ein…"; + /* placeholder */ "Enter welcome message…" = "Geben Sie eine Begrüßungsmeldung ein …"; @@ -1605,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Schnell und ohne warten auf den Absender, bis er online ist!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung."; + /* No comment provided by engineer. */ "Favorite" = "Favorit"; @@ -1866,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Inkognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Inkognito-Gruppen"; + /* No comment provided by engineer. */ "Incognito mode" = "Inkognito-Modus"; @@ -1893,6 +1956,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Inkompatible Datenbank-Version"; +/* No comment provided by engineer. */ +"Incompatible version" = "Inkompatible Version"; + /* PIN entry */ "Incorrect passcode" = "Zugangscode ist falsch"; @@ -2028,6 +2094,9 @@ /* No comment provided by engineer. */ "Joining group" = "Der Gruppe beitreten"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Die App muss geöffnet bleiben, um sie vom Desktop aus nutzen zu können"; + /* No comment provided by engineer. */ "Keep your connections" = "Ihre Verbindungen beibehalten"; @@ -2064,6 +2133,15 @@ /* No comment provided by engineer. */ "Limitations" = "Einschränkungen"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Verknüpfe Mobiltelefon- und Desktop-Apps! 🔗"; + +/* No comment provided by engineer. */ +"Linked desktop options" = "Verknüpfte Desktop-Optionen"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Verknüpfte Desktops"; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2462,6 +2540,9 @@ /* No comment provided by engineer. */ "Paste" = "Einfügen"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Desktop-Adresse einfügen"; + /* No comment provided by engineer. */ "Paste image" = "Bild einfügen"; @@ -2846,6 +2927,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "QR-Code scannen"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Den QR-Code vom Desktop scannen"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scannen Sie den Sicherheitscode von der App Ihres Kontakts."; @@ -2990,6 +3074,9 @@ /* No comment provided by engineer. */ "Servers" = "Server"; +/* No comment provided by engineer. */ +"Session code" = "Sitzungscode"; + /* No comment provided by engineer. */ "Set 1 day" = "Einen Tag festlegen"; @@ -3299,6 +3386,9 @@ /* notification title */ "this contact" = "Dieser Kontakt"; +/* No comment provided by engineer. */ +"This device name" = "Dieser Gerätename"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Es werden keine Empfangsbestätigungen gesendet, da diese Gruppe über %lld Mitglieder hat."; @@ -3320,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Um eine Verbindung herzustellen, kann Ihr Kontakt den QR-Code scannen oder den Link in der App verwenden."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Um unerwünschte Nachrichten zu verbergen."; + /* No comment provided by engineer. */ "To make a new connection" = "Um eine Verbindung mit einem neuen Kontakt zu erstellen"; @@ -3416,6 +3509,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Entweder hat Ihr Kontakt die Verbindung gelöscht, oder dieser Link wurde bereits verwendet, es könnte sich um einen Fehler handeln - Bitte melden Sie es uns.\nBitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um sich neu verbinden zu können und stellen Sie sicher, dass Sie eine stabile Netzwerk-Verbindung haben."; +/* No comment provided by engineer. */ +"Unlink" = "Entkoppeln"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Desktop entkoppeln?"; + /* No comment provided by engineer. */ "Unlock" = "Entsperren"; @@ -3470,6 +3569,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Für neue Verbindungen nutzen"; +/* No comment provided by engineer. */ +"Use from desktop" = "Vom Desktop aus nutzen"; + /* No comment provided by engineer. */ "Use iOS call interface" = "iOS Anrufschnittstelle nutzen"; @@ -3491,12 +3593,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Verwendung von SimpleX-Chat-Servern."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Code mit dem Desktop überprüfen"; + +/* No comment provided by engineer. */ +"Verify connection" = "Verbindung überprüfen"; + /* No comment provided by engineer. */ "Verify connection security" = "Sicherheit der Verbindung überprüfen"; +/* No comment provided by engineer. */ +"Verify connections" = "Verbindungen überprüfen"; + /* No comment provided by engineer. */ "Verify security code" = "Sicherheitscode überprüfen"; @@ -3515,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "über Relais"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Über ein sicheres quantenbeständiges Protokoll."; + /* No comment provided by engineer. */ "Video call" = "Videoanruf"; diff --git a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings index 6a33e41774..5fe2ef2d09 100644 --- a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "Face ID wird von SimpleX für die lokale Authentifizierung genutzt"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX nutzt den lokalen Netzwerkzugriff, um die Nutzung von Benutzer-Chatprofilen über eine Desktop-App im gleichen Netzwerk zu erlauben."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX benötigt Zugriff auf das Mikrofon, um Audio- und Videoanrufe und die Aufnahme von Sprachnachrichten zu ermöglichen."; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 33f571b513..b71d510e72 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- une diffusion plus stable des messages.\n- des groupes un peu plus performants.\n- et bien d'autres choses encore !"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- option pour notifier les contacts supprimés.\n- noms de profil avec espaces.\n- et plus encore !"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messages vocaux pouvant durer jusqu'à 5 minutes.\n- délai personnalisé de disparition.\n- l'historique de modification."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nouveau)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(cet appareil v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +557,9 @@ /* No comment provided by engineer. */ "Back" = "Retour"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Mauvaise adresse de bureau"; + /* integrity error chat item */ "bad message hash" = "hash de message incorrect"; @@ -560,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Mauvais ID de message"; +/* No comment provided by engineer. */ +"Better groups" = "Des groupes plus performants"; + /* No comment provided by engineer. */ "Better messages" = "Meilleurs messages"; /* No comment provided by engineer. */ "Block" = "Bloquer"; +/* No comment provided by engineer. */ +"Block group members" = "Bloquer des membres d'un groupe"; + /* No comment provided by engineer. */ "Block member" = "Bloquer ce membre"; @@ -771,6 +789,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Se connecter incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Se connecter au bureau"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "se connecter aux developpeurs de SimpleX Chat."; @@ -801,9 +822,15 @@ /* No comment provided by engineer. */ "connected" = "connecté"; +/* No comment provided by engineer. */ +"Connected desktop" = "Bureau connecté"; + /* rcv group event chat item */ "connected directly" = "s'est connecté.e de manière directe"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Connecté au bureau"; + /* No comment provided by engineer. */ "connecting" = "connexion"; @@ -828,6 +855,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connexion au serveur… (erreur : %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Connexion au bureau"; + /* chat list item title */ "connecting…" = "connexion…"; @@ -846,6 +876,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Demande de connexion envoyée !"; +/* No comment provided by engineer. */ +"Connection terminated" = "Connexion terminée"; + /* No comment provided by engineer. */ "Connection timeout" = "Délai de connexion"; @@ -900,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Créer"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Création de groupes via un profil aléatoire."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Créez une adresse pour permettre aux gens de vous contacter."; @@ -1173,6 +1209,15 @@ /* No comment provided by engineer. */ "Description" = "Description"; +/* No comment provided by engineer. */ +"Desktop address" = "Adresse de bureau"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "La version de l'application de bureau %@ n'est pas compatible avec cette application."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Appareils de bureau"; + /* No comment provided by engineer. */ "Develop" = "Développer"; @@ -1236,6 +1281,9 @@ /* server test step */ "Disconnect" = "Se déconnecter"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Déconnecter le bureau ?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Découvrir et rejoindre des groupes"; @@ -1374,6 +1422,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "renégociation de chiffrement autorisée pour %@"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Erreur lors de la renégociation du chiffrement"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "La renégociation du chiffrement a échoué."; + /* chat item text */ "encryption re-negotiation required" = "renégociation de chiffrement requise"; @@ -1404,6 +1458,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Entrer un serveur manuellement"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Entrez le nom de l'appareil…"; + /* placeholder */ "Enter welcome message…" = "Entrez un message de bienvenue…"; @@ -1605,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne !"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Connexion plus rapide et messages plus fiables."; + /* No comment provided by engineer. */ "Favorite" = "Favoris"; @@ -1866,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Groupes incognito"; + /* No comment provided by engineer. */ "Incognito mode" = "Mode Incognito"; @@ -1893,6 +1956,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Version de la base de données incompatible"; +/* No comment provided by engineer. */ +"Incompatible version" = "Version incompatible"; + /* PIN entry */ "Incorrect passcode" = "Code d'accès erroné"; @@ -2028,6 +2094,9 @@ /* No comment provided by engineer. */ "Joining group" = "Entrain de rejoindre le groupe"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Garder l'application ouverte pour l'utiliser depuis le bureau"; + /* No comment provided by engineer. */ "Keep your connections" = "Conserver vos connexions"; @@ -2064,6 +2133,15 @@ /* No comment provided by engineer. */ "Limitations" = "Limitations"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Liez vos applications mobiles et de bureau ! 🔗"; + +/* No comment provided by engineer. */ +"Linked desktop options" = "Options de bureau lié"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Bureaux liés"; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2462,6 +2540,9 @@ /* No comment provided by engineer. */ "Paste" = "Coller"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Coller l'adresse du bureau"; + /* No comment provided by engineer. */ "Paste image" = "Coller l'image"; @@ -2846,6 +2927,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Scanner un code QR"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Scanner le code QR du bureau"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scannez le code de sécurité depuis l'application de votre contact."; @@ -2990,6 +3074,9 @@ /* No comment provided by engineer. */ "Servers" = "Serveurs"; +/* No comment provided by engineer. */ +"Session code" = "Code de session"; + /* No comment provided by engineer. */ "Set 1 day" = "Définir 1 jour"; @@ -3299,6 +3386,9 @@ /* notification title */ "this contact" = "ce contact"; +/* No comment provided by engineer. */ +"This device name" = "Ce nom d'appareil"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Ce groupe compte plus de %lld membres, les accusés de réception ne sont pas envoyés."; @@ -3320,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Pour se connecter, votre contact peut scanner le code QR ou utiliser le lien dans l'application."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Pour cacher les messages indésirables."; + /* No comment provided by engineer. */ "To make a new connection" = "Pour établir une nouvelle connexion"; @@ -3416,6 +3509,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s'agir d'un bug - veuillez le signaler.\nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d'une connexion réseau stable."; +/* No comment provided by engineer. */ +"Unlink" = "Délier"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Délier le bureau ?"; + /* No comment provided by engineer. */ "Unlock" = "Déverrouiller"; @@ -3470,6 +3569,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Utiliser pour les nouvelles connexions"; +/* No comment provided by engineer. */ +"Use from desktop" = "Utilisation depuis le bureau"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Utiliser l'interface d'appel d'iOS"; @@ -3491,12 +3593,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Utilisation des serveurs SimpleX Chat."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Vérifier le code avec le bureau"; + +/* No comment provided by engineer. */ +"Verify connection" = "Vérifier la connexion"; + /* No comment provided by engineer. */ "Verify connection security" = "Vérifier la sécurité de la connexion"; +/* No comment provided by engineer. */ +"Verify connections" = "Vérifier les connexions"; + /* No comment provided by engineer. */ "Verify security code" = "Vérifier le code de sécurité"; @@ -3515,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "via relais"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Via un protocole sécurisé de cryptographie post-quantique."; + /* No comment provided by engineer. */ "Video call" = "Appel vidéo"; diff --git a/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings index f04cc09b00..e5bfdf09df 100644 --- a/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleGroup not found!X utilise Face ID pour l'authentification locale"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX utilise un accès au réseau local pour permettre l'utilisation du profil de chat de l'utilisateur via l'application de bureau au sein de ce même réseau."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX a besoin d'un accès au microphone pour les appels audio et vidéo ainsi que pour enregistrer des messages vocaux."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index eb77c2fe1e..274b909da2 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- recapito dei messaggi più stabile.\n- gruppi un po' migliorati.\n- e altro ancora!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- avvisa facoltativamente i contatti eliminati.\n- nomi del profilo con spazi.\n- e molto altro!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messaggi vocali fino a 5 minuti.\n- tempo di scomparsa personalizzato.\n- cronologia delle modifiche."; @@ -569,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "ID del messaggio errato"; +/* No comment provided by engineer. */ +"Better groups" = "Gruppi migliorati"; + /* No comment provided by engineer. */ "Better messages" = "Messaggi migliorati"; /* No comment provided by engineer. */ "Block" = "Blocca"; +/* No comment provided by engineer. */ +"Block group members" = "Blocca i membri dei gruppi"; + /* No comment provided by engineer. */ "Block member" = "Blocca membro"; @@ -924,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Crea"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Crea un gruppo usando un profilo casuale."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Crea un indirizzo per consentire alle persone di connettersi con te."; @@ -1275,9 +1287,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Scopri ed unisciti ai gruppi"; -/* No comment provided by engineer. */ -"Discover on network" = "Trova nella rete"; - /* No comment provided by engineer. */ "Do it later" = "Fallo dopo"; @@ -1653,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Veloce e senza aspettare che il mittente sia in linea!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Ingresso più veloce e messaggi più affidabili."; + /* No comment provided by engineer. */ "Favorite" = "Preferito"; @@ -1914,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Gruppi in incognito"; + /* No comment provided by engineer. */ "Incognito mode" = "Modalità incognito"; @@ -2118,6 +2133,9 @@ /* No comment provided by engineer. */ "Limitations" = "Limitazioni"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Collega le app mobile e desktop! 🔗"; + /* No comment provided by engineer. */ "Linked desktop options" = "Opzioni del desktop collegato"; @@ -3392,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell'app."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Per nascondere messaggi indesiderati."; + /* No comment provided by engineer. */ "To make a new connection" = "Per creare una nuova connessione"; @@ -3608,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "via relay"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Tramite protocollo sicuro resistente alla quantistica."; + /* No comment provided by engineer. */ "Video call" = "Videochiamata"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 4366d6cb93..ac1f8b03d3 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabielere berichtbezorging.\n- een beetje betere groepen.\n- en meer!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- optioneel verwijderde contacten op de hoogte stellen.\n- profielnamen met spaties.\n- en meer!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- spraakberichten tot 5 minuten.\n- aangepaste tijd om te verdwijnen.\n- bewerkingsgeschiedenis."; @@ -569,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Onjuiste bericht-ID"; +/* No comment provided by engineer. */ +"Better groups" = "Betere groepen"; + /* No comment provided by engineer. */ "Better messages" = "Betere berichten"; /* No comment provided by engineer. */ "Block" = "Blokkeren"; +/* No comment provided by engineer. */ +"Block group members" = "Groepsleden blokkeren"; + /* No comment provided by engineer. */ "Block member" = "Lid blokkeren"; @@ -924,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Maak"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Maak een groep met een willekeurig profiel."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Maak een adres aan zodat mensen contact met je kunnen opnemen."; @@ -1275,9 +1287,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; -/* No comment provided by engineer. */ -"Discover on network" = "Ontdek via netwerk"; - /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1653,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Snel en niet wachten tot de afzender online is!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Snellere deelname en betrouwbaardere berichten."; + /* No comment provided by engineer. */ "Favorite" = "Favoriet"; @@ -1914,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Incognitogroepen"; + /* No comment provided by engineer. */ "Incognito mode" = "Incognito modus"; @@ -2118,6 +2133,9 @@ /* No comment provided by engineer. */ "Limitations" = "Beperkingen"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Koppel mobiele en desktop-apps! 🔗"; + /* No comment provided by engineer. */ "Linked desktop options" = "Gekoppelde desktop opties"; @@ -3392,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Om verbinding te maken, kan uw contact de QR-code scannen of de link in de app gebruiken."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Om ongewenste berichten te verbergen."; + /* No comment provided by engineer. */ "To make a new connection" = "Om een nieuwe verbinding te maken"; @@ -3608,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "via relais"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Via een beveiligd kwantumbestendig protocol."; + /* No comment provided by engineer. */ "Video call" = "video oproep"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 73f06afeee..7d094fcf20 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- более стабильная доставка сообщений.\n- немного улучшенные группы.\n- и прочее!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- опционально уведомляйте удалённые контакты.\n- имена профилей с пробелами.\n- и прочее!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- голосовые сообщения до 5 минут.\n- настройка времени исчезающих сообщений.\n- история редактирования."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(новое)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(это устройство v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -118,12 +127,18 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@" = "%@ и %@"; + /* No comment provided by engineer. */ "%@ and %@ connected" = "%@ и %@ соединены"; /* copied message info, at Connect automatically + Automatisch verbinden No comment provided by engineer. @@ -1914,6 +1915,7 @@ Das kann nicht rückgängig gemacht werden! Discover via local network + Lokales Netzwerk durchsuchen No comment provided by engineer. @@ -2558,6 +2560,7 @@ Das kann nicht rückgängig gemacht werden! Found desktop + Gefundener Desktop No comment provided by engineer. @@ -3527,6 +3530,7 @@ Das ist Ihr Link für die Gruppe %@! Not compatible! + Nicht kompatibel! No comment provided by engineer. @@ -5501,6 +5505,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Waiting for desktop... + Es wird auf den Desktop gewartet... No comment provided by engineer. @@ -6050,6 +6055,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. author + Autor member role diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index af54b7ceb9..153d98be3c 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1196,6 +1196,7 @@ Connect automatically + Connexion automatique No comment provided by engineer. @@ -1914,6 +1915,7 @@ Cette opération ne peut être annulée ! Discover via local network + Rechercher sur le réseau No comment provided by engineer. @@ -2558,6 +2560,7 @@ Cette opération ne peut être annulée ! Found desktop + Bureau trouvé No comment provided by engineer. @@ -3527,6 +3530,7 @@ Voici votre lien pour le groupe %@ ! Not compatible! + Non compatible ! No comment provided by engineer. @@ -5501,6 +5505,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Waiting for desktop... + En attente du bureau... No comment provided by engineer. @@ -6050,6 +6055,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. author + auteur member role diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index d2be34ec6c..094a30677a 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1196,6 +1196,7 @@ Connect automatically + Automatisch verbinden No comment provided by engineer. @@ -1914,6 +1915,7 @@ Dit kan niet ongedaan gemaakt worden! Discover via local network + Ontdek via het lokale netwerk No comment provided by engineer. @@ -2558,6 +2560,7 @@ Dit kan niet ongedaan gemaakt worden! Found desktop + Desktop gevonden No comment provided by engineer. @@ -3527,6 +3530,7 @@ Dit is jouw link voor groep %@! Not compatible! + Niet compatibel! No comment provided by engineer. @@ -5501,6 +5505,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Waiting for desktop... + Wachten op desktop... No comment provided by engineer. @@ -6050,6 +6055,7 @@ SimpleX servers kunnen uw profiel niet zien. author + auteur member role diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 61147d7f98..ad1924f4c4 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -299,10 +299,12 @@ (new) + (nowy) No comment provided by engineer. (this device v%@) + (to urządzenie v%@) No comment provided by engineer. @@ -397,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - opcjonalnie powiadamiaj usunięte kontakty. +- nazwy profili ze spacją. +- i wiele więcej! No comment provided by engineer. @@ -885,6 +890,7 @@ Bad desktop address + Zły adres komputera No comment provided by engineer. @@ -899,6 +905,7 @@ Better groups + Lepsze grupy No comment provided by engineer. @@ -913,6 +920,7 @@ Block group members + Blokuj członków grupy No comment provided by engineer. @@ -1188,6 +1196,7 @@ Connect automatically + Łącz automatycznie No comment provided by engineer. @@ -1197,6 +1206,7 @@ Connect to desktop + Połącz do komputera No comment provided by engineer. @@ -1245,10 +1255,12 @@ To jest twój jednorazowy link! Connected desktop + Połączony komputer No comment provided by engineer. Connected to desktop + Połączony do komputera No comment provided by engineer. @@ -1263,6 +1275,7 @@ To jest twój jednorazowy link! Connecting to desktop + Łączenie z komputerem No comment provided by engineer. @@ -1287,6 +1300,7 @@ To jest twój jednorazowy link! Connection terminated + Połączenie zakończone No comment provided by engineer. @@ -1371,6 +1385,7 @@ To jest twój jednorazowy link! Create a group using a random profile. + Utwórz grupę używając losowego profilu. No comment provided by engineer. @@ -1785,14 +1800,17 @@ To nie może być cofnięte! Desktop address + Adres komputera No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Wersja aplikacji komputerowej %@ nie jest kompatybilna z tą aplikacją. No comment provided by engineer. Desktop devices + Urządzenia komputerowe No comment provided by engineer. @@ -1887,6 +1905,7 @@ To nie może być cofnięte! Disconnect desktop? + Rozłączyć komputer? No comment provided by engineer. @@ -1896,6 +1915,7 @@ To nie może być cofnięte! Discover via local network + Odkryj przez sieć lokalną No comment provided by engineer. @@ -2070,10 +2090,12 @@ To nie może być cofnięte! Encryption re-negotiation error + Błąd renegocjacji szyfrowania message decrypt error item Encryption re-negotiation failed. + Renegocjacja szyfrowania nie powiodła się. No comment provided by engineer. @@ -2108,6 +2130,7 @@ To nie może być cofnięte! Enter this device name… + Podaj nazwę urządzenia… No comment provided by engineer. @@ -2437,6 +2460,7 @@ To nie może być cofnięte! Faster joining and more reliable messages. + Szybsze dołączenie i bardziej niezawodne wiadomości. No comment provided by engineer. @@ -2536,6 +2560,7 @@ To nie może być cofnięte! Found desktop + Znaleziono komputer No comment provided by engineer. @@ -2865,6 +2890,7 @@ To nie może być cofnięte! Incognito groups + Grupy incognito No comment provided by engineer. @@ -2899,6 +2925,7 @@ To nie może być cofnięte! Incompatible version + Niekompatybilna wersja No comment provided by engineer. @@ -3073,6 +3100,7 @@ To jest twój link do grupy %@! Keep the app open to use it from desktop + Zostaw aplikację otwartą i używaj ją z komputera No comment provided by engineer. @@ -3137,14 +3165,17 @@ To jest twój link do grupy %@! Link mobile and desktop apps! 🔗 + Połącz mobile i komputerowe aplikacje! 🔗 No comment provided by engineer. Linked desktop options + Połączone opcje komputera No comment provided by engineer. Linked desktops + Połączone komputery No comment provided by engineer. @@ -3499,6 +3530,7 @@ To jest twój link do grupy %@! Not compatible! + Nie kompatybilny! No comment provided by engineer. @@ -3722,6 +3754,7 @@ To jest twój link do grupy %@! Paste desktop address + Wklej adres komputera No comment provided by engineer. @@ -4321,6 +4354,7 @@ To jest twój link do grupy %@! Scan QR code from desktop + Zeskanuj kod QR z komputera No comment provided by engineer. @@ -4545,6 +4579,7 @@ To jest twój link do grupy %@! Session code + Kod sesji No comment provided by engineer. @@ -5046,6 +5081,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This device name + Nazwa tego urządzenia No comment provided by engineer. @@ -5085,6 +5121,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom To hide unwanted messages. + Aby ukryć niechciane wiadomości. No comment provided by engineer. @@ -5248,10 +5285,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Unlink + Odłącz No comment provided by engineer. Unlink desktop? + Odłączyć komputer? No comment provided by engineer. @@ -5346,6 +5385,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use from desktop + Użyj z komputera No comment provided by engineer. @@ -5380,10 +5420,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Verify code with desktop + Zweryfikuj kod z komputera No comment provided by engineer. Verify connection + Zweryfikuj połączenie No comment provided by engineer. @@ -5393,6 +5435,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Verify connections + Zweryfikuj połączenia No comment provided by engineer. @@ -5407,6 +5450,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Via secure quantum resistant protocol. + Dzięki bezpiecznemu protokołowi odpornego kwantowo. No comment provided by engineer. @@ -5461,6 +5505,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Waiting for desktop... + Oczekiwanie na komputer... No comment provided by engineer. @@ -6010,6 +6055,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. author + autor member role @@ -6596,6 +6642,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. v%@ + v%@ No comment provided by engineer. @@ -6737,6 +6784,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX używa sieci lokalnej aby pozwolić na dostęp profilom czatu użytkownika przez aplikację komputerową na tej samej sieci. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index abd3d01d87..f4970446ae 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1196,6 +1196,7 @@ Connect automatically + Соединяться автоматически No comment provided by engineer. @@ -1914,6 +1915,7 @@ This cannot be undone! Discover via local network + Обнаружение по локальной сети No comment provided by engineer. @@ -2558,6 +2560,7 @@ This cannot be undone! Found desktop + Компьютер найден No comment provided by engineer. @@ -3527,6 +3530,7 @@ This is your link for group %@! Not compatible! + Несовместимая версия! No comment provided by engineer. @@ -5501,6 +5505,7 @@ To connect, please ask your contact to create another connection link and check Waiting for desktop... + Ожидается подключение компьютера... No comment provided by engineer. @@ -6050,6 +6055,7 @@ SimpleX серверы не могут получить доступ к Ваше author + автор member role diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index c860a24998..febd4c06a5 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Authentifizierung nicht verfügbar"; +/* member role */ +"author" = "Autor"; + /* No comment provided by engineer. */ "Auto-accept" = "Automatisch akzeptieren"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Verbinden"; +/* No comment provided by engineer. */ +"Connect automatically" = "Automatisch verbinden"; + /* No comment provided by engineer. */ "Connect incognito" = "Inkognito verbinden"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Gruppen entdecken und ihnen beitreten"; +/* No comment provided by engineer. */ +"Discover via local network" = "Lokales Netzwerk durchsuchen"; + /* No comment provided by engineer. */ "Do it later" = "Später wiederholen"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Für Konsole"; +/* No comment provided by engineer. */ +"Found desktop" = "Gefundener Desktop"; + /* No comment provided by engineer. */ "French interface" = "Französische Bedienoberfläche"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "Kein Text"; +/* No comment provided by engineer. */ +"Not compatible!" = "Nicht kompatibel!"; + /* No comment provided by engineer. */ "Notifications" = "Benachrichtigungen"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "Warten auf Bestätigung…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Es wird auf den Desktop gewartet..."; + /* No comment provided by engineer. */ "Waiting for file" = "Warte auf Datei"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index b71d510e72..470e23b4fc 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Authentification indisponible"; +/* member role */ +"author" = "auteur"; + /* No comment provided by engineer. */ "Auto-accept" = "Auto-accepter"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Se connecter"; +/* No comment provided by engineer. */ +"Connect automatically" = "Connexion automatique"; + /* No comment provided by engineer. */ "Connect incognito" = "Se connecter incognito"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Découvrir et rejoindre des groupes"; +/* No comment provided by engineer. */ +"Discover via local network" = "Rechercher sur le réseau"; + /* No comment provided by engineer. */ "Do it later" = "Faites-le plus tard"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Pour la console"; +/* No comment provided by engineer. */ +"Found desktop" = "Bureau trouvé"; + /* No comment provided by engineer. */ "French interface" = "Interface en français"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "aucun texte"; +/* No comment provided by engineer. */ +"Not compatible!" = "Non compatible !"; + /* No comment provided by engineer. */ "Notifications" = "Notifications"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "en attente de confirmation…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "En attente du bureau..."; + /* No comment provided by engineer. */ "Waiting for file" = "En attente du fichier"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index ac1f8b03d3..784acf1a9b 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Verificatie niet beschikbaar"; +/* member role */ +"author" = "auteur"; + /* No comment provided by engineer. */ "Auto-accept" = "Automatisch accepteren"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Verbind"; +/* No comment provided by engineer. */ +"Connect automatically" = "Automatisch verbinden"; + /* No comment provided by engineer. */ "Connect incognito" = "Verbind incognito"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; +/* No comment provided by engineer. */ +"Discover via local network" = "Ontdek via het lokale netwerk"; + /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Voor console"; +/* No comment provided by engineer. */ +"Found desktop" = "Desktop gevonden"; + /* No comment provided by engineer. */ "French interface" = "Franse interface"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "geen tekst"; +/* No comment provided by engineer. */ +"Not compatible!" = "Niet compatibel!"; + /* No comment provided by engineer. */ "Notifications" = "Meldingen"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "Wachten op bevestiging…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Wachten op desktop..."; + /* No comment provided by engineer. */ "Waiting for file" = "Wachten op bestand"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 3eb86d2a12..e0f4c4dee0 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- bardziej stabilne dostarczanie wiadomości.\n- nieco lepsze grupy.\n- i więcej!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- opcjonalnie powiadamiaj usunięte kontakty.\n- nazwy profili ze spacją.\n- i wiele więcej!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- wiadomości głosowe do 5 minut.\n- niestandardowy czas zniknięcia.\n- historia edycji."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nowy)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(to urządzenie v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Uwierzytelnianie niedostępne"; +/* member role */ +"author" = "autor"; + /* No comment provided by engineer. */ "Auto-accept" = "Automatycznie akceptuj"; @@ -548,6 +560,9 @@ /* No comment provided by engineer. */ "Back" = "Wstecz"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Zły adres komputera"; + /* integrity error chat item */ "bad message hash" = "zły hash wiadomości"; @@ -560,12 +575,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Zły identyfikator wiadomości"; +/* No comment provided by engineer. */ +"Better groups" = "Lepsze grupy"; + /* No comment provided by engineer. */ "Better messages" = "Lepsze wiadomości"; /* No comment provided by engineer. */ "Block" = "Zablokuj"; +/* No comment provided by engineer. */ +"Block group members" = "Blokuj członków grupy"; + /* No comment provided by engineer. */ "Block member" = "Zablokuj członka"; @@ -768,9 +789,15 @@ /* server test step */ "Connect" = "Połącz"; +/* No comment provided by engineer. */ +"Connect automatically" = "Łącz automatycznie"; + /* No comment provided by engineer. */ "Connect incognito" = "Połącz incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Połącz do komputera"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "połącz się z deweloperami SimpleX Chat."; @@ -801,9 +828,15 @@ /* No comment provided by engineer. */ "connected" = "połączony"; +/* No comment provided by engineer. */ +"Connected desktop" = "Połączony komputer"; + /* rcv group event chat item */ "connected directly" = "połącz bezpośrednio"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Połączony do komputera"; + /* No comment provided by engineer. */ "connecting" = "łączenie"; @@ -828,6 +861,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Łączenie z serwerem... (błąd: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Łączenie z komputerem"; + /* chat list item title */ "connecting…" = "łączenie…"; @@ -846,6 +882,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Prośba o połączenie wysłana!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Połączenie zakończone"; + /* No comment provided by engineer. */ "Connection timeout" = "Czas połączenia minął"; @@ -900,6 +939,9 @@ /* No comment provided by engineer. */ "Create" = "Utwórz"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Utwórz grupę używając losowego profilu."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Utwórz adres, aby ludzie mogli się z Tobą połączyć."; @@ -1173,6 +1215,15 @@ /* No comment provided by engineer. */ "Description" = "Opis"; +/* No comment provided by engineer. */ +"Desktop address" = "Adres komputera"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "Wersja aplikacji komputerowej %@ nie jest kompatybilna z tą aplikacją."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Urządzenia komputerowe"; + /* No comment provided by engineer. */ "Develop" = "Deweloperskie"; @@ -1236,9 +1287,15 @@ /* server test step */ "Disconnect" = "Rozłącz"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Rozłączyć komputer?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Odkrywaj i dołączaj do grup"; +/* No comment provided by engineer. */ +"Discover via local network" = "Odkryj przez sieć lokalną"; + /* No comment provided by engineer. */ "Do it later" = "Zrób to później"; @@ -1374,6 +1431,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "renegocjacja szyfrowania dozwolona dla %@"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Błąd renegocjacji szyfrowania"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Renegocjacja szyfrowania nie powiodła się."; + /* chat item text */ "encryption re-negotiation required" = "renegocjacja szyfrowania wymagana"; @@ -1404,6 +1467,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Wprowadź serwer ręcznie"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Podaj nazwę urządzenia…"; + /* placeholder */ "Enter welcome message…" = "Wpisz wiadomość powitalną…"; @@ -1605,6 +1671,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Szybko i bez czekania aż nadawca będzie online!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Szybsze dołączenie i bardziej niezawodne wiadomości."; + /* No comment provided by engineer. */ "Favorite" = "Ulubione"; @@ -1662,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Dla konsoli"; +/* No comment provided by engineer. */ +"Found desktop" = "Znaleziono komputer"; + /* No comment provided by engineer. */ "French interface" = "Francuski interfejs"; @@ -1866,6 +1938,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Grupy incognito"; + /* No comment provided by engineer. */ "Incognito mode" = "Tryb incognito"; @@ -1893,6 +1968,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Niekompatybilna wersja bazy danych"; +/* No comment provided by engineer. */ +"Incompatible version" = "Niekompatybilna wersja"; + /* PIN entry */ "Incorrect passcode" = "Nieprawidłowy pin"; @@ -2028,6 +2106,9 @@ /* No comment provided by engineer. */ "Joining group" = "Dołączanie do grupy"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Zostaw aplikację otwartą i używaj ją z komputera"; + /* No comment provided by engineer. */ "Keep your connections" = "Zachowaj swoje połączenia"; @@ -2064,6 +2145,15 @@ /* No comment provided by engineer. */ "Limitations" = "Ograniczenia"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Połącz mobile i komputerowe aplikacje! 🔗"; + +/* No comment provided by engineer. */ +"Linked desktop options" = "Połączone opcje komputera"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Połączone komputery"; + /* No comment provided by engineer. */ "LIVE" = "NA ŻYWO"; @@ -2319,6 +2409,9 @@ /* copied message info in history */ "no text" = "brak tekstu"; +/* No comment provided by engineer. */ +"Not compatible!" = "Nie kompatybilny!"; + /* No comment provided by engineer. */ "Notifications" = "Powiadomienia"; @@ -2462,6 +2555,9 @@ /* No comment provided by engineer. */ "Paste" = "Wklej"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Wklej adres komputera"; + /* No comment provided by engineer. */ "Paste image" = "Wklej obraz"; @@ -2846,6 +2942,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Zeskanuj kod QR"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Zeskanuj kod QR z komputera"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Zeskanuj kod bezpieczeństwa z aplikacji Twojego kontaktu."; @@ -2990,6 +3089,9 @@ /* No comment provided by engineer. */ "Servers" = "Serwery"; +/* No comment provided by engineer. */ +"Session code" = "Kod sesji"; + /* No comment provided by engineer. */ "Set 1 day" = "Ustaw 1 dzień"; @@ -3299,6 +3401,9 @@ /* notification title */ "this contact" = "ten kontakt"; +/* No comment provided by engineer. */ +"This device name" = "Nazwa tego urządzenia"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Ta grupa ma ponad %lld członków, potwierdzenia dostawy nie są wysyłane."; @@ -3320,6 +3425,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Aby się połączyć, Twój kontakt może zeskanować kod QR lub skorzystać z linku w aplikacji."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Aby ukryć niechciane wiadomości."; + /* No comment provided by engineer. */ "To make a new connection" = "Aby nawiązać nowe połączenie"; @@ -3416,6 +3524,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "O ile Twój kontakt nie usunął połączenia lub ten link był już użyty, może to być błąd - zgłoś go.\nAby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połączenia i sprawdź, czy masz stabilne połączenie z siecią."; +/* No comment provided by engineer. */ +"Unlink" = "Odłącz"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Odłączyć komputer?"; + /* No comment provided by engineer. */ "Unlock" = "Odblokuj"; @@ -3470,6 +3584,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Użyj dla nowych połączeń"; +/* No comment provided by engineer. */ +"Use from desktop" = "Użyj z komputera"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Użyj interfejsu połączeń iOS"; @@ -3491,12 +3608,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Używanie serwerów SimpleX Chat."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Zweryfikuj kod z komputera"; + +/* No comment provided by engineer. */ +"Verify connection" = "Zweryfikuj połączenie"; + /* No comment provided by engineer. */ "Verify connection security" = "Weryfikuj bezpieczeństwo połączenia"; +/* No comment provided by engineer. */ +"Verify connections" = "Zweryfikuj połączenia"; + /* No comment provided by engineer. */ "Verify security code" = "Weryfikuj kod bezpieczeństwa"; @@ -3515,6 +3644,9 @@ /* No comment provided by engineer. */ "via relay" = "przez przekaźnik"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Dzięki bezpiecznemu protokołowi odpornego kwantowo."; + /* No comment provided by engineer. */ "Video call" = "Połączenie wideo"; @@ -3554,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "oczekiwanie na potwierdzenie…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Oczekiwanie na komputer..."; + /* No comment provided by engineer. */ "Waiting for file" = "Oczekiwanie na plik"; diff --git a/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings index 92f6ba7764..8c0f71f744 100644 --- a/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX używa Face ID do lokalnego uwierzytelniania"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX używa sieci lokalnej aby pozwolić na dostęp profilom czatu użytkownika przez aplikację komputerową na tej samej sieci."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX potrzebuje dostępu do mikrofonu, w celu połączeń audio i wideo oraz nagrywania wiadomości głosowych."; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 7d094fcf20..03e4c4c508 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Аутентификация недоступна"; +/* member role */ +"author" = "автор"; + /* No comment provided by engineer. */ "Auto-accept" = "Автоприем"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Соединиться"; +/* No comment provided by engineer. */ +"Connect automatically" = "Соединяться автоматически"; + /* No comment provided by engineer. */ "Connect incognito" = "Соединиться Инкогнито"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Найдите и вступите в группы"; +/* No comment provided by engineer. */ +"Discover via local network" = "Обнаружение по локальной сети"; + /* No comment provided by engineer. */ "Do it later" = "Отложить"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Для консоли"; +/* No comment provided by engineer. */ +"Found desktop" = "Компьютер найден"; + /* No comment provided by engineer. */ "French interface" = "Французский интерфейс"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "нет текста"; +/* No comment provided by engineer. */ +"Not compatible!" = "Несовместимая версия!"; + /* No comment provided by engineer. */ "Notifications" = "Уведомления"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "ожидается подтверждение…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Ожидается подключение компьютера..."; + /* No comment provided by engineer. */ "Waiting for file" = "Ожидается прием файла"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 82217745f7..a9d315f680 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1519,4 +1519,9 @@ Veuillez patienter le temps que le fichier soit chargé depuis le mobile lié. La version de l\'application de bureau %s n\'est pas compatible avec cette application. Vérifier la connexion + Connexion automatique + En attente du bureau… + Bureau trouvé + Non compatible ! + Accessible via le réseau local \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index eb28386e5d..21a56d7e99 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -356,4 +356,116 @@ Adatbázis downgrade? Chat kiürítése Adatbázis titkosítási jelmondat meg lesz változtatva. + Kapcsolódás automatikusan + Adatbázis hiba + Adatbázis titkosítási jelmondat frissül és eltárolásra kerül a beállításokban. + Adatbázis ID + Adatbázis ID: %d + Adatbázis azonosítók és \"Transport Isolation\" opciók. + Az adatbázis titkosítás jelmondata megváltoztatásra és elmentésre kerül a Keystore-ban. + Az adatbázis titkosításra kerül és a jelmondat eltárolásra a beállításokban. + Szerver törlése + Eszközhitelesítés kikapcsolva. SimpleX zár kikapcsolása. + Letiltás + Letiltás minden csoportnak + Engedélyezve minden csoportnak + engedélyezve az ismerősnek + Eltűnő üzenetek tiltottak ebben a csoportban. + Azonosító törlés + %d hét + PC címe + %ds + Kézbesítési izagolások! + Eszközhitelesítés nincs bekapcsolva. Bekapcsolhatod a SimpleX zárat a Beállításokon keresztük, miután bekapcsoltad az eszközhitelesítést. + Titkosítás visszafejtési hiba + Eltűnik ekkor: %s + szerkesztve + Törlés + %d óra + %d hónap + Azonosító törlése? + Igazolások letiltása? + Az adatbázis jelmondat eltérő a Keystore-ba elmentettől. + Közvetlen üzenetek + E-mail + Letiltás mindenkinek + Fejlesztői eszközök + Adatbázis jelmondat + %d napok + Szétkapcsolva + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, lecserélheted. + %dó + %dhét + Felfedezés helyi hálózatomn keresztül + Helyi csoportok felfedezése és csatolakozás + %d üzenet moderálva %s által + Eltűnő üzenet + Ne hozz létre azonosítót + Ne mutasd ismét + SimpleX Zár kikapcsolása + e2e titkosított + ESZKÖZ + e2e titkosított videóhívás + közvetlen + PC + %d perc + %d ismerős(-ök) kiválasztva + Engedélyez + %dhónap + A közvetlen üzenetek tagok között titltottak ebben a csoportban. + %d perc + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Kérlek cseréld le exportálás előtt! + Igazolások letiltása csoportoknak? + nap + %d nap + Chat archív törlése? + Duplikálódott megjelenítési név! + Letiltás(felülírások megtartásával) + "Adatbázis upgrade" + %d üzenet blokkolva + Eltűnik ekkor + %d hét + engedélyezve számodra + Eltűnő üzenetek + Törlés + Törlés és ismerős értesítése + letiltva + %d másodperc + Minden fájl törlése + Az adatbázis titkosításra kerül. + Adatbázis jelmondat és exportálás + Az adatbázis titkosításra kerül és a jelmondat eltárolásra a Keystore-ban. + Automatikus üzenet törlés engedélyezve? + Törlés + az adatbázis verzió újabb, mint az app, de nincs lefelé migráció eddig: %s + Leírás + %d óra + %dp + Szétkapcsolás + Szerkesztés + Letiltás(csoport felülírások megtartásával) + %d csoportesemény + %d hónap + Csoport profil szerkesztése + e2e titkosított hanghívás + %d s + Decentralizált + Dekódolási hiba + Kép szerkesztése + Értesítéásek letiltása + Eszközök + Látható helyi hálózaton + Ne engedélyezd + Archívum törlése + Eltűnő üzenetek tiltottak ebben a chatben. + alap (%s) + duplikálódott üzenet + Számítógép leválasztása? + A számítógépes app verzió %s inem kompatibilis ezzel az appal. + Kézbesítés + %d fájl %s összméretben + Adatbázis jelmondat szükséges chat megnyitásához. + %dn + Engedélyeve mindenki számára + Kézbesítési izagolások kikapcsolva! \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index e61f040a89..a9549080a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1461,4 +1461,67 @@ Pokaż konsolę w nowym oknie Błąd renegocjacji szyfrowania Renegocjacja szyfrowania nie powiodła się. + Blokuj członków grupy + Utwórz grupę używając losowego profilu. + Połączony komputer + Łącz automatycznie + Adres komputera + Lepsze grupy + Komputer + Połączony do komputera + Łączenie z komputerem + Połączony telefon + Połączenie zakończone + Połącz do komputera + autor + Połączony do telefonu + Zły adres komputera + Aplikacja komputerowa %s nie jest kompatybilna z tą aplikacją. + Nowy urządzenie mobilne + Tylko jedno urządzenie może działać w tym samym czasie + Połącz mobile i komputerowe aplikacje! 🔗 + Dzięki bezpiecznemu protokołowi odpornego kwantowo. + Użyj z komputera w aplikacji mobilnej i zeskanuj kod QR.]]> + Aby ukryć niechciane wiadomości. + Niekompatybilna wersja + (nowy)]]> + Odłączyć komputer? + Połączone opcje komputera + Połączone komputery + Odkryj przez sieć lokalną + Grupy incognito + To urządzenie + %s został rozłączony]]> + Oczekiwanie na komputer… + Szybsze dołączenie i bardziej niezawodne wiadomości. + Połączone telefony + Nazwa tego urządzenia + %s]]> + Ładowanie pliku + Znaleziono komputer + Urządzenia komputerowe + Nie kompatybilny! + Połącz z telefonem + Użyj z komputera + Kod sesji + (to urządzenie v%s)]]> + Odłącz + Nazwa urządzenia zostanie udostępniona z połączonym klientem mobilnym. + Zweryfikuj kod na telefonie + Podaj nazwę urządzenia… + Błąd + Rozłącz + Wklej adres komputera + Zweryfikuj kod z komputera + Zeskanuj kod QR z komputera + Urządzenia + Odkrywane przez sieć lokalna + - opcjonalnie powiadamiaj usunięte kontakty. +\n- nazwy profili ze spacją. +\n- i wiele więcej! + Zeskanuj z telefonu + Zweryfikuj połączenia + Rozłączyć komputer? + Proszę poczekać na załadowanie pliku z połączonego telefonu + Zweryfikuj połączenie \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 91a37196ff..dc5771316c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1507,7 +1507,7 @@ Свяжите мобильное и настольное приложения! 🔗 %d сообщений помечено удалёнными Группа уже существует! - Использовать с компьютера в мобильном приложении и сосканируйте QR код]]> + Использовать с компьютера в мобильном приложении и сосканируйте QR код.]]> Уже соединяется! Несовместимая версия (новое)]]> @@ -1520,7 +1520,7 @@ Инкогнито группы Вступление в группу уже начато! %d сообщений модерировано членом %s - %s]]> + %s был отключен]]> Быстрое вступление и надежная доставка сообщений. Соединиться с самим собой? Связанные мобильные @@ -1599,4 +1599,11 @@ Показывать консоль в новом окне Проверять соединения Проверить соединение + Соединяться автоматически + Ожидается подключение… + %s]]> + Компьютер найден + Несовместимая версия! + автор + Найти через локальную сеть \ No newline at end of file From 6c05eb0ff3616fd44d5ff5d955bdd5f4af920936 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 23:21:38 +0000 Subject: [PATCH 161/174] directory: support group names with spaces (#3458) --- .../src/Directory/Events.hs | 12 +++++- .../src/Directory/Service.hs | 4 +- tests/Bots/DirectoryTests.hs | 42 ++++++++++++++----- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index dab9ceb77e..89231e4db1 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -14,6 +14,7 @@ module Directory.Events DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, + viewName, ) where @@ -158,4 +159,13 @@ directoryCmdP = DCListLastGroups_ -> DCListLastGroups <$> (A.space *> A.decimal <|> pure 10) DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) where - gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> A.takeTill (== ' ') + gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP + displayNameP = quoted '\'' <|> takeNameTill (== ' ') + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' + +viewName :: String -> String +viewName n = if ' ' `elem` n then "'" <> n <> "'" else n diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index a30638249f..fb187bbebe 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -156,7 +156,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { askConfirmation = do ugrId <- addGroupReg st ct g GRSPendingConfirmation sendMessage cc ct $ T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already submitted to the directory.\nTo confirm the registration, please send:" - sendMessage cc ct $ "/confirm " <> show ugrId <> ":" <> T.unpack displayName + sendMessage cc ct $ "/confirm " <> show ugrId <> ":" <> viewName (T.unpack displayName) badRolesMsg :: GroupRolesStatus -> Maybe String badRolesMsg = \case @@ -301,7 +301,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withSuperUsers $ \cId -> do sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> T.unpack displayName <> " " <> show gaId + sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> viewName (T.unpack displayName) <> " " <> show gaId deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 36b990ba35..b31d6f36fe 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -11,6 +11,7 @@ import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) import Control.Monad (forM_) +import Directory.Events (viewName) import Directory.Options import Directory.Service import Directory.Store @@ -28,6 +29,7 @@ directoryServiceTests = do it "should register group" testDirectoryService it "should suspend and resume group" testSuspendResume it "should join found group via link" testJoinGroup + it "should support group names with spaces" testGroupNameWithSpaces describe "de-listing the group" $ do it "should de-list if owner leaves the group" testDelistedOwnerLeaves it "should de-list if owner is removed from the group" testDelistedOwnerRemoved @@ -243,6 +245,24 @@ testJoinGroup tmp = cath <## "#privacy: new member dan is connected" ] +testGroupNameWithSpaces :: HasCallStack => FilePath -> IO () +testGroupNameWithSpaces tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + registerGroup superUser bob "Privacy & Security" "" + groupFound bob "Privacy & Security" + superUser #> "@SimpleX-Directory /suspend 1:'Privacy & Security'" + superUser <# "SimpleX-Directory> > /suspend 1:'Privacy & Security'" + superUser <## " Group suspended!" + bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is suspended and hidden from directory. Please contact the administrators." + groupNotFound bob "privacy" + superUser #> "@SimpleX-Directory /resume 1:'Privacy & Security'" + superUser <# "SimpleX-Directory> > /resume 1:'Privacy & Security'" + superUser <## " Group listing resumed!" + bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is listed in the directory again!" + groupFound bob "Privacy & Security" + testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -840,16 +860,16 @@ registerGroupId su u n fn gId ugId = do submitGroup :: TestCC -> String -> String -> IO () submitGroup u n fn = do - u ##> ("/g " <> n <> " " <> fn) - u <## ("group #" <> n <> " (" <> fn <> ") is created") - u <## ("to add members use /a " <> n <> " or /create link #" <> n) - u ##> ("/a " <> n <> " SimpleX-Directory admin") - u <## ("invitation to join the group #" <> n <> " sent to SimpleX-Directory") + u ##> ("/g " <> viewName n <> if null fn then "" else " " <> fn) + u <## ("group #" <> viewName n <> (if null fn then "" else " (" <> fn <> ")") <> " is created") + u <## ("to add members use /a " <> viewName n <> " or /create link #" <> viewName n) + u ##> ("/a " <> viewName n <> " SimpleX-Directory admin") + u <## ("invitation to join the group #" <> viewName n <> " sent to SimpleX-Directory") groupAccepted :: TestCC -> String -> IO String groupAccepted u n = do u <# ("SimpleX-Directory> Joining the group " <> n <> "…") - u <## ("#" <> n <> ": SimpleX-Directory joined the group") + u <## ("#" <> viewName n <> ": SimpleX-Directory joined the group") u <# ("SimpleX-Directory> Joined the group " <> n <> ", creating the link…") u <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online." u <## "" @@ -869,7 +889,7 @@ completeRegistrationId su u n fn welcomeWithLink gId ugId = do updateProfileWithLink :: TestCC -> String -> String -> Int -> IO () updateProfileWithLink u n welcomeWithLink ugId = do - u ##> ("/set welcome " <> n <> " " <> welcomeWithLink) + u ##> ("/set welcome " <> viewName n <> " " <> welcomeWithLink) u <## "description changed to:" u <## welcomeWithLink u <# ("SimpleX-Directory> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") @@ -879,13 +899,13 @@ notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO ( notifySuperUser su u n fn welcomeWithLink gId = do uName <- userName u su <# ("SimpleX-Directory> " <> uName <> " submitted the group ID " <> show gId <> ":") - su <## (n <> " (" <> fn <> ")") + su <## (n <> if null fn then "" else " (" <> fn <> ")") su <## "Welcome message:" su <## welcomeWithLink su .<## "members" su <## "" su <## "To approve send:" - let approve = "/approve " <> show gId <> ":" <> n <> " 1" + let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" su <# ("SimpleX-Directory> " <> approve) approveRegistration :: TestCC -> TestCC -> String -> Int -> IO () @@ -894,7 +914,7 @@ approveRegistration su u n gId = approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO () approveRegistrationId su u n gId ugId = do - let approve = "/approve " <> show gId <> ":" <> n <> " 1" + let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" su #> ("@SimpleX-Directory " <> approve) su <# ("SimpleX-Directory> > " <> approve) su <## " Group approved!" @@ -948,7 +968,7 @@ groupFoundN count u name = do u #> ("@SimpleX-Directory " <> name) u <# ("SimpleX-Directory> > " <> name) u <## " Found 1 group(s)" - u <#. ("SimpleX-Directory> " <> name <> " (") + u <#. ("SimpleX-Directory> " <> name) u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") From 1902b692f5354541e1ddb310ff82c0681dd12b52 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 00:13:31 +0000 Subject: [PATCH 162/174] 5.4: ios 183, android 162, desktop 18 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ apps/multiplatform/gradle.properties | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3d78512e3f..a9990f56b3 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1502,7 +1502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1545,7 +1545,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1626,7 +1626,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1658,7 +1658,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1690,7 +1690,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1736,7 +1736,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 9c817f08f4..570e982e22 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.4 -android.version_code=161 +android.version_name=5.4 +android.version_code=162 -desktop.version_name=5.4-beta.4 -desktop.version_code=17 +desktop.version_name=5.4 +desktop.version_code=18 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From de1c8855015a191cc31547c67c405b972619e3ff Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 11:22:02 +0000 Subject: [PATCH 163/174] ios: 5.4 build 184: switch to GHC 8.10.7 (9.6.3 crashes on older iPhone models), fix Connect to desktop closing when switching to QR code scan --- .../RemoteAccess/ConnectDesktopView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 1f120860e2..e934bbc89a 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -208,7 +208,7 @@ struct ConnectDesktopView: View { Section("Found desktop") { Text("Waiting for desktop...").italic() Button { - disconnectDesktop(.dismiss) + disconnectDesktop() } label: { Label("Scan QR code", systemImage: "qrcode") } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a9990f56b3..e799041d04 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -120,11 +120,11 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; - 5CD67B962B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */; }; - 5CD67B972B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */; }; - 5CD67B982B11416700C510B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B932B11416600C510B1 /* libffi.a */; }; - 5CD67B992B11416700C510B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B942B11416600C510B1 /* libgmp.a */; }; - 5CD67B9A2B11416700C510B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B952B11416700C510B1 /* libgmpxx.a */; }; + 5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9B2B120ADF00C510B1 /* libgmp.a */; }; + 5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */; }; + 5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */; }; + 5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */; }; + 5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9F2B120ADF00C510B1 /* libffi.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -403,11 +403,11 @@ 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = ""; }; 5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = ""; }; - 5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a"; sourceTree = ""; }; - 5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a"; sourceTree = ""; }; - 5CD67B932B11416600C510B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CD67B942B11416600C510B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CD67B952B11416700C510B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CD67B9B2B120ADF00C510B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a"; sourceTree = ""; }; + 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a"; sourceTree = ""; }; + 5CD67B9F2B120ADF00C510B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -511,13 +511,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CD67B972B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a in Frameworks */, + 5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */, + 5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */, + 5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */, + 5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CD67B982B11416700C510B1 /* libffi.a in Frameworks */, - 5CD67B992B11416700C510B1 /* libgmp.a in Frameworks */, - 5CD67B962B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a in Frameworks */, - 5CD67B9A2B11416700C510B1 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -579,11 +579,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CD67B932B11416600C510B1 /* libffi.a */, - 5CD67B942B11416600C510B1 /* libgmp.a */, - 5CD67B952B11416700C510B1 /* libgmpxx.a */, - 5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */, - 5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */, + 5CD67B9F2B120ADF00C510B1 /* libffi.a */, + 5CD67B9B2B120ADF00C510B1 /* libgmp.a */, + 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */, + 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */, + 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */, ); path = Libraries; sourceTree = ""; @@ -1502,7 +1502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1545,7 +1545,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1626,7 +1626,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1658,7 +1658,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1690,7 +1690,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1736,7 +1736,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; From b9dd2f45c92f73615be745f3c81079ac81d16587 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 12:51:05 +0000 Subject: [PATCH 164/174] rfc: post-quantum resistant augmented double ratchet algorithm (#3463) * rfc: post-quantum resistant augmented double ratchet algorithm * update doc * replace Kyber with "some KEM" --- docs/rfcs/2023-09-30-pq-double-ratchet.md | 224 ++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/rfcs/2023-09-30-pq-double-ratchet.md diff --git a/docs/rfcs/2023-09-30-pq-double-ratchet.md b/docs/rfcs/2023-09-30-pq-double-ratchet.md new file mode 100644 index 0000000000..255051320d --- /dev/null +++ b/docs/rfcs/2023-09-30-pq-double-ratchet.md @@ -0,0 +1,224 @@ +# Post-quantum resistant augmented double ratchet algorithm + +- [Overview](#overview) +- [Problem](#problem) +- [Comparison of the proposed solutions](#comparison-of-the-proposed-solutions) + - [PQXDH for post-quantum key agreement](#pqxdh-for-post-quantum-key-agreement) (Signal) + - [Hybrid Signal protocol for post-quantum encryption](#hybrid-signal-protocol-for-post-quantum-encryption) (Tutanota) + - [Problems with ML-KEM / Kyber](#problems-with-ml-kem--kyber) +- [Proposed solution: augmented double ratchet algorithm](#proposed-solution-augmented-double-ratchet-algorithm) +- [Double ratchet with encrypted headers augmented with double PQ KEM](#double-ratchet-with-encrypted-headers-augmented-with-double-pq-kem) + - [Initialization](#initialization) + - [Encrypting messages](#encrypting-messages) + - [Decrypting messages](#decrypting-messages) +- [Alternative approach: CTIDH](#alternative-approach-ctidh) +- [Implementation considerations for SimpleX Chat](#implementation-considerations-for-simplex-chat) +- [Summary](#summary) +- [Notes](#notes) + +## 1. Overview + +Currently SimpleX Chat uses [double-ratchet with header encryption](https://signal.org/docs/specifications/doubleratchet/#double-ratchet-with-header-encryption) to provide end-to-end encryption to messages and files. This document proposes a way to augment this algorithm with post-quantum key encapsulation mechanism (KEM) to make it resistant to quantum computers. + +This document is purposefully written in an informal style to make it understandable for general audience with some technical, but without mathematical background. It does not compromise on the technical accuracy though. + +## 2. Problem + +It is a reasonable assumption that "record-now-decrypt-later" attacks are ongoing, so the users want to use cryptographic schemes for end-to-end encryption that are augmented with some post-quantum algorithm that is believed to be resistant to quantum computers. + +Double-ratchet algorithm is a state of the art solution for end to end encryption offering a set of qualities that is unmatched by any other algorithm: + +- perfect forward secrecy, i.e. compromise of session or long term keys does not lead to the ability to decrypt any of the past messages. +- deniability (also known as repudiation), i.e. the fact that the recipient of the message cannot prove to a third party that the sender actually sent this message [1]. +- break-in recovery [2] (also know as post-compromise security or future secrecy), i.e. the ability of the end-to-end encryption security to recover from the compromise of the long term keys. This is achieved by generating a new random key pair whenever a new DH key is received (DH ratchet step). + +It is desirable to preserve all these qualities when augmenting the algorithm with a post-quantum algorithm, and having these qualities resistant (or "believed to be" resistant [3]) to both conventional and quantum computers. + +## Comparison of the proposed solutions + +### PQXDH for post-quantum key agreement + +[The solution](https://signal.org/docs/specifications/pqxdh/) recently [introduced by Signal](https://signal.org/blog/pqxdh/) augments the initial key agreement ([X3DH](https://signal.org/docs/specifications/x3dh/)) that is made prior to double ratchet algorithm. This is believed to provide protection from "record-now-decrypt-later" attack, but if the attacker at any point obtains long term keys from any of the devices, the break-in recovery will not be post-quantum resistant, and the attacker would be able to decrypt all the subsequent messages. + +In addition to that, the authentication of parties in the proposed scheme is also not post-quantum resistant, although this is not part of double ratchet algorithm. + +### Hybrid Signal protocol for post-quantum encryption + +[The solution](https://eprint.iacr.org/2021/875.pdf) [proposed by Tutanota](https://tutanota.com/blog/posts/pqmail-update/) aims to preserve the break-in recovery property of double ratchet, but in doing so it: +- replaces rather than augments DH key agreement with post-quantum KEM mechanism, making it potentially vulnerable to conventional computers. +- adds signature to the DH ratchet step, to compensate for not keeping DH key agreement, but losing the deniability property for some of the messages. + +### Problems with ML-KEM / Kyber + +ML-KEM / Kyber used in both Signal and Tutanota schemes is the chosen algorithm by NIST, but its standardisation process raised some concerns amongst the experts: + +- hashing of random numbers that was present in the original submission was removed from the standardised version of the algorithm. See lines 304-314 in the published spec (https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.203.ipd.pdf) and also the linked discussion on the subject: https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/WFRDl8DqYQ4. This decision polarised the experts, with some of them saying that it effectively created a backdoor. +- 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 Daniel Bernstein](https://blog.cr.yp.to/20231003-countcorrectly.html). + +## Proposed solution: augmented double ratchet algorithm + +We will augment the double ratchet algorithm with post-quantum KEM mechanism, preserving all properties of the double ratchet algorithm. + +It is possible, because although double ratchet uses DH (which is a non-interactive key exchange [4]), it uses it "interactively", when the new DH keys are generated by both parties in turns. Parties of double-ratchet encrypted communication can run one or two post-quantum key encapsulation mechanisms in parallel with DH key agreement on each DH ratchet step, making break-in recovery of double ratchet algorithm post-quantum resistant (unlike Signal PQXDH scheme), without losing deniability or resistance to conventional computers (unlike Tutanota scheme). + +Specifically, it is proposed to augment [double ratchet with encrypted headers](https://signal.org/docs/specifications/doubleratchet/#double-ratchet-with-header-encryption) with some post-quantum key encapsulation mechanism (KEM) as described below. A possible algorithm for PQ KEM is [NTRU-prime](https://ntruprime.cr.yp.to), that is currently adopted in SSH and has available implementations. It is important though that the proposed scheme can be used with any PQ KEM algorithm. + +The downside of the proposed scheme is its substantial size overhead, as the encapsulation key and/or encapsulated shared secret are added to the header of each message. For the scheme described below, when both the encapsulation key and encapsulated secret are added to each message header, NTRU-prime adds ~2-4kb to each message (depending on the key size and the chosen variant). See [this table](https://ntruprime.cr.yp.to/security.html) for key and ciphertext sizes and the assessment of the security level for various key sizes. + +## Double ratchet with encrypted headers augmented with double PQ KEM + +Algorithm below assumes that in addition to shared secret from the initial key agreement, there will be an encapsulation key available from the party that published its keys (Bob). + +### Initialization + +The double ratchet initialization is defined in pseudocode as follows: + +``` +// Alice obtained Bob's keys and initializes ratchet first +def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, bob_pq_kem_encapsulation_key): + state.DHRs = GENERATE_DH() + state.DHRr = bob_dh_public_key + // below added for post-quantum KEM + 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 + // 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) + state.CKr = None + state.Ns = 0 + state.Nr = 0 + state.PN = 0 + state.MKSKIPPED = {} + state.HKs = shared_hka + state.HKr = None + state.NHKr = shared_nhkb + +// Bob initializes ratchet second, having received Alice's connection request +def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob_pq_kem_key_pair): + state.DHRs = bob_dh_key_pair + state.DHRr = None + // below added for KEM + state.PQRs = bob_pq_kem_key_pair + state.PQRr = None + state.PQRss = None + state.PQRenc_ss = None + // above added for KEM + state.RK = SK + state.CKs = None + state.CKr = None + state.Ns = 0 + state.Nr = 0 + state.PN = 0 + state.MKSKIPPED = {} + state.HKs = None + state.NHKs = shared_nhkb + state.HKr = None + state.NHKr = shared_hka +``` + +`GENERATE_PQKEM` generates decapsulation/encapsulation key pair. + +`PQKEM-ENC` is key encapsulation algorithm. + +Other than commented lines, the above adds parameters `bob_pq_kem_encapsulation_key` and `bob_pq_kem_key_pair` to the ratchet intialization. Otherwise it is identical to the original double ratchet initialization. + +### Encrypting messages + +``` +def RatchetEncryptPQ2HE(state, plaintext, AD): + state.CKs, mk = KDF_CK(state.CKs) + // encapsulation key from PQRs and encapsulated shared secret is added to header + header = HEADER_PQ2( + dh = state.DHRs.public, + 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 + return enc_header, ENCRYPT(mk, plaintext, CONCAT(AD, enc_header)) +``` + +Other than adding encapsulation key and encapsulated shared secret into the header, the above is identical to the original double ratchet message encryption step. + +As an optimization, to save space, it might be possible to add encapsulation key and encapsulated secret only when they change. The downside of this optimization would be that it will be impossible to decrypt the message when the message that has them is skipped or lost, compromising the ability of double ratchet to manage skipped messages. + +### Decrypting messages + +``` +def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD): + plaintext = TrySkippedMessageKeysHE(state, enc_header, ciphertext, AD) + if plaintext != None: + return plaintext + header, dh_ratchet = DecryptHeader(state, enc_header) // DecryptHeader is the same as in double ratchet specification + if dh_ratchet: + SkipMessageKeysHE(state, header.pn) // SkipMessageKeysHE is the same as in double ratchet specification + DHRatchetPQ2HE(state, header) + SkipMessageKeysHE(state, header.n) + state.CKr, mk = KDF_CK(state.CKr) + state.Nr += 1 + return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header)) + +def DHRatchetPQ2HE(state, header): + state.PN = state.Ns + state.Ns = 0 + state.Nr = 0 + state.HKs = state.NHKs + state.HKr = state.NHKr + state.DHRr = header.dh + // save new encapsulation key from header + state.PQRr = header.encaps + // decapsulate shared secret from header - KEM #2 + ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss) + // 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 + // 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) +``` + +`PQKEM-DEC` is key decapsulation algorithm. + +`DHRatchetPQ2HE` augments both DH agreements with decapsulated shared secret from the received header and with the new shared secret, respectively. The new shared secret together with the new encapsulation key are saved in the state and will be added to the header in the next sent message. + +Other than augmenting DH key agreements with the shared secrets from KEM, the above is identical to the original double ratchet DH ratchet step. + +It is worth noting that while DH agreements work as ping-pong, when the new received DH key is used for both DH agreements (and only the sent DH key is updated for the second DH key agreement), PQ KEM agreements in the proposed scheme work as a "parallel ping-pong", with two balls in play all the time (two KEM agreements run in parallel). + +## Alternative approach: CTIDH + +Instead of using KEM, we can consider using [CTIDH](https://ctidh.isogeny.org). The advantage is a smaller key size, and also as CTIDH is non-interactive [4], there is no need to run two key agreements in parallel, the PQ keys would simply augment DH keys and would be used in the same way. + +The main downside is the absense of performance-efficient implementation for aarch64 architecture. + +## 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. + +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. + +Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the invitation link. As encapsulation key is large, it may be inconvenient to share it in the link in some contexts. + +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). + +## 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. + +## Notes + +[1] This is often misunderstood to mean that the recipient cannot prove that the sender sent the message at all, which is incorrect, as the recipient has the proof that either themselves or the sender encrypted the message, and as they know that the recipient themselves did not encrypt it, therefore the sender did. So the communication is secure and authenticated for the parties, without providing a proof to a third party. + +[2] The term "break-in recovery" is used in this document, consistent with the terminology of the double ratchet algorithm specification, and also because it can be used to mean both the quality of being able to recover from the compromise and the actual process used to recover. + +[3] This is important to remember that no existing post-quantum algorithms are proven to be resistant to quantum or even conventional computers, therefore the correct approach is to augment rather than replace existing algorithms with the post-quantum ones. + +[4] Non-interactive key exchange is a type of key agreement that allows both parties to generate key pairs independently, without input from another parties, and then use the public keys from each other to compute the same shared secret. From 962964a73d3fe9a2a83bbccebc7e15e4335629e7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 14:15:41 +0000 Subject: [PATCH 165/174] docs: update downloads page --- docs/DOWNLOADS.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index a1576c355e..a43a694097 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,13 +1,13 @@ --- title: Download SimpleX apps permalink: /downloads/index.html -revision: 01.10.2023 +revision: 25.11.2023 --- -| Updated 01.10.2023 | Languages: EN | +| Updated 25.11.2023 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.3.2. +The latest stable version is v5.4.0. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.3/simplex-desktop-windows-x86-64.msi) (BETA). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-windows-x86_64.msi). ## Mobile apps **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-windows-x86-64). From 1e6891e222837cdac990a7187978b10c988f4d40 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 18:27:41 +0000 Subject: [PATCH 166/174] blog: v5.4 announcement (#3457) * blog: v5.4 announcement * update * corrections Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * add images, CLI section * images * preview, readme * correction --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- README.md | 10 +- ...desktop-quantum-resistant-better-groups.md | 128 ++++++++++++++---- blog/README.md | 22 +++ blog/images/20231125-block.png | Bin 0 -> 1137734 bytes blog/images/20231125-desktop1.png | Bin 0 -> 43571 bytes blog/images/20231125-desktop2.png | Bin 0 -> 31992 bytes blog/images/20231125-desktop3.png | Bin 0 -> 115839 bytes blog/images/20231125-desktop4.png | Bin 0 -> 450951 bytes blog/images/20231125-group1.png | Bin 0 -> 798614 bytes blog/images/20231125-group2.png | Bin 0 -> 548108 bytes blog/images/20231125-mobile1.png | Bin 0 -> 400610 bytes blog/images/20231125-mobile1a.png | Bin 0 -> 268967 bytes blog/images/20231125-mobile2.png | Bin 0 -> 766821 bytes blog/images/20231125-mobile3.png | Bin 0 -> 204927 bytes blog/images/20231125-mobile4.png | Bin 0 -> 204082 bytes blog/images/arrow.png | Bin 0 -> 8789 bytes .../src/_includes/blog_previews/20231125.html | 15 ++ 17 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 blog/images/20231125-block.png create mode 100644 blog/images/20231125-desktop1.png create mode 100644 blog/images/20231125-desktop2.png create mode 100644 blog/images/20231125-desktop3.png create mode 100644 blog/images/20231125-desktop4.png create mode 100644 blog/images/20231125-group1.png create mode 100644 blog/images/20231125-group2.png create mode 100644 blog/images/20231125-mobile1.png create mode 100644 blog/images/20231125-mobile1a.png create mode 100644 blog/images/20231125-mobile2.png create mode 100644 blog/images/20231125-mobile3.png create mode 100644 blog/images/20231125-mobile4.png create mode 100644 blog/images/arrow.png create mode 100644 website/src/_includes/blog_previews/20231125.html diff --git a/README.md b/README.md index 66465926dd..254a66c2bc 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,8 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[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). + [Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md). [Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md). @@ -366,13 +368,13 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A - ✅ Message delivery confirmation (with sender opt-out per contact). - ✅ Desktop client. - ✅ Encryption of local files stored in the app. -- 🏗 Using mobile profiles from the desktop app. +- ✅ Using mobile profiles from the desktop app. +- 🏗 Improve experience for the new users. +- 🏗 Post-quantum resistant key exchange in double ratchet protocol. +- 🏗 Large groups, communities and public channels. - Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic). -- Post-quantum resistant key exchange in double ratchet protocol. -- Large groups, communities and public channels. - Privacy & security slider - a simple way to set all settings at once. - Improve sending videos (including encryption of locally stored videos). -- Improve experience for the new users. - SMP queue redundancy and rotation (manual is supported). - Include optional message into connection request sent via contact address. - Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.). diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md index aef4dddf64..4fbfc400ad 100644 --- a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md +++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md @@ -2,48 +2,128 @@ layout: layouts/article.html title: "SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups." date: 2023-11-25 -preview: SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. -# image: images/20231125-remote-desktop.jpg -draft: true -imageWide: true -permalink: "/blog/20231125-simplex-chat-v5-4-quantum-resistant-mobile-from-desktop-better-groups.html" +previewBody: blog_previews/20231125.html +image: images/20231125-mobile2.png +permalink: "/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html" --- -TODO stub for release announcement - # SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. **Published:** Nov 25, 2023 -- [Quick start: control SimpleX Chat mobile app from CLI](#⚡️-quick-start-use-profiles-in-SimpleX-Chat-mobile-from-desktop-app) -- [What's the problem](#whats-the-problem) -- [Why didn't we use some existing solution?](#why-didnt-we-use-some-existing-solution) +**What's new in v5.4:** +- [Link mobile and desktop apps via secure quantum-resistant protocol](#link-mobile-and-desktop-apps-via-secure-quantum-resistant-protocol). + - ⚡️ Quick start - how to use it. + - How does it work? + - 🤖 Connecting to remote CLI. +- [Better groups](#better-groups). + - [Faster to join and more reliable](#faster-to-join-with-more-reliable-message-delivery). + - [New group features](#new-group-features): + - create groups with incognito profile, + - block group members to reduce noise, + - prohibit files and media in a group. +- [Better calls](#better-calls): faster to connect, with screen sharing on desktop. -## ⚡️ Quick start: use profiles in SimpleX Chat mobile from desktop app +There are many [other improvements](#other-improvements) and fixes in this release: + - profile names now allow spaces. + - when you delete contacts, they are optionally notified. + - previously used and your own SimpleX links are recognized by the app. + - and more - see the [release notes](https://github.com/simplex-chat/simplex-chat/releases/tag/v5.4.0). -## What's the problem? +## Link mobile and desktop apps via secure quantum-resistant protocol -Currently you cannot use the same SimpleX Chat profile on mobile and desktop devices. Even though you can use small groups instead of direct conversations as a workaround, it is quite inconvenient – read status and delivery receipts become much less useful. +This release allows to use chat profiles you have in mobile app from desktop app. -So, we need a way to use the same profile on desktop as we use on mobile. +This is only possible when both devices are connected to the same local network. To send and receive messages mobile app has to be connected to the Internet. -If SimpleX Chat profile was stored on the server, the problem would have been simpler - you can just connect to it from another device. But even in this case, accessing the conversation history without compromising the security of double ratchet end-to-end encryption is not really possible. +### ⚡️ Quick start - how to use it -So we decided to implement the solution that is similar to what WhatsApp and WeChat did in early days - allowing a desktop device access profile on mobile via network. Unlike these big apps, we don't use the server to connect to mobile, but instead use the connection over the local network. +**On desktop** -The downside of this approach is that mobile device has to be with you and connected to the same local network (and in case of iOS, the app has to be in the foreground as well). But the upside is that we the connection can be secure, and that you do not have to have a copy of your profiles on the desktop, which usually has lower security. +If you don't have desktop app installed yet, [download it](https://simplex.chat/downloads/) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. -## Why didn't we use some existing solution? +Then in desktop app settings choose *Link a mobile* - it will show a QR code. -While there are several existing protocols for remote access, all of them are vulnerable to spoofing and man + - in many cases support of sending files and images is not very good, and sending videos and large files is simply impossible. There are currently these problems: +**On mobile** -- the sender has to be online for file transfer to complete, once it was confirmed by the recipient. -- when the file is sent to the group, the sender will have to transfer it separately to each member, creating a lot of traffic. -- the file transfer is slow, as it is sent in small chunks - approximately 16kb per message. +In mobile app settings choose *Use from desktop*, scan the QR code and verify session code when it appears on both devices - it should be the same. Verifying session code confirms that the devices are connected directly via a secure encrypted connection. There is an option to verify this code on subsequent connections too, but by default it is only required once. -As a result, we limited the supported size of files in the app to 8mb. Even for supported files, it is quite inefficient for sending any files to large groups. + + +The devices are now paired, and you can continue using all mobile profiles from desktop. + +If it is an Android app, you can move the app to background, but iOS app has to remain open. In both cases, while you are using mobile profiles from desktop, you won't be able to use mobile app. + +The subsequent connections happen much faster - by default, the desktop app broadcasts its session address to the network, in encrypted form, and mobile app connects to it once you choose *Use from desktop* in mobile app settings. + +### How does it work? + +The way we designed this solution avoided any security compromises, and the end-to-end encryption remained as secure as it was - it uses [double-ratchet algorithm](../docs/GLOSSARY.md#double-ratchet-algorithm), with [perfect forward secrecy](../docs/GLOSSARY.md#forward-secrecy), [post-compromise security](../docs/GLOSSARY.md#post-compromise-security) and deniability. + +This solution is similar to WhatsApp and WeChat. But unlike these apps, no server is involved in the connection between mobile and desktop. The connection itself uses a new SimpleX Remote Control Protocol (XRCP) based on secure TLS 1.3 and additional quantum-resistant encryption inside TLS. You can read XRCP protocol specification and threat model in [this document](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2023-10-25-remote-control.md). We will soon be [augmenting double ratchet](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2023-09-30-pq-double-ratchet.md) to be resistant to quantum computers as well. + +The downside of this approach is that mobile device has to be connected to the same local network as desktop. But the upside is that the connection is secure, and you do not need to have a copy of all your data on desktop, which usually has lower security than mobile. + +Please note, that the files you send, save or play from desktop app, and also images you view are automatically saved on your desktop device (encrypted by default except videos). To remove all these files you can unlink the paired mobile device from the desktop app settings – there will be an option soon allowing to remove the files without unlinking the mobile. + +### 🤖 Connecting to remote SimpleX CLI + +*Warning*: this section is for technically advanced users! + +If you run SimpleX CLI on a computer in another network - e.g., in the cloud VM or on a Raspberry Pi at home while you are at work, you can also use if from desktop via SSH tunnel. Below assumes that you have remote machine connected via SSH and CLI running there - you can use `tmux` for it to keep running when you are not connected via ssh. + +Follow these steps to use remote CLI from desktop app: +1. On the remote machine add the IP address of your desktop to the firewall rules, so that when CLI tries to connect to this address, it connects to `localhost` instead: `iptables -t nat -A OUTPUT -p all -d 192.168.1.100 -j DNAT --to-destination 127.0.0.1` (replace `192.168.1.100` with the actual address of your desktop, and make sure it is not needed for something else on your remote machine). +2. Also on the remote machine, run Simplex CLI with the option `--device-name 'SimpleX CLI'`, or any other name you like. You can also use the command `/set device name ` to set it for the CLI. +3. Choose *Link a mobile* in desktop app settings, note the port it shows under the QR code, and click "Share link". +4. Run ssh port forwarding on desktop computer to let your remote machine connect to desktop app: `ssh -R 12345:127.0.0.1:12345 -N user@example.com` where `12345` is the port on which desktop app is listening for the connections from step 3, `example.com` is the hostname or IP address of your remote machine, and `user` is some username on remote machine. You can run port forwarding in the background by adding `-f` option. +5. On the remote machine, run CLI command `/connect remote ctrl `, where `` is the desktop session address copied in step 3. You should run this command within 1 minute from choosing *Link a mobile*. +6. If the connection is successful, the CLI will ask you to verify the session code (you need to copy and paste the command) with the one shown in desktop app. Once you use `/verify remote ctrl ` command, CLI can be used from desktop app. +7. To stop remote session use `/stop remote ctrl` command. + +## Better groups + +### Faster to join, with more reliable message delivery + +We improved the protocols for groups, by making joining groups much faster, and also by adding message forwarding. Previously, the problem was that until a new member connects directly with each existing group member, they did not see each other messages in the group. The problem is explained in detail in [this video](https://www.youtube.com/watch?v=7yjQFmhAftE&t=1104s) at 18:23. + +With v5.4, the admin who added members to the group forwards messages to and from the new members until they connect to the existing members. So you should no longer miss any messages and be surprised with replies to messages you have never seen once you and new group members upgrade. + +### New group features + + + +**Create groups with incognito profile** + +Previously, you could only create groups with your main profile. This version allows creating groups with incognito profile directly. You will not be able to add your contacts, they can only join via group link. + +**Block group members to reduce noise** + +You now can block messages from group members that send too many messages, or the messages you don't won't to see. Blocked members won't know that you blocked their messages. When they send messages they will appear in the conversation as one line, showing how many messages were blocked. You can reveal them, or delete all sequential blocked messages at once. + +**Prohibit files and media in a group** + +Group owners now have an option to prohibit sending files and media. This can be useful if you don't won't any images shared, and only want to allow text messages. + +## Better calls + +Calls in SimpleX Chat still require a lot of work to become stable, but this version improved the speed of connecting calls, and they should work for more users. + +We also added screen sharing in video calls to desktop app. + +## Other improvements + +This version also has many small and large improvements to make the app more usable and reliable. + +The new users and group profiles now allow spaces in the names, to make them more readable. To message these contacts in CLI you need to use quotes, for example, `@'John Doe' Hello!`. + +When you delete contacts, you can notify them - to let them know they can't message you. + +When you try to connect to the same contact or join the same group, or connect via your own link, the app will recognize it and warn you, or simply open the correct conversation. + +You can find the full list of fixed bugs and small improvements in the [release notes](https://github.com/simplex-chat/simplex-chat/releases/tag/v5.4.0). ## SimpleX platform diff --git a/blog/README.md b/blog/README.md index 129ba2c80d..815009139f 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,27 @@ # Blog +Nov 25, 2023 [SimpleX Chat v5.4 released](./20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md) + +- Link mobile and desktop apps via secure quantum-resistant protocol. 🔗 +- Better groups: + - faster to join and more reliable. + - create groups with incognito profile. + - block group members to reduce noise. + - prohibit files and media in a group. +- Better calls: faster to connect, with screen sharing on desktop. +- Many other improvements. + +--- + +Sep 25, 2023 [SimpleX Chat v5.3 released](./20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md) + +- new desktop app! 💻 +- directory service and other group improvements. +- encrypted local files and media with forward secrecy. +- simplified incognito mode. + +--- + July 22, 2023 [SimpleX Chat v5.2 released](./20230722-simplex-chat-v5-2-message-delivery-receipts.md) **What's new in v5.2:** diff --git a/blog/images/20231125-block.png b/blog/images/20231125-block.png new file mode 100644 index 0000000000000000000000000000000000000000..1928b7a6fd2d59a486798ae05a774a6b7c1f54dc GIT binary patch literal 1137734 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